Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "Launch LSP with bundled artifacts as fallback"
}
15 changes: 12 additions & 3 deletions packages/amazonq/src/lsp/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import vscode from 'vscode'
import { startLanguageServer } from './client'
import { AmazonQLspInstaller } from './lspInstaller'
import { lspSetupStage, ToolkitError, messages } from 'aws-core-vscode/shared'
import { AmazonQLspInstaller, getBundledResourcePaths } from './lspInstaller'
import { lspSetupStage, ToolkitError, messages, getLogger } from 'aws-core-vscode/shared'

export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
try {
Expand All @@ -16,6 +16,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
})
} catch (err) {
const e = err as ToolkitError
void messages.showViewLogsMessage(`Failed to launch Amazon Q language server: ${e.message}`)
getLogger('amazonqLsp').warn(`Failed to start downloaded LSP ${e.message}. Starting with bundled LSP...`)
try {
await lspSetupStage('all', async () => {
await lspSetupStage('launch', async () => await startLanguageServer(ctx, getBundledResourcePaths(ctx)))
})
} catch (error) {
Comment on lines +20 to +24
Copy link
Contributor

@justinmk3 justinmk3 Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fallback logic should live in the resolver, just like all the other fallback cases. it doesn't make sense to add a fallback at this level.

this fallback is no different than the other fallback cases in the resolver. it's just yet another fallback scenario.

Copy link
Contributor Author

@leigaol leigaol Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. In the case of a firewall/anti virus, the resolver believes it successfully finished downloading and it then proceed to start the language server with the download path, but at that moment anti virus kicked in and broke it.

This outmost try catch can handle such cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the resolver believes it successfully finished downloading and it then proceed to start the language server with the download path

isn't that a bug in the resolver that should be fixed / redesigned ?

void messages.showViewLogsMessage(
`Failed to launch Amazon Q language server: ${(error as ToolkitError).message}`
)
}
}
}
10 changes: 7 additions & 3 deletions packages/amazonq/src/lsp/chat/webviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AmazonQPromptSettings,
LanguageServerResolver,
amazonqMark,
getLogger,
} from 'aws-core-vscode/shared'
import { AuthUtil, RegionProfile } from 'aws-core-vscode/codewhisperer'
import { featureConfig } from 'aws-core-vscode/amazonq'
Expand All @@ -44,9 +45,12 @@ export class AmazonQChatViewProvider implements WebviewViewProvider {
) {
const lspDir = Uri.file(LanguageServerResolver.defaultDir())
const dist = Uri.joinPath(globals.context.extensionUri, 'dist')

const resourcesRoots = [lspDir, dist]

const bundledResources = Uri.joinPath(globals.context.extensionUri, 'resources', 'language-server')
let resourcesRoots = [lspDir, dist]
if (this.mynahUIPath?.startsWith(globals.context.extensionUri.fsPath)) {
getLogger('amazonqLsp').info(`Using bundled webview resources ${bundledResources.fsPath}`)
Comment on lines +49 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this decision being made here instead of in LanguageServerResolver

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason as I mentioned above. When it failed to launch, there are cases when the anti virus modified/removed the downloaded artifacts later on. The LanguageServerResolver does not throw, it indeed successfully unzipped those artifacts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If LanguageServerResolver does not throw, I will not be able to resolve to bundled artifact

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When it failed to launch, there are cases when the anti virus modified/removed the downloaded artifacts later on. The LanguageServerResolver does not throw, it indeed successfully unzipped those artifacts.

Can it do a step that verifies the contents after unzipping? I.e. whatever causes the AV to quarantine the artifacts, the resolver should do itself before continuing.

resourcesRoots = [bundledResources, dist]
}
/**
* if the mynah chat client is defined, then make sure to add it to the resource roots, otherwise
* it will 401 when trying to load
Expand Down
11 changes: 11 additions & 0 deletions packages/amazonq/src/lsp/lspInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import vscode from 'vscode'
import { fs, getNodeExecutableName, getRgExecutableName, BaseLspInstaller, ResourcePaths } from 'aws-core-vscode/shared'
import path from 'path'
import { ExtendedAmazonQLSPConfig, getAmazonQLspConfig } from './config'
Expand Down Expand Up @@ -54,3 +55,13 @@ export class AmazonQLspInstaller extends BaseLspInstaller.BaseLspInstaller<

protected override downloadMessageOverride: string | undefined = 'Updating Amazon Q plugin'
}

export function getBundledResourcePaths(ctx: vscode.ExtensionContext): AmazonQResourcePaths {
const assetDirectory = vscode.Uri.joinPath(ctx.extensionUri, 'resources', 'language-server').fsPath
return {
lsp: path.join(assetDirectory, 'servers', 'aws-lsp-codewhisperer.js'),
node: process.execPath,
ripGrep: '',
ui: path.join(assetDirectory, 'clients', 'amazonq-ui.js'),
}
}
174 changes: 174 additions & 0 deletions scripts/lspArtifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as https from 'https'
import * as fs from 'fs'
import * as crypto from 'crypto'
import * as path from 'path'
import * as os from 'os'
import AdmZip from 'adm-zip'

interface ManifestContent {
filename: string
url: string
hashes: string[]
bytes: number
}

interface ManifestTarget {
platform: string
arch: string
contents: ManifestContent[]
}

interface ManifestVersion {
serverVersion: string
isDelisted: boolean
targets: ManifestTarget[]
}

interface Manifest {
versions: ManifestVersion[]
}
async function verifyFileHash(filePath: string, expectedHash: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha384')
const stream = fs.createReadStream(filePath)

stream.on('data', (data) => {
hash.update(data)
})

stream.on('end', () => {
const fileHash = hash.digest('hex')
// Remove 'sha384:' prefix from expected hash if present
const expectedHashValue = expectedHash.replace('sha384:', '')
resolve(fileHash === expectedHashValue)
})

stream.on('error', reject)
})
}

async function ensureDirectoryExists(dirPath: string): Promise<void> {
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true })
}
}

export async function downloadLanguageServer(): Promise<void> {
const tempDir = path.join(os.tmpdir(), 'amazonq-download-temp')
const resourcesDir = path.join(__dirname, '../packages/amazonq/resources/language-server')

// clear previous cached language server
try {
if (fs.existsSync(resourcesDir)) {
fs.rmdirSync(resourcesDir, { recursive: true })
}
} catch (e) {
throw Error(`Failed to clean up language server ${resourcesDir}`)
}

await ensureDirectoryExists(tempDir)
await ensureDirectoryExists(resourcesDir)

return new Promise((resolve, reject) => {
const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/qAgenticChatServer/0/manifest.json'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@leigaol Future note: this could be a source of confusion for cases like 756c286 when an alpha manifest is used. Unless this location is also updated.


https
.get(manifestUrl, (res) => {
let data = ''

res.on('data', (chunk) => {
data += chunk
})

res.on('end', async () => {
try {
const manifest: Manifest = JSON.parse(data)

const latestVersion = manifest.versions
.filter((v) => !v.isDelisted)
.sort((a, b) => b.serverVersion.localeCompare(a.serverVersion))[0]

if (!latestVersion) {
throw new Error('No valid version found in manifest')
}

const darwinArm64Target = latestVersion.targets.find(
(t) => t.platform === 'darwin' && t.arch === 'arm64'
)

if (!darwinArm64Target) {
throw new Error('No darwin arm64 target found')
}

for (const content of darwinArm64Target.contents) {
const fileName = content.filename
const fileUrl = content.url
const expectedHash = content.hashes[0]
const tempFilePath = path.join(tempDir, fileName)
const fileFolderName = content.filename.replace('.zip', '')

console.log(`Downloading ${fileName} from ${fileUrl} ...`)

await new Promise((downloadResolve, downloadReject) => {
https
.get(fileUrl, (fileRes) => {
const fileStream = fs.createWriteStream(tempFilePath)
fileRes.pipe(fileStream)

fileStream.on('finish', () => {
fileStream.close()
downloadResolve(void 0)
})

fileStream.on('error', (err) => {
fs.unlink(tempFilePath, () => {})
downloadReject(err)
})
})
.on('error', (err) => {
fs.unlink(tempFilePath, () => {})
downloadReject(err)
})
})

console.log(`Verifying hash for ${fileName}...`)
const isHashValid = await verifyFileHash(tempFilePath, expectedHash)

if (!isHashValid) {
fs.unlinkSync(tempFilePath)
throw new Error(`Hash verification failed for ${fileName}`)
}

console.log(`Extracting ${fileName}...`)
const zip = new AdmZip(tempFilePath)
zip.extractAllTo(path.join(resourcesDir, fileFolderName), true) // true for overwrite

// Clean up temp file
fs.unlinkSync(tempFilePath)
console.log(`Successfully processed ${fileName}`)
}

// Clean up temp directory
fs.rmdirSync(tempDir)
fs.rmdirSync(path.join(resourcesDir, 'servers', 'indexing'), { recursive: true })
fs.rmSync(path.join(resourcesDir, 'servers', 'node'))
console.log('Download and extraction completed successfully')
resolve()
} catch (err) {
// Clean up temp directory on error
if (fs.existsSync(tempDir)) {
fs.rmdirSync(tempDir, { recursive: true })
}
reject(err)
}
})
})
.on('error', (err) => {
// Clean up temp directory on error
if (fs.existsSync(tempDir)) {
fs.rmdirSync(tempDir, { recursive: true })
}
reject(err)
})
})
}
9 changes: 8 additions & 1 deletion scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import * as child_process from 'child_process' // eslint-disable-line no-restricted-imports
import * as nodefs from 'fs' // eslint-disable-line no-restricted-imports
import * as path from 'path'
import { downloadLanguageServer } from './lspArtifact'

function parseArgs() {
// Invoking this script with argument "foo":
Expand Down Expand Up @@ -105,7 +106,7 @@ function getVersionSuffix(feature: string, debug: boolean): string {
return `${debugSuffix}${featureSuffix}${commitSuffix}`
}

function main() {
async function main() {
const args = parseArgs()
// It is expected that this will package from a packages/{subproject} folder.
// There is a base config in packages/
Expand Down Expand Up @@ -155,6 +156,12 @@ function main() {
}

nodefs.writeFileSync(packageJsonFile, JSON.stringify(packageJson, undefined, ' '))

// add language server bundle
if (packageJson.name === 'amazon-q-vscode') {
await downloadLanguageServer()
}

child_process.execFileSync(
'vsce',
[
Expand Down
Loading