-
Notifications
You must be signed in to change notification settings - Fork 640
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
Changes from 4 commits
07ec9b2
04c7a35
b4b6a05
ecd939e
d6f8e77
fbc0de0
ee347a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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( | ||||||
|
@@ -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, | ||||||
|
@@ -63,7 +65,6 @@ export class LanguageServerResolver { | |||||
return await tryStageResolvers('getServer', serverResolvers, getServerVersion) | ||||||
} finally { | ||||||
logger.info(`Finished setting up LSP server`) | ||||||
timeout.cancel() | ||||||
} | ||||||
} | ||||||
|
||||||
|
@@ -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') | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not add aws-toolkit-vscode/packages/core/src/shared/errors.ts Lines 620 to 621 in 71650e5
Then you wouldn't need to catch-and-throw here. Instead the caller could just check |
||||||
} | ||||||
throw err | ||||||
} finally { | ||||||
timeout.dispose() | ||||||
} | ||||||
} | ||||||
|
||||||
|
@@ -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))) { | ||||||
|
@@ -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, | ||||||
} | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 } | ||
|
||
|
@@ -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 | ||
tomcat323 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
) {} | ||
) { | ||
this.params.swallowError = this.params.swallowError ?? true | ||
} | ||
|
||
/** | ||
* Returns the response of the resource, or undefined if the response failed could not be retrieved. | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} | ||
|
||
|
@@ -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 | ||
tomcat323 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return !(error instanceof RequestCancelledError) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Purpose of this added |
||
}, | ||
} | ||
) | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -247,6 +248,11 @@ export async function waitUntil<T>( | |
fn: () => Promise<T>, | ||
options: WaitUntilOptions & { retryOnFail: false } | ||
): Promise<T | undefined> | ||
export async function waitUntil<T>( | ||
justinmk3 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
fn: () => Promise<T>, | ||
options: WaitUntilOptions & { retryOnFail: (error: Error) => boolean } | ||
): Promise<T> | ||
|
||
export async function waitUntil<T>( | ||
fn: () => Promise<T>, | ||
options: Omit<WaitUntilOptions, 'retryOnFail'> | ||
|
@@ -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') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
return opt.retryOnFail(error) | ||
} | ||
return opt.retryOnFail | ||
} | ||
|
||
for (let i = 0; true; i++) { | ||
const start: number = globals.clock.Date.now() | ||
let result: T | ||
|
@@ -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 | ||
} | ||
|
||
|
@@ -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 | ||
} | ||
|
||
|
Uh oh!
There was an error while loading. Please reload this page.