Skip to content

Commit 263fadf

Browse files
committed
fix(desktop): hold a re-requested download until the cancelled one settles
electron-updater hands the in-flight download promise to the next caller and ignores the token it is given, so downloading again straight after a cancel bound the new attempt to the cancelled promise and surfaced its rejection as an error. Defer the restart until the previous promise settles, and drop the deferred restart if the user cancels again. Also assert the dialog close control exists before clicking it, so the test cannot pass without it.
1 parent 56c3c67 commit 263fadf

3 files changed

Lines changed: 147 additions & 12 deletions

File tree

apps/desktop/src/updater.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ let checkInterval: ReturnType<typeof setInterval> | undefined
132132
let checkPromise: Promise<UpdateState> | undefined
133133
let installRequestedVersion: string | undefined
134134
let activeDownloadToken: CancellationToken | undefined
135+
let restartDownloadWhenSettled = false
135136
let listenersWired = false
136137
let initialized = false
137138
let updateTelemetryTrack: UpdateTelemetryTrack = () => {}
@@ -214,8 +215,9 @@ function stateError(error: unknown): void {
214215
* as a genuine failure, and `CancellationError` carries no distinguishing
215216
* `name`, so the token itself is the discriminator: `cancel()` flips
216217
* `cancelled` synchronously, before the rejection reaches us. The guard reads
217-
* the token that owns this rejection rather than the current one, so a retry
218-
* started after a cancel cannot inherit the cancelled download's failure.
218+
* the token that owns this rejection rather than the current one.
219+
*
220+
* That alone is not enough to make a retry safe — see `beginDownload`.
219221
*/
220222
function downloadError(token: CancellationToken, error: unknown): void {
221223
if (token.cancelled) return
@@ -458,6 +460,29 @@ export function undoSkippedUpdate(): UpdateState {
458460
return state
459461
}
460462

463+
/**
464+
* `AppUpdater.downloadUpdate` returns the in-flight `downloadPromise` when one
465+
* exists and ignores the token it is handed. A download started before the
466+
* previous one has settled would therefore be bound to the older promise — so
467+
* cancelling and immediately downloading again would surface the cancelled
468+
* attempt's rejection as this attempt's error. Hold the new start until the
469+
* previous promise settles, and only then ask for a fresh one.
470+
*/
471+
function beginDownload(): void {
472+
const token = new CancellationToken()
473+
activeDownloadToken = token
474+
void autoUpdater
475+
.downloadUpdate(token)
476+
.catch((error: unknown) => downloadError(token, error))
477+
.finally(() => {
478+
if (activeDownloadToken === token) activeDownloadToken = undefined
479+
token.dispose()
480+
if (!restartDownloadWhenSettled) return
481+
restartDownloadWhenSettled = false
482+
if (state.status === 'downloading') beginDownload()
483+
})
484+
}
485+
461486
export function startUpdateDownload(): UpdateState {
462487
const canDownload = state.status === 'available'
463488
|| (state.status === 'error' && state.availableVersion !== undefined)
@@ -472,17 +497,14 @@ export function startUpdateDownload(): UpdateState {
472497
bytesPerSecond: undefined,
473498
message: undefined,
474499
})
475-
const token = new CancellationToken()
476-
activeDownloadToken = token
477-
void autoUpdater
478-
.downloadUpdate(token)
479-
.catch((error: unknown) => downloadError(token, error))
480-
.finally(() => {
481-
if (activeDownloadToken === token) activeDownloadToken = undefined
482-
token.dispose()
483-
})
500+
if (activeDownloadToken !== undefined) {
501+
restartDownloadWhenSettled = true
502+
return state
503+
}
504+
beginDownload()
484505
} catch (error) {
485506
activeDownloadToken = undefined
507+
restartDownloadWhenSettled = false
486508
stateError(error)
487509
}
488510
return state
@@ -495,6 +517,7 @@ export function startUpdateDownload(): UpdateState {
495517
export function cancelUpdateDownload(): UpdateState {
496518
const token = activeDownloadToken
497519
if (state.status !== 'downloading' || token === undefined) return state
520+
restartDownloadWhenSettled = false
498521
token.cancel()
499522
updateState({
500523
status: 'available',

apps/desktop/tests/updater.spec.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,116 @@ describe('strict update consent', () => {
484484
expect(getLocalUpdateState()).toMatchObject({ status: 'available', availableVersion: '1.2.3' })
485485
expect(getLocalUpdateState().message).toBeUndefined()
486486
})
487+
488+
it('retries a download requested before the cancelled one settled', async () => {
489+
vi.resetModules()
490+
const directory = temporaryDirectory()
491+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
492+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
493+
const { app: localApp } = await import('electron')
494+
const { default: localElectronUpdater } = await import('electron-updater')
495+
const {
496+
cancelUpdateDownload: cancelLocalUpdateDownload,
497+
getUpdateState: getLocalUpdateState,
498+
initUpdater: initLocalUpdater,
499+
startUpdateDownload: startLocalUpdateDownload,
500+
} = await import('../src/updater')
501+
const localAutoUpdater = localElectronUpdater.autoUpdater
502+
vi.mocked(localApp.getPath).mockReturnValue(directory)
503+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
504+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
505+
506+
// Mirrors AppUpdater.downloadUpdate: an in-flight download is handed back
507+
// to the next caller and the token it passes is ignored. The promise that
508+
// clears the slot is the one the caller receives, so the slot is already
509+
// free by the time the caller's own handlers run.
510+
let rejectDownload: ((error: Error) => void) | undefined
511+
let downloadPromise: Promise<string[]> | null = null
512+
vi.mocked(localAutoUpdater.downloadUpdate).mockImplementation(() => {
513+
if (downloadPromise !== null) return downloadPromise
514+
const inner = new Promise<string[]>((_resolve, reject) => {
515+
rejectDownload = reject
516+
})
517+
downloadPromise = inner.finally(() => {
518+
downloadPromise = null
519+
})
520+
return downloadPromise
521+
})
522+
523+
initLocalUpdater(() => undefined)
524+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
525+
([event]) => event === 'update-available',
526+
)?.[1] as ((info: { version: string }) => void) | undefined
527+
available?.({ version: '1.2.3' })
528+
529+
startLocalUpdateDownload()
530+
cancelLocalUpdateDownload()
531+
startLocalUpdateDownload()
532+
533+
// The retry must not reach electron-updater yet: it would be handed the
534+
// cancelled download and inherit its rejection.
535+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce()
536+
537+
rejectDownload?.(new Error('cancelled'))
538+
await new Promise((resolve) => setTimeout(resolve, 0))
539+
540+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledTimes(2)
541+
const retryToken = vi.mocked(localAutoUpdater.downloadUpdate).mock.calls[1]?.[0] as
542+
| { cancelled: boolean }
543+
| undefined
544+
expect(retryToken?.cancelled).toBe(false)
545+
expect(getLocalUpdateState()).toMatchObject({ status: 'downloading' })
546+
expect(getLocalUpdateState().message).toBeUndefined()
547+
})
548+
549+
it('drops a deferred retry when the user cancels again', async () => {
550+
vi.resetModules()
551+
const directory = temporaryDirectory()
552+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
553+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
554+
const { app: localApp } = await import('electron')
555+
const { default: localElectronUpdater } = await import('electron-updater')
556+
const {
557+
cancelUpdateDownload: cancelLocalUpdateDownload,
558+
getUpdateState: getLocalUpdateState,
559+
initUpdater: initLocalUpdater,
560+
startUpdateDownload: startLocalUpdateDownload,
561+
} = await import('../src/updater')
562+
const localAutoUpdater = localElectronUpdater.autoUpdater
563+
vi.mocked(localApp.getPath).mockReturnValue(directory)
564+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
565+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
566+
567+
let rejectDownload: ((error: Error) => void) | undefined
568+
let downloadPromise: Promise<string[]> | null = null
569+
vi.mocked(localAutoUpdater.downloadUpdate).mockImplementation(() => {
570+
if (downloadPromise !== null) return downloadPromise
571+
const inner = new Promise<string[]>((_resolve, reject) => {
572+
rejectDownload = reject
573+
})
574+
downloadPromise = inner.finally(() => {
575+
downloadPromise = null
576+
})
577+
return downloadPromise
578+
})
579+
580+
initLocalUpdater(() => undefined)
581+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
582+
([event]) => event === 'update-available',
583+
)?.[1] as ((info: { version: string }) => void) | undefined
584+
available?.({ version: '1.2.3' })
585+
586+
startLocalUpdateDownload()
587+
cancelLocalUpdateDownload()
588+
startLocalUpdateDownload()
589+
cancelLocalUpdateDownload()
590+
591+
rejectDownload?.(new Error('cancelled'))
592+
await new Promise((resolve) => setTimeout(resolve, 0))
593+
594+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce()
595+
expect(getLocalUpdateState()).toMatchObject({ status: 'available', availableVersion: '1.2.3' })
596+
})
487597
})
488598

489599
describe('update prompt receipts', () => {

apps/pythinker-web/test/update-dialog.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ describe('UpdateDialog', () => {
201201

202202
update.openDialog();
203203
await nextTick();
204-
body().querySelector<HTMLElement>('.ui-dialog__close')?.click();
204+
const close = body().querySelector<HTMLElement>('.ui-dialog__close');
205+
expect(close).not.toBeNull();
206+
close?.click();
205207
await nextTick();
206208

207209
expect(update.dialogOpen.value).toBe(true);

0 commit comments

Comments
 (0)