Skip to content

Commit ff1bbbd

Browse files
tomcat323tomzu
and
tomzu
authored
feat(amazonq): user cancellable lsp download (#6573)
## Problem the cancellation button on notification doesn't actually stop the LSP download ## Solution Route the notification timeout into `httpResourceFetcher` call, so that the user cancellation event stops the download as well. Demo: non-cancelled and cancelled downloads: https://github.com/user-attachments/assets/4f3d4ed0-635a-4f88-aef7-ee62e87bfc83 --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: tomzu <[email protected]>
1 parent 1b7eb03 commit ff1bbbd

File tree

5 files changed

+113
-22
lines changed

5 files changed

+113
-22
lines changed

packages/core/src/shared/errors.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-stre
1616
import { driveLetterRegex } from './utilities/pathUtils'
1717
import { getLogger } from './logger/logger'
1818
import { crashMonitoringDirName } from './constants'
19+
import { RequestCancelledError } from './request'
1920

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

620621
export function isUserCancelledError(error: unknown): boolean {
621-
return CancellationError.isUserCancelled(error) || (error instanceof ToolkitError && error.cancelled)
622+
return (
623+
CancellationError.isUserCancelled(error) ||
624+
(error instanceof ToolkitError && error.cancelled) ||
625+
error instanceof RequestCancelledError
626+
)
622627
}
623628

624629
/**

packages/core/src/shared/lsp/lspResolver.ts

+31-12
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
1616
import { showMessageWithCancel } from '../../shared/utilities/messages'
1717
import { Timeout } from '../utilities/timeoutUtils'
1818

19+
// max timeout for downloading remote LSP assets progress, the lowest possible is 3000, bounded by httpResourceFetcher's waitUntil
20+
const remoteDownloadTimeout = 5000
21+
1922
export class LanguageServerResolver {
2023
constructor(
2124
private readonly manifest: Manifest,
@@ -32,8 +35,6 @@ export class LanguageServerResolver {
3235
* @throws ToolkitError if no compatible version can be found
3336
*/
3437
async resolve() {
35-
const timeout = new Timeout(5000)
36-
await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout)
3738
function getServerVersion(result: LspResult) {
3839
return {
3940
languageServerVersion: result.version,
@@ -62,7 +63,6 @@ export class LanguageServerResolver {
6263
return await tryStageResolvers('getServer', serverResolvers, getServerVersion)
6364
} finally {
6465
logger.info(`Finished setting up LSP server`)
65-
timeout.cancel()
6666
}
6767
}
6868

@@ -86,19 +86,34 @@ export class LanguageServerResolver {
8686
}
8787
}
8888

89+
/**
90+
* Show a toast notification with progress bar for lsp remote downlaod
91+
* Returns a timeout to be passed down into httpFetcher to handle user cancellation
92+
*/
93+
private async showDownloadProgress() {
94+
const timeout = new Timeout(remoteDownloadTimeout)
95+
await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout)
96+
return timeout
97+
}
98+
8999
private async fetchRemoteServer(
90100
cacheDirectory: string,
91101
latestVersion: LspVersion,
92102
targetContents: TargetContent[]
93103
): Promise<LspResult> {
94-
if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) {
95-
return {
96-
location: 'remote',
97-
version: latestVersion.serverVersion,
98-
assetDirectory: cacheDirectory,
104+
const timeout = await this.showDownloadProgress()
105+
try {
106+
if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion, timeout)) {
107+
return {
108+
location: 'remote',
109+
version: latestVersion.serverVersion,
110+
assetDirectory: cacheDirectory,
111+
}
112+
} else {
113+
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
99114
}
100-
} else {
101-
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
115+
} finally {
116+
timeout.dispose()
102117
}
103118
}
104119

@@ -192,7 +207,7 @@ export class LanguageServerResolver {
192207
* true, if all of the contents were successfully downloaded and unzipped
193208
* false, if any of the contents failed to download or unzip
194209
*/
195-
private async downloadRemoteTargetContent(contents: TargetContent[], version: string) {
210+
private async downloadRemoteTargetContent(contents: TargetContent[], version: string, timeout: Timeout) {
196211
const downloadDirectory = this.getDownloadDirectory(version)
197212

198213
if (!(await fs.existsDir(downloadDirectory))) {
@@ -201,7 +216,11 @@ export class LanguageServerResolver {
201216

202217
const fetchTasks = contents.map(async (content) => {
203218
return {
204-
res: await new HttpResourceFetcher(content.url, { showUrl: true }).get(),
219+
res: await new HttpResourceFetcher(content.url, {
220+
showUrl: true,
221+
timeout: timeout,
222+
throwOnError: true,
223+
}).get(),
205224
hash: content.hashes[0],
206225
filename: content.filename,
207226
}

packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getLogger, Logger } from '../logger/logger'
88
import { ResourceFetcher } from './resourcefetcher'
99
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
1010
import request, { RequestError } from '../request'
11+
import { isUserCancelledError } from '../errors'
1112

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

@@ -22,15 +23,19 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
2223
* @param {string} params.friendlyName If URL is not shown, replaces the URL with this text.
2324
* @param {Timeout} params.timeout Timeout token to abort/cancel the request. Similar to `AbortSignal`.
2425
* @param {number} params.retries The number of retries a get request should make if one fails
26+
* @param {boolean} params.throwOnError True if we want to throw if there's request error, defaul to false
2527
*/
2628
public constructor(
2729
private readonly url: string,
2830
private readonly params: {
2931
showUrl: boolean
3032
friendlyName?: string
3133
timeout?: Timeout
34+
throwOnError?: boolean
3235
}
33-
) {}
36+
) {
37+
this.params.throwOnError = this.params.throwOnError ?? false
38+
}
3439

3540
/**
3641
* Returns the response of the resource, or undefined if the response failed could not be retrieved.
@@ -82,6 +87,9 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
8287
`Error downloading ${this.logText()}: %s`,
8388
error.message ?? error.code ?? error.response.statusText ?? error.response.status
8489
)
90+
if (this.params.throwOnError) {
91+
throw error
92+
}
8593
return undefined
8694
}
8795
}
@@ -112,7 +120,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
112120
timeout: 3000,
113121
interval: 100,
114122
backoff: 2,
115-
retryOnFail: true,
123+
retryOnFail: (error: Error) => {
124+
// Retry unless the user intentionally canceled the operation.
125+
return !isUserCancelledError(error)
126+
},
116127
}
117128
)
118129
}

packages/core/src/shared/utilities/timeoutUtils.ts

+23-7
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,12 @@ interface WaitUntilOptions {
223223
readonly backoff?: number
224224
/**
225225
* Only retries when an error is thrown, otherwise returning the immediate result.
226+
* Can also be a callback for conditional retry based on errors
226227
* - 'truthy' arg is ignored
227228
* - If the timeout is reached it throws the last error
228229
* - default: false
229230
*/
230-
readonly retryOnFail?: boolean
231+
readonly retryOnFail?: boolean | ((error: Error) => boolean)
231232
}
232233

233234
export const waitUntilDefaultTimeout = 2000
@@ -247,6 +248,11 @@ export async function waitUntil<T>(
247248
fn: () => Promise<T>,
248249
options: WaitUntilOptions & { retryOnFail: false }
249250
): Promise<T | undefined>
251+
export async function waitUntil<T>(
252+
fn: () => Promise<T>,
253+
options: WaitUntilOptions & { retryOnFail: (error: Error) => boolean }
254+
): Promise<T>
255+
250256
export async function waitUntil<T>(
251257
fn: () => Promise<T>,
252258
options: Omit<WaitUntilOptions, 'retryOnFail'>
@@ -267,6 +273,17 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio
267273
let elapsed: number = 0
268274
let remaining = opt.timeout
269275

276+
// Internal helper to determine if we should retry
277+
function shouldRetry(error: Error | undefined): boolean {
278+
if (error === undefined) {
279+
return typeof opt.retryOnFail === 'boolean' ? opt.retryOnFail : true
280+
}
281+
if (typeof opt.retryOnFail === 'function') {
282+
return opt.retryOnFail(error)
283+
}
284+
return opt.retryOnFail
285+
}
286+
270287
for (let i = 0; true; i++) {
271288
const start: number = globals.clock.Date.now()
272289
let result: T
@@ -279,16 +296,16 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio
279296
result = await fn()
280297
}
281298

282-
if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
299+
if (shouldRetry(lastError) || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
283300
return result
284301
}
285302
} catch (e) {
286-
if (!opt.retryOnFail) {
303+
// Unlikely to hit this, but exists for typing
304+
if (!(e instanceof Error)) {
287305
throw e
288306
}
289307

290-
// Unlikely to hit this, but exists for typing
291-
if (!(e instanceof Error)) {
308+
if (!shouldRetry(e)) {
292309
throw e
293310
}
294311

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

301318
// If the sleep will exceed the timeout, abort early
302319
if (elapsed + interval >= remaining) {
303-
if (!opt.retryOnFail) {
320+
if (!shouldRetry(lastError)) {
304321
return undefined
305322
}
306-
307323
throw lastError
308324
}
309325

packages/core/src/test/shared/utilities/timeoutUtils.test.ts

+40
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,46 @@ export const timeoutUtilsDescribe = describe('timeoutUtils', async function () {
394394
fn = sandbox.stub()
395395
})
396396

397+
it('should retry when retryOnFail callback returns true', async function () {
398+
fn.onCall(0).throws(new Error('Retry error'))
399+
fn.onCall(1).throws(new Error('Retry error'))
400+
fn.onCall(2).resolves('success')
401+
402+
const res = waitUntil(fn, {
403+
retryOnFail: (error: Error) => {
404+
return error.message === 'Retry error'
405+
},
406+
})
407+
408+
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
409+
assert.strictEqual(fn.callCount, 2)
410+
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
411+
assert.strictEqual(fn.callCount, 3)
412+
assert.strictEqual(await res, 'success')
413+
})
414+
415+
it('should not retry when retryOnFail callback returns false', async function () {
416+
fn.onCall(0).throws(new Error('Retry error'))
417+
fn.onCall(1).throws(new Error('Retry error'))
418+
fn.onCall(2).throws(new Error('Last error'))
419+
fn.onCall(3).resolves('this is not hit')
420+
421+
const res = assert.rejects(
422+
waitUntil(fn, {
423+
retryOnFail: (error: Error) => {
424+
return error.message === 'Retry error'
425+
},
426+
}),
427+
(e) => e instanceof Error && e.message === 'Last error'
428+
)
429+
430+
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
431+
assert.strictEqual(fn.callCount, 2)
432+
await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
433+
assert.strictEqual(fn.callCount, 3)
434+
await res
435+
})
436+
397437
it('retries the function until it succeeds', async function () {
398438
fn.onCall(0).throws()
399439
fn.onCall(1).throws()

0 commit comments

Comments
 (0)