Skip to content
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

feat(amazonq): user cancellable lsp download #6573

Merged
merged 7 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
51 changes: 38 additions & 13 deletions packages/core/src/shared/lsp/lspResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { createHash } from '../crypto'
import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage'
import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
import { showMessageWithCancel } from '../../shared/utilities/messages'
import { Timeout } from '../utilities/timeoutUtils'
import { CancellationError, Timeout } from '../utilities/timeoutUtils'
import { RequestCancelledError } from '../request'

// 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(
Expand All @@ -33,8 +37,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 +65,6 @@ export class LanguageServerResolver {
return await tryStageResolvers('getServer', serverResolvers, getServerVersion)
} finally {
logger.info(`Finished setting up LSP server`)
timeout.cancel()
}
}

Expand All @@ -87,19 +88,39 @@ 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' })
} catch (err) {
if (err instanceof RequestCancelledError) {
throw new CancellationError('user')
Copy link
Contributor

@justinmk3 justinmk3 Feb 27, 2025

Choose a reason for hiding this comment

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

Why not add err instanceof RequestCancelledError to isUserCancelledError ?

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

Then you wouldn't need to catch-and-throw here. Instead the caller could just check isUserCancelledError , which is the expected pattern.

}
throw err
} finally {
timeout.dispose()
}
}

Expand Down Expand Up @@ -193,7 +214,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 +223,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,
swallowError: false,
}).get(),
hash: content.hashes[0],
filename: content.filename,
}
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { VSCODE_EXTENSION_ID } from '../extensions'
import { getLogger, Logger } from '../logger/logger'
import { ResourceFetcher } from './resourcefetcher'
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
import request, { RequestError } from '../request'
import request, { RequestCancelledError, RequestError } from '../request'

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

Expand All @@ -22,15 +22,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.swallowError True if we want to swallow the request error, false if we want the error to keep bubbling up
*/
public constructor(
private readonly url: string,
private readonly params: {
showUrl: boolean
friendlyName?: string
timeout?: Timeout
swallowError?: boolean
}
) {}
) {
this.params.swallowError = this.params.swallowError ?? true
}

/**
* Returns the response of the resource, or undefined if the response failed could not be retrieved.
Expand Down Expand Up @@ -82,7 +86,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
`Error downloading ${this.logText()}: %s`,
error.message ?? error.code ?? error.response.statusText ?? error.response.status
)
return undefined
if (this.params.swallowError) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

previously this is swallowing all errors from fetch. With this change we can detect and catch the user cancellation errors.

return undefined
}
throw error
}
}

Expand Down Expand Up @@ -112,7 +119,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
timeout: 3000,
interval: 100,
backoff: 2,
retryOnFail: true,
retryOnFail: (error: Error) => {
// retry unless we got an user cancellation error
return !(error instanceof RequestCancelledError)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Purpose of this added waitUntil feature:
We need to stop download retries if its an user cancel error
But for all other error types from fetch, we continue retry.

},
}
)
}
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
Loading