Skip to content

Commit eb5a429

Browse files
committed
feat(amazonq): Auto update language servers when new versions are available
Problem: - Only one version is installed right now Solution: - Automatically update when a new version is available in the manifest
1 parent 86a6e6e commit eb5a429

File tree

8 files changed

+533
-35
lines changed

8 files changed

+533
-35
lines changed

packages/amazonq/src/lsp/activation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import { startLanguageServer } from './client'
1111
export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
1212
const serverPath = ctx.asAbsolutePath('resources/qdeveloperserver')
1313
const clientPath = ctx.asAbsolutePath('resources/qdeveloperclient')
14-
await new AmazonQLSPDownloader(serverPath, clientPath).tryInstallLsp()
15-
await startLanguageServer(ctx, path.join(serverPath, 'aws-lsp-codewhisperer.js'))
14+
const installedAndReady = await new AmazonQLSPDownloader(serverPath, clientPath).tryInstallLsp()
15+
if (installedAndReady) {
16+
await startLanguageServer(
17+
ctx,
18+
process.env.AWS_LANGUAGE_SERVER_OVERRIDE ?? path.join(serverPath, 'aws-lsp-codewhisperer.js')
19+
)
20+
}
1621
}

packages/amazonq/src/lsp/download.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
tryRemoveFolder,
1111
fs,
1212
Manifest,
13+
globals,
1314
} from 'aws-core-vscode/shared'
1415

1516
const manifestURL = 'https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json'
@@ -19,7 +20,7 @@ export class AmazonQLSPDownloader extends LspDownloader {
1920
private readonly serverPath: string,
2021
private readonly clientPath: string
2122
) {
22-
super(manifestURL)
23+
super(manifestURL, 'codewhisperer')
2324
}
2425

2526
async isLspInstalled(): Promise<boolean> {
@@ -46,26 +47,38 @@ export class AmazonQLSPDownloader extends LspDownloader {
4647
return false
4748
}
4849

50+
const current = globals.globalState.tryGet('aws.toolkit.lsp.versions', Object, {})
51+
current[this.lsName] = server.serverVersion
52+
globals.globalState.tryUpdate('aws.toolkit.lsp.versions', current)
53+
4954
let tempFolder = undefined
5055

5156
try {
5257
tempFolder = await makeTemporaryToolkitFolder()
5358

5459
// download and extract the business logic
55-
await this.downloadAndExtractServer({
60+
const downloadedServer = await this.downloadAndExtractServer({
5661
content: server,
5762
installLocation: this.serverPath,
5863
name: 'qdeveloperserver',
5964
tempFolder,
6065
})
66+
if (!downloadedServer) {
67+
getLogger('lsp').error(`Failed to download and extract server`)
68+
return false
69+
}
6170

6271
// download and extract mynah ui
63-
await this.downloadAndExtractServer({
72+
const downloadedClient = await this.downloadAndExtractServer({
6473
content: clients,
6574
installLocation: this.clientPath,
6675
name: 'qdeveloperclient',
6776
tempFolder,
6877
})
78+
if (!downloadedClient) {
79+
getLogger('lsp').error(`Failed to download and extract client`)
80+
return false
81+
}
6982
} finally {
7083
if (tempFolder) {
7184
await tryRemoveFolder(tempFolder)

packages/core/src/amazonq/lsp/lspController.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export class LspController extends LspDownloader {
6060
}
6161

6262
constructor() {
63-
super(manifestUrl, supportedLspServerVersions)
63+
super(manifestUrl, 'qcontextserver', supportedLspServerVersions)
6464
this.serverPath = globals.context.asAbsolutePath(path.join('resources', 'qserver'))
6565
this.nodePath = globals.context.asAbsolutePath(path.join('resources', nodeBinName))
6666
}
@@ -194,6 +194,10 @@ export class LspController extends LspDownloader {
194194
return false
195195
}
196196

197+
const current = globals.globalState.tryGet('aws.toolkit.lsp.versions', Object, {})
198+
current[this.lsName] = server.serverVersion
199+
globals.globalState.tryUpdate('aws.toolkit.lsp.versions', current)
200+
197201
let tempFolder = undefined
198202

199203
try {

packages/core/src/shared/fetchLsp.ts

Lines changed: 150 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import path from 'path'
77
import * as crypto from 'crypto'
88
import fs from './fs/fs'
99
import { getLogger } from './logger/logger'
10-
import request from './request'
1110
import { getUserAgent } from './telemetry/util'
1211
import { ToolkitError } from './errors'
1312
import fetch from 'node-fetch'
1413
// TODO remove
1514
// eslint-disable-next-line no-restricted-imports
1615
import { createWriteStream } from 'fs'
1716
import AdmZip from 'adm-zip'
17+
import { RetryableResourceFetcher } from './resourcefetcher/httpResourceFetcher'
18+
import { Timeout } from './utilities/timeoutUtils'
19+
import globals from './extensionGlobals'
1820

1921
export interface Content {
2022
filename: string
@@ -42,25 +44,58 @@ export interface Manifest {
4244
}[]
4345
}
4446

47+
export const logger = getLogger('lsp')
48+
49+
interface StorageManifest {
50+
etag: string
51+
content: string
52+
}
53+
4554
export abstract class LspDownloader {
4655
constructor(
4756
private readonly manifestURL: string,
57+
protected readonly lsName: string,
4858
private readonly supportedLspServerVersions?: string[]
4959
) {}
5060

51-
async fetchManifest() {
61+
/**
62+
* Finds the latest available manifest. If it fails to download then fallback to the local
63+
* manifest version
64+
*/
65+
async downloadManifest() {
5266
try {
53-
const resp = await request.fetch('GET', this.manifestURL, {
54-
headers: {
55-
'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }),
67+
const resourceFetcher = new RetryableResourceFetcher({
68+
resource: this.manifestURL,
69+
params: {
70+
timeout: new Timeout(15000),
5671
},
57-
}).response
58-
if (!resp.ok) {
59-
throw new ToolkitError(`Failed to fetch manifest. Error: ${resp.statusText}`)
72+
})
73+
const etag = (globals.globalState.tryGet('aws.toolkit.lsp.manifest', Object) as StorageManifest)?.etag
74+
const resp = await resourceFetcher.getNewETagContent(etag)
75+
if (resp.content === undefined) {
76+
throw new ToolkitError('Content was not downloaded')
77+
}
78+
79+
const manifest = JSON.parse(resp.content) as Manifest
80+
if (manifest.isManifestDeprecated) {
81+
logger.info('This LSP manifest is deprecated. No future updates will be available.')
6082
}
61-
return resp.json()
83+
globals.globalState.tryUpdate('aws.toolkit.lsp.manifest', {
84+
etag: resp.eTag,
85+
content: resp.content,
86+
} as StorageManifest)
87+
return manifest
6288
} catch (e: any) {
63-
throw new ToolkitError(`Failed to fetch manifest. Error: ${JSON.stringify(e)}`)
89+
logger.info('Failed to download latest LSP manifest. Falling back to local manifest.')
90+
const manifest = globals.globalState.tryGet('aws.toolkit.lsp.manifest', Object)
91+
if (!manifest) {
92+
throw new ToolkitError('Failed to download LSP manifest and no local manifest found.')
93+
}
94+
95+
if (manifest?.isManifestDeprecated) {
96+
logger.info('This LSP manifest is deprecated. No future updates will be available.')
97+
}
98+
return manifest
6499
}
65100
}
66101

@@ -95,7 +130,7 @@ export abstract class LspDownloader {
95130
private async hashMatch(filePath: string, content: Content) {
96131
const sha384 = await this.getFileSha384(filePath)
97132
if ('sha384:' + sha384 !== content.hashes[0]) {
98-
getLogger('lsp').error(`Downloaded file sha ${sha384} does not match manifest ${content.hashes[0]}.`)
133+
logger.error(`Downloaded file sha ${sha384} does not match manifest ${content.hashes[0]}.`)
99134
await fs.delete(filePath)
100135
return false
101136
}
@@ -104,11 +139,7 @@ export abstract class LspDownloader {
104139

105140
async downloadAndCheckHash(filePath: string, content: Content) {
106141
await this._download(filePath, content.url)
107-
const match = await this.hashMatch(filePath, content)
108-
if (!match) {
109-
return false
110-
}
111-
return true
142+
return await this.hashMatch(filePath, content)
112143
}
113144

114145
getDependency(manifest: Manifest, name: string): Content | undefined {
@@ -189,26 +220,117 @@ export abstract class LspDownloader {
189220
*/
190221
abstract install(manifest: Manifest): Promise<boolean>
191222

223+
/**
224+
* Get the currently installed version of the LSP on disk
225+
*/
226+
async latestInstalledVersion(): Promise<string | undefined> {
227+
const latestVersion: Record<string, string> = globals.globalState.tryGet('aws.toolkit.lsp.versions', Object)
228+
if (!latestVersion || !latestVersion[this.lsName]) {
229+
return undefined
230+
}
231+
return latestVersion[this.lsName]
232+
}
233+
192234
async tryInstallLsp(): Promise<boolean> {
193235
try {
194-
if (await this.isLspInstalled()) {
195-
getLogger('lsp').info(`LSP already installed`)
236+
if (process.env.AWS_LANGUAGE_SERVER_OVERRIDE) {
237+
logger.info(`LSP override location: ${process.env.AWS_LANGUAGE_SERVER_OVERRIDE}`)
196238
return true
197239
}
198240

199-
const clean = await this.cleanup()
200-
if (!clean) {
201-
getLogger('lsp').error(`Failed to clean up old LSPs`)
202-
return false
203-
}
241+
const manifest: Manifest = await this.downloadManifest()
204242

205-
// fetch download url for server and runtime
206-
const manifest: Manifest = (await this.fetchManifest()) as Manifest
243+
// if a compatible version was found check what's installed locally
244+
if (this.latestCompatibleVersion(manifest)) {
245+
return await this.checkInstalledLS(manifest)
246+
}
207247

208-
return await this.install(manifest)
209-
} catch (e) {
210-
getLogger().error(`LspController: Failed to setup LSP server ${e}`)
248+
// we found no latest compatible version in the manifest; try to fallback to a local version
249+
return this.fallbackToLocalVersion(manifest)
250+
} catch (err) {
251+
const e = err as ToolkitError
252+
logger.info(`Failed to setup LSP server: ${e.message}`)
211253
return false
212254
}
213255
}
256+
257+
/**
258+
* Attempts to fall back to a local version if one is available
259+
*/
260+
async fallbackToLocalVersion(manifest?: Manifest): Promise<boolean> {
261+
// was language server previously downloaded?
262+
const installed = await this.isLspInstalled()
263+
264+
// yes
265+
if (installed) {
266+
if (!manifest) {
267+
// we want to launch if the manifest can't be found
268+
return true
269+
}
270+
271+
// the manifest is found; check that the current version is not delisted
272+
const currentVersion = await this.latestInstalledVersion()
273+
const v = manifest.versions.find((v) => v.serverVersion === currentVersion)
274+
if (v?.isDelisted) {
275+
throw new ToolkitError('Local LSP version is delisted. Please update to a newer version.')
276+
}
277+
278+
// current version is not delisted, we should launch
279+
return true
280+
}
281+
282+
// it was not installed before
283+
throw new ToolkitError('No compatible local LSP version found', { code: 'LSPNotInstalled' })
284+
}
285+
286+
/**
287+
* Check to see if we can re-use the previously downloaded language server.
288+
* If it wasn't previously downloaded then download it and store it
289+
* If it was then check the current installed language version
290+
* If there is an error, download the latest version and store it
291+
* If there wasn't an error, compare the current and latest versions
292+
* If they mismatch then download the latest language server and store it
293+
* If they are the same then launch the language server
294+
*/
295+
async checkInstalledLS(manifest: Manifest): Promise<boolean> {
296+
// was ls previously downloaded
297+
const installed = await this.isLspInstalled()
298+
299+
// yes
300+
if (installed) {
301+
try {
302+
const currentVersion = await this.latestInstalledVersion()
303+
if (currentVersion !== this.latestCompatibleVersion(manifest)) {
304+
// download and install latest version
305+
return this._install(manifest)
306+
}
307+
return true
308+
} catch (e) {
309+
logger.info('Failed to query language server for installed version')
310+
311+
// error found! download the latest version and store it
312+
return this._install(manifest)
313+
}
314+
}
315+
316+
// no; install and store it
317+
return this._install(manifest)
318+
}
319+
320+
private _install(manifest: Manifest): Promise<boolean> {
321+
return this.install(manifest).catch((_) => this.fallbackToLocalVersion(manifest))
322+
}
323+
324+
private latestCompatibleVersion(manifest: Manifest) {
325+
for (const version of manifest.versions) {
326+
if (version.isDelisted) {
327+
continue
328+
}
329+
if (this.supportedLspServerVersions && !this.supportedLspServerVersions.includes(version.serverVersion)) {
330+
continue
331+
}
332+
return version.serverVersion
333+
}
334+
return undefined
335+
}
214336
}

packages/core/src/shared/globalState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type globalKey =
4646
| 'aws.amazonq.workspaceIndexToggleOn'
4747
| 'aws.toolkit.separationPromptCommand'
4848
| 'aws.toolkit.separationPromptDismissed'
49+
| 'aws.toolkit.lsp.versions'
50+
| 'aws.toolkit.lsp.manifest'
4951
// Deprecated/legacy names. New keys should start with "aws.".
5052
| '#sessionCreationDates' // Legacy name from `ssoAccessTokenProvider.ts`.
5153
| 'CODECATALYST_RECONNECT'

packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ResourceFetcher } from './resourcefetcher'
1616
import { Timeout, CancellationError, CancelEvent } from '../utilities/timeoutUtils'
1717
import { isCloud9 } from '../extensionUtilities'
1818
import { Headers } from 'got/dist/source/core'
19+
import { withRetries } from '../utilities/functionUtils'
1920

2021
// XXX: patched Got module for compatability with older VS Code versions (e.g. Cloud9)
2122
// `got` has also deprecated `urlToOptions`
@@ -201,6 +202,50 @@ export class HttpResourceFetcher implements ResourceFetcher {
201202
}
202203
}
203204

205+
export class RetryableResourceFetcher extends HttpResourceFetcher {
206+
private readonly retryNumber: number
207+
private readonly retryIntervalMs: number
208+
private readonly resource: string
209+
210+
constructor({
211+
resource,
212+
params: { retryNumber = 5, retryIntervalMs = 3000, showUrl = true, timeout = new Timeout(5000) },
213+
}: {
214+
resource: string
215+
params: {
216+
retryNumber?: number
217+
retryIntervalMs?: number
218+
showUrl?: boolean
219+
timeout?: Timeout
220+
}
221+
}) {
222+
super(resource, {
223+
showUrl,
224+
timeout,
225+
})
226+
this.retryNumber = retryNumber
227+
this.retryIntervalMs = retryIntervalMs
228+
this.resource = resource
229+
}
230+
231+
fetch(versionTag?: string) {
232+
return withRetries(
233+
async () => {
234+
try {
235+
return await this.getNewETagContent(versionTag)
236+
} catch (err) {
237+
getLogger('lsp').error('Failed to fetch at endpoint: %s, err: %s', this.resource, err)
238+
throw err
239+
}
240+
},
241+
{
242+
maxRetries: this.retryNumber,
243+
delay: this.retryIntervalMs,
244+
}
245+
)
246+
}
247+
}
248+
204249
/**
205250
* Retrieves JSON property value from a remote resource
206251
* @param property property to retrieve

0 commit comments

Comments
 (0)