packages/core/src/shared/lsp/lspResolver.ts (296 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import fs from '../fs/fs'
import { ToolkitError } from '../errors'
import * as semver from 'semver'
import * as path from 'path'
import { FileType } from 'vscode'
import AdmZip from 'adm-zip'
import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types'
import { createHash } from '../crypto'
import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage'
import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
import { showProgressWithTimeout } from '../../shared/utilities/messages'
import { Timeout } from '../utilities/timeoutUtils'
import { oneMinute } from '../datetime'
import vscode from 'vscode'
// max timeout for downloading remote LSP assets. Some asserts are large (100+ MB) so this needs to be large for slow connections.
// Since the user can cancel this one we can let it run very long.
const remoteDownloadTimeout = oneMinute * 30
export class LanguageServerResolver {
private readonly downloadMessage: string
constructor(
private readonly manifest: Manifest,
private readonly lsName: string,
private readonly versionRange: semver.Range,
/**
* Custom message to show user when downloading, if undefined it will use the default.
*/
downloadMessage?: string,
private readonly _defaultDownloadFolder?: string
) {
this.downloadMessage = downloadMessage ?? `Updating '${this.lsName}' language server`
}
/**
* Downloads and sets up the Language Server, attempting different locations in order:
* 1. Local cache
* 2. Remote download
* 3. Fallback version
* @throws ToolkitError if no compatible version can be found
*/
async resolve() {
function getServerVersion(result: LspResult) {
return {
languageServerVersion: result.version,
}
}
const latestVersion = this.latestCompatibleLspVersion()
const targetContents = this.getLSPTargetContents(latestVersion)
const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion)
const serverResolvers: StageResolver<LspResult>[] = [
{
// 1: Use the current local ("cached") LSP server bundle, if any.
resolve: async () => await this.getLocalServer(cacheDirectory, latestVersion, targetContents),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'cache' },
},
{
// 2: Download the latest LSP server bundle.
resolve: async () => await this.fetchRemoteServer(cacheDirectory, latestVersion, targetContents),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'remote' },
},
{
// 3: If the download fails, try an older, cached version.
resolve: async () => await this.getFallbackServer(latestVersion),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'fallback' },
},
]
/**
* Example:
* ```
* LspResult {
* assetDirectory = "<cachedir>/aws/toolkits/language-servers/AmazonQ/3.3.0"
* location = 'cache'
* version = '3.3.0'
* }
* ```
*/
const resolved = await tryStageResolvers('getServer', serverResolvers, getServerVersion)
logger.info('Finished preparing "%s" LSP server: %O', this.lsName, resolved.assetDirectory)
return resolved
}
/** Finds an older, cached version of the LSP server bundle. */
private async getFallbackServer(latestVersion: LspVersion): Promise<LspResult> {
const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion)
if (!fallbackDirectory) {
throw new ToolkitError('Unable to find a compatible version of the Language Server', {
code: 'IncompatibleVersion',
})
}
const version = path.basename(fallbackDirectory)
logger.info(
`Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}`
)
return {
location: 'fallback',
version: version,
assetDirectory: fallbackDirectory,
}
}
/**
* Show a toast notification with progress bar for lsp remote downlaod
* Returns a timeout to be passed down into httpFetcher to handle user cancellation
*/
private async showDownloadProgress() {
const timeout = new Timeout(remoteDownloadTimeout)
void showProgressWithTimeout(
{
title: this.downloadMessage,
location: vscode.ProgressLocation.Notification,
cancellable: false,
},
timeout,
0
)
return timeout
}
/** Downloads the latest LSP server bundle. */
private async fetchRemoteServer(
cacheDirectory: string,
latestVersion: LspVersion,
targetContents: TargetContent[]
): Promise<LspResult> {
const timeout = await this.showDownloadProgress()
try {
if (await this.downloadRemoteTargetContent(targetContents, latestVersion, timeout)) {
return {
location: 'remote',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
}
} else {
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
}
} finally {
timeout.dispose()
}
}
/** Gets the current local ("cached") LSP server bundle. */
private async getLocalServer(
cacheDirectory: string,
latestVersion: LspVersion,
targetContents: TargetContent[]
): Promise<LspResult> {
if (await this.hasValidLocalCache(cacheDirectory, targetContents)) {
return {
location: 'cache',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
}
} else {
// Delete the cached directory since it's invalid
await fs.delete(cacheDirectory, { force: true, recursive: true })
throw new ToolkitError('Failed to retrieve server from cache', { code: 'InvalidCache' })
}
}
/**
* Get all of the compatible language server versions from the manifest
*/
private compatibleManifestLspVersion() {
return this.manifest.versions.filter((x) => this.isCompatibleVersion(x))
}
/**
* Returns the path to the most compatible cached LSP version that can serve as a fallback
**/
private async getFallbackDir(version: string) {
const compatibleLspVersions = this.compatibleManifestLspVersion()
// determine all folders containing lsp versions in the fallback parent folder
const cachedVersions = (await fs.readdir(this.defaultDownloadFolder()))
.filter(([_, filetype]) => filetype === FileType.Directory)
.map(([pathName, _]) => semver.parse(pathName))
.filter((ver): ver is semver.SemVer => ver !== null)
.map((x) => x.version)
const expectedVersion = semver.parse(version)
if (!expectedVersion) {
return undefined
}
const sortedCachedLspVersions = compatibleLspVersions
.filter((v) => this.isValidCachedVersion(v, cachedVersions, expectedVersion))
.sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))
const fallbackDir = (
await Promise.all(sortedCachedLspVersions.map((ver) => this.getValidLocalCacheDirectory(ver)))
).filter((v) => v !== undefined)
return fallbackDir.length > 0 ? fallbackDir[0] : undefined
}
/**
* Validate the local cache directory of the given lsp version (matches expected hash)
* If valid return cache directory, else return undefined
*/
private async getValidLocalCacheDirectory(version: LspVersion) {
const targetContents = this.getTargetContents(version)
if (targetContents === undefined || targetContents.length === 0) {
return undefined
}
const cacheDir = this.getDownloadDirectory(version.serverVersion)
const hasValidCache = await this.hasValidLocalCache(cacheDir, targetContents)
return hasValidCache ? cacheDir : undefined
}
/**
* Determines if a cached LSP version is valid for use as a fallback.
* A version is considered valid if it exists in the cache and is less than
* or equal to the expected version.
*/
private isValidCachedVersion(version: LspVersion, cachedVersions: string[], expectedVersion: semver.SemVer) {
const serverVersion = semver.parse(version.serverVersion) as semver.SemVer
return cachedVersions.includes(serverVersion.version) && semver.lte(serverVersion, expectedVersion)
}
/**
* Download and unzip all of the contents into the download directory
*
* @returns
* true, if all of the contents were successfully downloaded and unzipped
* false, if any of the contents failed to download or unzip
*/
private async downloadRemoteTargetContent(contents: TargetContent[], lspVersion: LspVersion, timeout: Timeout) {
const downloadDirectory = this.getDownloadDirectory(lspVersion.serverVersion)
if (!(await fs.existsDir(downloadDirectory))) {
await fs.mkdir(downloadDirectory)
}
const fetchTasks = contents.map(async (content) => {
return {
res: await new HttpResourceFetcher(content.url, {
showUrl: true,
timeout: timeout,
throwOnError: true,
}).get(),
hash: content.hashes[0],
filename: content.filename,
}
})
const fetchResults = await Promise.all(fetchTasks)
const verifyTasks = fetchResults
.filter((fetchResult) => fetchResult.res && fetchResult.res.ok && fetchResult.res.body)
.flatMap(async (fetchResult) => {
const arrBuffer = await fetchResult.res!.arrayBuffer()
const data = Buffer.from(arrBuffer)
const hash = createHash('sha384', data)
if (hash === fetchResult.hash) {
return [{ filename: fetchResult.filename, data }]
}
return []
})
if (verifyTasks.length !== contents.length) {
return false
}
const filesToDownload = await lspSetupStage('validate', async () => (await Promise.all(verifyTasks)).flat())
// We were instructed by legal to show this message
const thirdPartyLicenses = lspVersion.thirdPartyLicenses
logger.info(
`Installing '${this.lsName}' Language Server v${lspVersion.serverVersion} to: ${downloadDirectory}${thirdPartyLicenses ? ` (Attribution notice can be found at ${thirdPartyLicenses})` : ''}`
)
for (const file of filesToDownload) {
await fs.writeFile(`${downloadDirectory}/${file.filename}`, file.data)
}
return this.extractZipFilesFromRemote(downloadDirectory)
}
private async extractZipFilesFromRemote(downloadDirectory: string) {
// Find all the zips
const zips = (await fs.readdir(downloadDirectory))
.filter(([fileName, _]) => fileName.endsWith('.zip'))
.map(([fileName, _]) => `${downloadDirectory}/${fileName}`)
if (zips.length === 0) {
return true
}
return this.copyZipContents(zips)
}
private async hasValidLocalCache(localCacheDirectory: string, targetContents: TargetContent[]) {
// check if the zips are still at the present location
const results = await Promise.all(
targetContents.map((content) => {
const path = `${localCacheDirectory}/${content.filename}`
return fs.existsFile(path)
})
)
const allFilesExist = results.every(Boolean)
return allFilesExist && this.ensureUnzippedFoldersMatchZip(localCacheDirectory, targetContents)
}
/**
* Ensures zip files in cache have an unzipped folder of the same name
* with the same content files (by name)
*
* @returns
* false, if any of the unzipped folder don't match zip contents (by name)
*/
private ensureUnzippedFoldersMatchZip(localCacheDirectory: string, targetContents: TargetContent[]) {
const zipPaths = targetContents
.filter((x) => x.filename.endsWith('.zip'))
.map((y) => `${localCacheDirectory}/${y.filename}`)
if (zipPaths.length === 0) {
return true
}
return this.copyZipContents(zipPaths)
}
/**
* Copies all the contents from zip into the directory
*
* @returns
* false, if any of the unzips fails
*/
private copyZipContents(zips: string[]) {
const unzips = zips.map((zip) => {
try {
// attempt to unzip
const zipFile = new AdmZip(zip)
const extractPath = zip.replace('.zip', '')
/**
* Avoid overwriting existing files during extraction to prevent file corruption.
* On Mac ARM64 when a language server is already running in one VS Code window,
* attempting to extract and overwrite its files from another window can cause
* the newly started language server to crash with 'EXC_CRASH (SIGKILL (Code Signature Invalid))'.
*/
zipFile.extractAllTo(extractPath, false)
} catch (e) {
return false
}
return true
})
// make sure every one completed successfully
return unzips.every(Boolean)
}
/**
* Parses the toolkit lsp version object retrieved from the version manifest to determine
* lsp contents
*/
private getLSPTargetContents(version: LspVersion) {
const lspTarget = this.getCompatibleLspTarget(version)
if (!lspTarget) {
throw new ToolkitError("No language server target found matching the system's architecture and platform")
}
const targetContents = lspTarget.contents
if (!targetContents) {
throw new ToolkitError('No matching target contents found')
}
return targetContents
}
/**
* Get the latest language server version matching the toolkit compatible version range,
* not de-listed and contains the required target contents:
* architecture, platform and files
*/
private latestCompatibleLspVersion() {
if (this.manifest === null) {
throw new ToolkitError('No valid manifest')
}
const latestCompatibleVersion =
this.manifest.versions
.filter((ver) => this.isCompatibleVersion(ver) && this.hasRequiredTargetContent(ver))
.sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))[0] ?? undefined
if (latestCompatibleVersion === undefined) {
// TODO fix these error range names
throw new ToolkitError(
`Unable to find a language server that satifies one or more of these conditions: version in range [${this.versionRange.range}], matching system's architecture and platform`
)
}
return latestCompatibleVersion
}
/**
* Determine if the given lsp version is toolkit compatible
* i.e. in version range and not de-listed
*/
private isCompatibleVersion(version: LspVersion) {
// invalid version
if (semver.parse(version.serverVersion) === null) {
return false
}
return (
semver.satisfies(version.serverVersion, this.versionRange, {
includePrerelease: true,
}) && !version.isDelisted
)
}
/**
* Validates the lsp version contains the required toolkit compatible contents:
* architecture, platform and file
*/
private hasRequiredTargetContent(version: LspVersion) {
const targetContents = this.getTargetContents(version)
return targetContents !== undefined && targetContents.length > 0
}
/**
* Returns the target contents of the lsp version that contains the required
* toolkit compatible contents: architecture, platform and file
*/
private getTargetContents(version: LspVersion) {
const target = this.getCompatibleLspTarget(version)
return target?.contents
}
/**
* Retrives the lsp target matching the user's system architecture and platform
* from the language server version object
*/
private getCompatibleLspTarget(version: LspVersion) {
// TODO make this web friendly
// TODO make this fully support windows
// Workaround: Manifest platform field is `windows`, whereas node returns win32
const platform = process.platform === 'win32' ? 'windows' : process.platform
const arch = process.arch
return version.targets.find((x) => x.arch === arch && x.platform === platform)
}
/**
* Gets platform-specific "cache" dir ("$LOCALAPPDATA/aws/…" or "~/.cache/aws/…").
*
* Lazy-calls `getCacheDir()` to avoid failure on Windows.
*/
public static defaultDir() {
return path.join(fs.getCacheDir(), `aws/toolkits/language-servers`)
}
defaultDownloadFolder() {
return path.join(LanguageServerResolver.defaultDir(), `${this.lsName}`)
}
private getDownloadDirectory(version: string) {
const directory = this._defaultDownloadFolder ?? this.defaultDownloadFolder()
return `${directory}/${version}`
}
}