Skip to content

Commit 7488e0a

Browse files
authored
feat(lsp): older and delisted versions of lsp are automatically removed (#6409)
## Problem - Installed LSP artifacts remain forever. There is no cleanup effort made. ## Solution Implement the following heuristic: - Delete all currently installed delisted versions. - Delete all versions that remain, except the most recent 2. Included in this change is a new utility function `partition`. `partition` is like `filter`, but it produces both the positive and negative result in two separate sublists. See the tests for a simple example. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 37c3c77 commit 7488e0a

File tree

8 files changed

+176
-4
lines changed

8 files changed

+176
-4
lines changed

packages/amazonq/src/lsp/lspInstaller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
fs,
1313
LspResolution,
1414
getNodeExecutableName,
15+
cleanLspDownloads,
1516
} from 'aws-core-vscode/shared'
1617
import path from 'path'
1718

@@ -46,7 +47,7 @@ export class AmazonQLSPResolver implements LspResolver {
4647
const nodePath = path.join(installationResult.assetDirectory, `servers/${getNodeExecutableName()}`)
4748
await fs.chmod(nodePath, 0o755)
4849

49-
// TODO Cleanup old versions of language servers
50+
await cleanLspDownloads(manifest.versions, path.dirname(installationResult.assetDirectory))
5051
return {
5152
...installationResult,
5253
resourcePaths: {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { LanguageServerResolver } from '../../shared/lsp/lspResolver'
1010
import { Range } from 'semver'
1111
import { getNodeExecutableName } from '../../shared/lsp/utils/platform'
1212
import { fs } from '../../shared/fs/fs'
13+
import { cleanLspDownloads } from '../../shared'
1314

1415
const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json'
1516
// this LSP client in Q extension is only going to work with these LSP server versions
@@ -30,7 +31,7 @@ export class WorkspaceLSPResolver implements LspResolver {
3031
const nodePath = path.join(installationResult.assetDirectory, nodeName)
3132
await fs.chmod(nodePath, 0o755)
3233

33-
// TODO Cleanup old versions of language servers
34+
await cleanLspDownloads(manifest.versions, path.basename(installationResult.assetDirectory))
3435
return {
3536
...installationResult,
3637
resourcePaths: {

packages/core/src/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants'
6363
export * from './lsp/manifestResolver'
6464
export * from './lsp/lspResolver'
6565
export * from './lsp/types'
66+
export * from './lsp/utils/cleanup'
6667
export { default as request } from './request'
6768
export * from './lsp/utils/platform'
6869
export * as processUtils from './utilities/processUtils'

packages/core/src/shared/lsp/lspResolver.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,13 @@ export class LanguageServerResolver {
338338
return version.targets.find((x) => x.arch === arch && x.platform === platform)
339339
}
340340

341+
// lazy calls to `getApplicationSupportFolder()` to avoid failure on windows.
342+
public static get defaultDir() {
343+
return path.join(getApplicationSupportFolder(), `aws/toolkits/language-servers`)
344+
}
345+
341346
defaultDownloadFolder() {
342-
const applicationSupportFolder = getApplicationSupportFolder()
343-
return path.join(applicationSupportFolder, `aws/toolkits/language-servers/${this.lsName}`)
347+
return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`)
344348
}
345349

346350
private getDownloadDirectory(version: string) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import path from 'path'
7+
import { LspVersion } from '../types'
8+
import { fs } from '../../../shared/fs/fs'
9+
import { partition } from '../../../shared/utilities/tsUtils'
10+
import { sort } from 'semver'
11+
12+
async function getDownloadedVersions(installLocation: string) {
13+
return (await fs.readdir(installLocation)).map(([f, _], __) => f)
14+
}
15+
16+
function isDelisted(manifestVersions: LspVersion[], targetVersion: string): boolean {
17+
return manifestVersions.find((v) => v.serverVersion === targetVersion)?.isDelisted ?? false
18+
}
19+
20+
/**
21+
* Delete all delisted versions and keep the two newest versions that remain
22+
* @param manifest
23+
* @param downloadDirectory
24+
*/
25+
export async function cleanLspDownloads(manifestVersions: LspVersion[], downloadDirectory: string): Promise<void> {
26+
const downloadedVersions = await getDownloadedVersions(downloadDirectory)
27+
const [delistedVersions, remainingVersions] = partition(downloadedVersions, (v: string) =>
28+
isDelisted(manifestVersions, v)
29+
)
30+
for (const v of delistedVersions) {
31+
await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
32+
}
33+
34+
if (remainingVersions.length <= 2) {
35+
return
36+
}
37+
38+
for (const v of sort(remainingVersions).slice(0, -2)) {
39+
await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
40+
}
41+
}

packages/core/src/shared/utilities/tsUtils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ export function createFactoryFunction<T extends new (...args: any[]) => any>(cto
9494
return (...args) => new ctor(...args)
9595
}
9696

97+
/**
98+
* Split a list into two sublists based on the result of a predicate.
99+
* @param lst list to split
100+
* @param pred predicate to apply to each element
101+
* @returns two nested lists, where for all items x in the left sublist, pred(x) returns true. The remaining elements are in the right sublist.
102+
*/
103+
export function partition<T>(lst: T[], pred: (arg: T) => boolean): [T[], T[]] {
104+
return lst.reduce(
105+
([leftAcc, rightAcc], item) => {
106+
return pred(item) ? [[...leftAcc, item], rightAcc] : [leftAcc, [...rightAcc, item]]
107+
},
108+
[[], []] as [T[], T[]]
109+
)
110+
}
111+
97112
type NoSymbols<T> = { [Property in keyof T]: Property extends symbol ? never : Property }[keyof T]
98113
export type InterfaceNoSymbol<T> = Pick<T, NoSymbols<T>>
99114
/**
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Uri } from 'vscode'
7+
import { cleanLspDownloads, fs } from '../../../../shared'
8+
import { createTestWorkspaceFolder } from '../../../testUtil'
9+
import path from 'path'
10+
import assert from 'assert'
11+
12+
async function fakeInstallVersion(version: string, installationDir: string): Promise<void> {
13+
const versionDir = path.join(installationDir, version)
14+
await fs.mkdir(versionDir)
15+
await fs.writeFile(path.join(versionDir, 'file.txt'), 'content')
16+
}
17+
18+
async function fakeInstallVersions(versions: string[], installationDir: string): Promise<void> {
19+
for (const v of versions) {
20+
await fakeInstallVersion(v, installationDir)
21+
}
22+
}
23+
24+
describe('cleanLSPDownloads', function () {
25+
let installationDir: Uri
26+
27+
before(async function () {
28+
installationDir = (await createTestWorkspaceFolder()).uri
29+
})
30+
31+
afterEach(async function () {
32+
const files = await fs.readdir(installationDir.fsPath)
33+
for (const [name, _type] of files) {
34+
await fs.delete(path.join(installationDir.fsPath, name), { force: true, recursive: true })
35+
}
36+
})
37+
38+
after(async function () {
39+
await fs.delete(installationDir, { force: true, recursive: true })
40+
})
41+
42+
it('keeps two newest versions', async function () {
43+
await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
44+
await cleanLspDownloads([], installationDir.fsPath)
45+
46+
const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
47+
assert.strictEqual(result.length, 2)
48+
assert.ok(result.includes('2.1.1'))
49+
assert.ok(result.includes('1.1.1'))
50+
})
51+
52+
it('deletes delisted versions', async function () {
53+
await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
54+
await cleanLspDownloads([{ serverVersion: '1.1.1', isDelisted: true, targets: [] }], installationDir.fsPath)
55+
56+
const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
57+
assert.strictEqual(result.length, 2)
58+
assert.ok(result.includes('2.1.1'))
59+
assert.ok(result.includes('1.0.1'))
60+
})
61+
62+
it('handles case where less than 2 versions are not delisted', async function () {
63+
await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
64+
await cleanLspDownloads(
65+
[
66+
{ serverVersion: '1.1.1', isDelisted: true, targets: [] },
67+
{ serverVersion: '2.1.1', isDelisted: true, targets: [] },
68+
{ serverVersion: '1.0.0', isDelisted: true, targets: [] },
69+
],
70+
installationDir.fsPath
71+
)
72+
73+
const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
74+
assert.strictEqual(result.length, 1)
75+
assert.ok(result.includes('1.0.1'))
76+
})
77+
78+
it('handles case where less than 2 versions exist', async function () {
79+
await fakeInstallVersions(['1.0.0'], installationDir.fsPath)
80+
await cleanLspDownloads([], installationDir.fsPath)
81+
82+
const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
83+
assert.strictEqual(result.length, 1)
84+
})
85+
86+
it('does not install delisted version when no other option exists', async function () {
87+
await fakeInstallVersions(['1.0.0'], installationDir.fsPath)
88+
await cleanLspDownloads([{ serverVersion: '1.0.0', isDelisted: true, targets: [] }], installationDir.fsPath)
89+
90+
const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
91+
assert.strictEqual(result.length, 0)
92+
})
93+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { partition } from '../../../shared/utilities/tsUtils'
7+
import assert from 'assert'
8+
9+
describe('partition', function () {
10+
it('should split the list according to predicate', function () {
11+
const items = [1, 2, 3, 4, 5, 6, 7, 8]
12+
const [even, odd] = partition(items, (i) => i % 2 === 0)
13+
assert.deepStrictEqual(even, [2, 4, 6, 8])
14+
assert.deepStrictEqual(odd, [1, 3, 5, 7])
15+
})
16+
})

0 commit comments

Comments
 (0)