Skip to content

feat(amazonq): user cancellable lsp download #6573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion packages/core/src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-stre
import { driveLetterRegex } from './utilities/pathUtils'
import { getLogger } from './logger/logger'
import { crashMonitoringDirName } from './constants'
import { RequestCancelledError } from './request'

let _username = 'unknown-user'
let _isAutomation = false
Expand Down Expand Up @@ -618,7 +619,11 @@ function hasTime(error: Error): error is typeof error & { time: Date } {
}

export function isUserCancelledError(error: unknown): boolean {
return CancellationError.isUserCancelled(error) || (error instanceof ToolkitError && error.cancelled)
return (
CancellationError.isUserCancelled(error) ||
(error instanceof ToolkitError && error.cancelled) ||
error instanceof RequestCancelledError
)
}

/**
Expand Down
43 changes: 31 additions & 12 deletions packages/core/src/shared/lsp/lspResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
import { showMessageWithCancel } from '../../shared/utilities/messages'
import { Timeout } from '../utilities/timeoutUtils'

// max timeout for downloading remote LSP assets progress, the lowest possible is 3000, bounded by httpResourceFetcher's waitUntil
const remoteDownloadTimeout = 5000

export class LanguageServerResolver {
constructor(
private readonly manifest: Manifest,
Expand All @@ -33,8 +36,6 @@ export class LanguageServerResolver {
* @throws ToolkitError if no compatible version can be found
*/
async resolve() {
const timeout = new Timeout(5000)
await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout)
function getServerVersion(result: LspResult) {
return {
languageServerVersion: result.version,
Expand Down Expand Up @@ -63,7 +64,6 @@ export class LanguageServerResolver {
return await tryStageResolvers('getServer', serverResolvers, getServerVersion)
} finally {
logger.info(`Finished setting up LSP server`)
timeout.cancel()
}
}

Expand All @@ -87,19 +87,34 @@ export class LanguageServerResolver {
}
}

/**
* 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)
await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout)
return timeout
}

private async fetchRemoteServer(
cacheDirectory: string,
latestVersion: LspVersion,
targetContents: TargetContent[]
): Promise<LspResult> {
if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) {
return {
location: 'remote',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
const timeout = await this.showDownloadProgress()
try {
if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion, timeout)) {
return {
location: 'remote',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
}
} else {
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
}
} else {
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
} finally {
timeout.dispose()
}
}

Expand Down Expand Up @@ -193,7 +208,7 @@ export class LanguageServerResolver {
* 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[], version: string) {
private async downloadRemoteTargetContent(contents: TargetContent[], version: string, timeout: Timeout) {
const downloadDirectory = this.getDownloadDirectory(version)

if (!(await fs.existsDir(downloadDirectory))) {
Expand All @@ -202,7 +217,11 @@ export class LanguageServerResolver {

const fetchTasks = contents.map(async (content) => {
return {
res: await new HttpResourceFetcher(content.url, { showUrl: true }).get(),
res: await new HttpResourceFetcher(content.url, {
showUrl: true,
timeout: timeout,
throwOnError: true,
}).get(),
hash: content.hashes[0],
filename: content.filename,
}
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getLogger, Logger } from '../logger/logger'
import { ResourceFetcher } from './resourcefetcher'
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
import request, { RequestError } from '../request'
import { isUserCancelledError } from '../errors'

type RequestHeaders = { eTag?: string; gZip?: boolean }

Expand All @@ -22,15 +23,19 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
* @param {string} params.friendlyName If URL is not shown, replaces the URL with this text.
* @param {Timeout} params.timeout Timeout token to abort/cancel the request. Similar to `AbortSignal`.
* @param {number} params.retries The number of retries a get request should make if one fails
* @param {boolean} params.throwOnError True if we want to throw if there's request error, defaul to false
*/
public constructor(
private readonly url: string,
private readonly params: {
showUrl: boolean
friendlyName?: string
timeout?: Timeout
throwOnError?: boolean
}
) {}
) {
this.params.throwOnError = this.params.throwOnError ?? false
}

/**
* Returns the response of the resource, or undefined if the response failed could not be retrieved.
Expand Down Expand Up @@ -82,6 +87,9 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
`Error downloading ${this.logText()}: %s`,
error.message ?? error.code ?? error.response.statusText ?? error.response.status
)
if (this.params.throwOnError) {
throw error
}
return undefined
}
}
Expand Down Expand Up @@ -112,7 +120,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
timeout: 3000,
interval: 100,
backoff: 2,
retryOnFail: true,
retryOnFail: (error: Error) => {
// Retry unless the user intentionally canceled the operation.
return !isUserCancelledError(error)
},
}
)
}
Expand Down
30 changes: 23 additions & 7 deletions packages/core/src/shared/utilities/timeoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,12 @@ interface WaitUntilOptions {
readonly backoff?: number
/**
* Only retries when an error is thrown, otherwise returning the immediate result.
* Can also be a callback for conditional retry based on errors
* - 'truthy' arg is ignored
* - If the timeout is reached it throws the last error
* - default: false
*/
readonly retryOnFail?: boolean
readonly retryOnFail?: boolean | ((error: Error) => boolean)
}

export const waitUntilDefaultTimeout = 2000
Expand All @@ -247,6 +248,11 @@ export async function waitUntil<T>(
fn: () => Promise<T>,
options: WaitUntilOptions & { retryOnFail: false }
): Promise<T | undefined>
export async function waitUntil<T>(
fn: () => Promise<T>,
options: WaitUntilOptions & { retryOnFail: (error: Error) => boolean }
): Promise<T>

export async function waitUntil<T>(
fn: () => Promise<T>,
options: Omit<WaitUntilOptions, 'retryOnFail'>
Expand All @@ -267,6 +273,17 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio
let elapsed: number = 0
let remaining = opt.timeout

// Internal helper to determine if we should retry
function shouldRetry(error: Error | undefined): boolean {
if (error === undefined) {
return typeof opt.retryOnFail === 'boolean' ? opt.retryOnFail : true
}
if (typeof opt.retryOnFail === 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we make it always be a function? That should simplify some logic, eliminate an overload, and eliminate some tests.

It's trivial for calls to pass () => true if they want a "constant".

return opt.retryOnFail(error)
}
return opt.retryOnFail
}

for (let i = 0; true; i++) {
const start: number = globals.clock.Date.now()
let result: T
Expand All @@ -279,16 +296,16 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio
result = await fn()
}

if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
if (shouldRetry(lastError) || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
return result
}
} catch (e) {
if (!opt.retryOnFail) {
// Unlikely to hit this, but exists for typing
if (!(e instanceof Error)) {
throw e
}

// Unlikely to hit this, but exists for typing
if (!(e instanceof Error)) {
if (!shouldRetry(e)) {
throw e
}

Expand All @@ -300,10 +317,9 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio

// If the sleep will exceed the timeout, abort early
if (elapsed + interval >= remaining) {
if (!opt.retryOnFail) {
if (!shouldRetry(lastError)) {
return undefined
}

throw lastError
}

Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/test/shared/utilities/timeoutUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,46 @@ export const timeoutUtilsDescribe = describe('timeoutUtils', async function () {
fn = sandbox.stub()
})

it('should retry when retryOnFail callback returns true', async function () {
fn.onCall(0).throws(new Error('Retry error'))
fn.onCall(1).throws(new Error('Retry error'))
fn.onCall(2).resolves('success')

const res = waitUntil(fn, {
retryOnFail: (error: Error) => {
return error.message === 'Retry error'
},
})

await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
assert.strictEqual(fn.callCount, 2)
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
assert.strictEqual(fn.callCount, 3)
assert.strictEqual(await res, 'success')
})

it('should not retry when retryOnFail callback returns false', async function () {
fn.onCall(0).throws(new Error('Retry error'))
fn.onCall(1).throws(new Error('Retry error'))
fn.onCall(2).throws(new Error('Last error'))
fn.onCall(3).resolves('this is not hit')

const res = assert.rejects(
waitUntil(fn, {
retryOnFail: (error: Error) => {
return error.message === 'Retry error'
},
}),
(e) => e instanceof Error && e.message === 'Last error'
)

await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
assert.strictEqual(fn.callCount, 2)
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
assert.strictEqual(fn.callCount, 3)
await res
})

it('retries the function until it succeeds', async function () {
fn.onCall(0).throws()
fn.onCall(1).throws()
Expand Down
Loading