From 45dd8e93a6de04a3b90b456e57caa50c6a284ee1 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 17 Aug 2026 11:44:39 +0800 Subject: [PATCH] fix(desktop): force reliable full installer updates --- .github/workflows/desktop-acceptance.yml | 6 +- .github/workflows/desktop-release.yml | 6 +- package.json | 1 + packages/desktop/src/update-manager.test.ts | 34 +++++ packages/desktop/src/update-manager.ts | 56 ++++++-- scripts/force-desktop-full-download.test.ts | 62 +++++++++ scripts/force-desktop-full-download.ts | 122 ++++++++++++++++++ scripts/github-workflows.test.ts | 28 +++- .../verify-desktop-installed-update.test.ts | 14 ++ scripts/verify-desktop-installed-update.ts | 40 +++++- 10 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 scripts/force-desktop-full-download.test.ts create mode 100644 scripts/force-desktop-full-download.ts diff --git a/.github/workflows/desktop-acceptance.yml b/.github/workflows/desktop-acceptance.yml index 1c9e812e..3cd90b5d 100644 --- a/.github/workflows/desktop-acceptance.yml +++ b/.github/workflows/desktop-acceptance.yml @@ -176,7 +176,7 @@ jobs: name: ${{ needs.build-assets.outputs.complete_artifact }} path: release/desktop-acceptance - - name: Carry forward previous Shell blockmap for differential updates + - name: Carry forward previous Shell blockmap for legacy updater fallback if: needs.prepare.outputs.has_previous_desktop == 'true' && needs.prepare.outputs.release_kind == 'full' shell: bash env: @@ -193,6 +193,10 @@ jobs: --dir release/desktop-acceptance \ --clobber + - name: Force full Shell installer download + if: needs.prepare.outputs.release_kind == 'full' + run: pnpm desktop:force-full-download -- --directory release/desktop-acceptance + - name: Download acceptance public key uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 4dbb63db..0a714065 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -351,7 +351,7 @@ jobs: path: release/desktop-release-final merge-multiple: true - - name: Carry forward previous Shell blockmap for differential updates + - name: Carry forward previous Shell blockmap for legacy updater fallback if: needs.prepare.outputs.has_previous_desktop == 'true' && needs.prepare.outputs.release_kind == 'full' shell: bash env: @@ -368,6 +368,10 @@ jobs: --dir release/desktop-release-final \ --clobber + - name: Force full Shell installer download + if: needs.prepare.outputs.release_kind == 'full' + run: pnpm desktop:force-full-download -- --directory release/desktop-release-final + - name: Build signed Desktop channel shell: bash run: | diff --git a/package.json b/package.json index aedbb627..a61d10f4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "smoke:desktop": "tsx scripts/smoke-desktop-package.ts", "desktop:channel": "tsx scripts/build-desktop-channel.ts", "desktop:artifacts": "tsx scripts/desktop-release-artifacts.ts", + "desktop:force-full-download": "tsx scripts/force-desktop-full-download.ts", "changeset": "changeset", "changeset:validate": "tsx scripts/validate-changesets.ts", "version-packages": "changeset version", diff --git a/packages/desktop/src/update-manager.test.ts b/packages/desktop/src/update-manager.test.ts index d906f482..6f9350b5 100644 --- a/packages/desktop/src/update-manager.test.ts +++ b/packages/desktop/src/update-manager.test.ts @@ -8,6 +8,8 @@ function createUpdater() { autoDownload: true, autoInstallOnAppQuit: true, allowPrerelease: false, + disableDifferentialDownload: false, + disableWebInstaller: false, on: (event: string, listener: (value: unknown) => void) => { emitter.on(event, listener); return updater; @@ -49,6 +51,8 @@ describe("DesktopShellUpdateAdapter", () => { }); expect(updater.autoDownload).toBe(false); expect(updater.autoInstallOnAppQuit).toBe(false); + expect(updater.disableDifferentialDownload).toBe(true); + expect(updater.disableWebInstaller).toBe(true); expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); }); @@ -126,4 +130,34 @@ describe("DesktopShellUpdateAdapter", () => { recoveryAction: "https://releases.example/desktop", }); }); + + it("cancels a Shell download that stops reporting progress", async () => { + vi.useFakeTimers(); + try { + const updater = createUpdater(); + const token = { cancel: vi.fn() }; + const adapter = new DesktopShellUpdateAdapter({ + updater, + currentVersion: "0.2.0", + isPackaged: true, + createCancellationToken: () => token, + downloadInactivityTimeoutMs: 1_000, + }); + const metadata = await adapter.checkMetadata(expectedShell); + const download = adapter.download(metadata, vi.fn()); + const rejected = expect(download).rejects.toMatchObject({ name: "TimeoutError" }); + + await vi.advanceTimersByTimeAsync(900); + updater.emit("download-progress", { percent: 1 }); + await vi.advanceTimersByTimeAsync(900); + expect(token.cancel).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + + await rejected; + expect(token.cancel).toHaveBeenCalledTimes(1); + expect(adapter.cancelDownload()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/desktop/src/update-manager.ts b/packages/desktop/src/update-manager.ts index 9fdd2095..4f6fd17c 100644 --- a/packages/desktop/src/update-manager.ts +++ b/packages/desktop/src/update-manager.ts @@ -10,6 +10,8 @@ export interface ShellUpdaterPort { autoDownload: boolean; autoInstallOnAppQuit: boolean; allowPrerelease: boolean; + disableDifferentialDownload: boolean; + disableWebInstaller: boolean; on(event: "download-progress", listener: (value: { percent?: number }) => void): unknown; on(event: "update-downloaded", listener: (value: { version?: string }) => void): unknown; on(event: "error", listener: (value: unknown) => void): unknown; @@ -38,6 +40,7 @@ export interface DesktopShellUpdateAdapterOptions { createCancellationToken?: () => ShellCancellationToken; logLocations?: string[]; manualInstallerUrl?: string | null; + downloadInactivityTimeoutMs?: number; } interface ActiveDownload { @@ -46,8 +49,11 @@ interface ActiveDownload { onProgress: (percent: number) => void; resolve: () => void; reject: (error: Error) => void; + inactivityTimer: ReturnType | null; } +const DEFAULT_DOWNLOAD_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1_000; + class LocalCancellationToken extends EventEmitter implements ShellCancellationToken { cancelled = false; @@ -70,6 +76,13 @@ export class ShellCancellationError extends Error { } } +export class ShellDownloadTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Desktop Shell download made no progress for ${Math.ceil(timeoutMs / 1_000)} seconds`); + this.name = "TimeoutError"; + } +} + export class DesktopShellUpdateAdapter { private started = false; private activeDownload: ActiveDownload | null = null; @@ -82,16 +95,18 @@ export class DesktopShellUpdateAdapter { this.started = true; this.options.updater.autoDownload = false; this.options.updater.autoInstallOnAppQuit = false; + this.options.updater.disableDifferentialDownload = true; + this.options.updater.disableWebInstaller = true; this.options.updater.allowPrerelease = this.options.allowPrerelease === true || this.options.currentVersion.includes("-"); this.options.updater.on("download-progress", (progress: { percent?: number }) => { if (!this.activeDownload || typeof progress.percent !== "number") return; + this.resetDownloadInactivityTimer(this.activeDownload); this.activeDownload.onProgress(Math.max(0, Math.min(100, progress.percent))); }); this.options.updater.on("update-downloaded", (info: { version?: string }) => { - const active = this.activeDownload; + const active = this.takeActiveDownload(); if (!active) return; - this.activeDownload = null; if (info.version !== active.expectedVersion) { active.reject(new Error("Downloaded Desktop Shell does not match signed Desktop channel")); return; @@ -99,13 +114,37 @@ export class DesktopShellUpdateAdapter { active.resolve(); }); this.options.updater.on("error", (error: unknown) => { - const active = this.activeDownload; + const active = this.takeActiveDownload(); if (!active) return; - this.activeDownload = null; active.reject(error instanceof Error ? error : new Error(String(error))); }); } + private takeActiveDownload(): ActiveDownload | null { + const active = this.activeDownload; + if (!active) return null; + this.activeDownload = null; + if (active.inactivityTimer) clearTimeout(active.inactivityTimer); + active.inactivityTimer = null; + return active; + } + + private resetDownloadInactivityTimer(active: ActiveDownload): void { + if (active.inactivityTimer) clearTimeout(active.inactivityTimer); + const timeoutMs = + this.options.downloadInactivityTimeoutMs ?? DEFAULT_DOWNLOAD_INACTIVITY_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + active.inactivityTimer = null; + return; + } + active.inactivityTimer = setTimeout(() => { + if (this.activeDownload !== active) return; + this.takeActiveDownload(); + active.token.cancel(); + active.reject(new ShellDownloadTimeoutError(timeoutMs)); + }, timeoutMs); + } + async checkMetadata(expected: DesktopChannel["shell"]): Promise { this.start(); const versionOrder = compareVersions(expected.version, this.options.currentVersion); @@ -149,20 +188,21 @@ export class DesktopShellUpdateAdapter { onProgress, resolve, reject, + inactivityTimer: null, }; + this.resetDownloadInactivityTimer(this.activeDownload); void this.options.updater.downloadUpdate(token).catch((error: unknown) => { const active = this.activeDownload; if (!active || active.token !== token) return; - this.activeDownload = null; - active.reject(error instanceof Error ? error : new Error(String(error))); + const failed = this.takeActiveDownload(); + failed?.reject(error instanceof Error ? error : new Error(String(error))); }); }); } cancelDownload(): boolean { - const active = this.activeDownload; + const active = this.takeActiveDownload(); if (!active) return false; - this.activeDownload = null; active.token.cancel(); active.reject(new ShellCancellationError()); return true; diff --git a/scripts/force-desktop-full-download.test.ts b/scripts/force-desktop-full-download.test.ts new file mode 100644 index 00000000..ccd885d7 --- /dev/null +++ b/scripts/force-desktop-full-download.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { afterEach, describe, expect, it } from "vitest"; +import { forceDesktopFullDownload } from "./force-desktop-full-download.js"; + +const roots: string[] = []; + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), "coder-studio-full-download-")); + roots.push(root); + const installer = "Coder-Studio-Setup-0.1.2.exe"; + await writeFile(join(root, "latest.yml"), `version: 0.1.2\npath: ${installer}\n`); + await writeFile( + join(root, `${installer}.blockmap`), + gzipSync( + JSON.stringify({ + version: "2", + files: [{ name: "file", offsets: [0], checksums: ["checksum"], sizes: [123] }], + }) + ) + ); + return { root, installer }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("force-desktop-full-download", () => { + it("marks the target blockmap as incompatible so legacy clients fall back to the full installer", async () => { + const fixture = await createFixture(); + + await expect(forceDesktopFullDownload(fixture.root)).resolves.toMatchObject({ + originalVersion: "2", + forcedVersion: "2-coder-studio-full-download-0.1.2", + changed: true, + }); + const blockmap = JSON.parse( + gunzipSync(await readFile(join(fixture.root, `${fixture.installer}.blockmap`))).toString( + "utf8" + ) + ); + expect(blockmap).toEqual({ + version: "2-coder-studio-full-download-0.1.2", + files: [{ name: "file", offsets: [0], checksums: ["checksum"], sizes: [123] }], + }); + + await expect(forceDesktopFullDownload(fixture.root)).resolves.toMatchObject({ + forcedVersion: "2-coder-studio-full-download-0.1.2", + changed: false, + }); + }); + + it("rejects updater paths that escape the release directory", async () => { + const fixture = await createFixture(); + await writeFile(join(fixture.root, "latest.yml"), "version: 0.1.2\npath: ../outside.exe\n"); + + await expect(forceDesktopFullDownload(fixture.root)).rejects.toThrow("inside"); + }); +}); diff --git a/scripts/force-desktop-full-download.ts b/scripts/force-desktop-full-download.ts new file mode 100644 index 00000000..3b540c57 --- /dev/null +++ b/scripts/force-desktop-full-download.ts @@ -0,0 +1,122 @@ +import { lstat, readFile, writeFile } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { parse } from "yaml"; +import { error, success } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; + +interface UpdaterMetadata { + path: string; + version: string; +} + +interface BlockMap { + version: string; + [key: string]: unknown; +} + +export interface ForceFullDownloadResult { + blockmapPath: string; + originalVersion: string; + forcedVersion: string; + changed: boolean; +} + +function readRequiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function isInside(parent: string, candidate: string): boolean { + const path = relative(parent, candidate); + return path.length > 0 && path !== ".." && !path.startsWith(`..${sep}`); +} + +async function readRegularFile(path: string): Promise { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Desktop updater asset must be a regular file: ${path}`); + } + return readFile(path); +} + +async function readUpdaterMetadata(directory: string): Promise { + const value = parse((await readRegularFile(resolve(directory, "latest.yml"))).toString("utf8")); + if (!value || typeof value !== "object") { + throw new Error("Desktop updater metadata must be an object"); + } + const candidate = value as Record; + return { + path: readRequiredString(candidate.path, "Desktop updater path"), + version: readRequiredString(candidate.version, "Desktop updater version"), + }; +} + +export async function forceDesktopFullDownload( + directoryValue: string +): Promise { + const directory = resolve(directoryValue); + const updater = await readUpdaterMetadata(directory); + const blockmapPath = resolve(directory, `${updater.path}.blockmap`); + if (!isInside(directory, blockmapPath)) { + throw new Error("Desktop updater blockmap must stay inside the release directory"); + } + + const source = await readRegularFile(blockmapPath); + let blockmap: BlockMap; + try { + const value = JSON.parse(gunzipSync(source).toString("utf8")) as unknown; + if (!value || typeof value !== "object") throw new Error("blockmap must be an object"); + blockmap = value as BlockMap; + } catch (blockmapError) { + throw new Error( + `Cannot parse Desktop updater blockmap: ${blockmapError instanceof Error ? blockmapError.message : String(blockmapError)}` + ); + } + + const originalVersion = readRequiredString(blockmap.version, "Desktop blockmap version"); + // The installed 0.1.1 Shell controls its own upgrade and cannot observe the new + // disableDifferentialDownload setting yet. A valid-but-incompatible blockmap version makes + // electron-updater take its existing full-download fallback, which still verifies latest.yml's + // installer SHA-512. New Shells disable differential downloads before checking for updates. + const marker = `coder-studio-full-download-${updater.version}`; + const forcedVersion = originalVersion.includes("-coder-studio-full-download-") + ? originalVersion + : `${originalVersion}-${marker}`; + if (forcedVersion !== `${originalVersion.split("-coder-studio-full-download-")[0]}-${marker}`) { + throw new Error("Desktop blockmap is already marked for a different full-download release"); + } + if (blockmap.version === forcedVersion) { + return { blockmapPath, originalVersion, forcedVersion, changed: false }; + } + + blockmap.version = forcedVersion; + await writeFile(blockmapPath, gzipSync(JSON.stringify(blockmap))); + return { blockmapPath, originalVersion, forcedVersion, changed: true }; +} + +function parseDirectory(argv: string[]): string { + if (argv.length !== 2 || argv[0] !== "--directory" || !argv[1]) { + throw new Error("Usage: force-desktop-full-download --directory "); + } + return argv[1]; +} + +async function main(): Promise { + const result = await forceDesktopFullDownload(parseDirectory(process.argv.slice(2))); + success( + `${result.changed ? "Marked" : "Verified"} Desktop blockmap for full installer download: ${result.blockmapPath}` + ); +} + +if (isDirectExecution(import.meta.url)) { + main().catch((forceError) => { + error( + forceError instanceof Error ? forceError.stack || forceError.message : String(forceError) + ); + process.exit(1); + }); +} diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 68e54741..8cdb3334 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -212,7 +212,10 @@ describe("GitHub workflow boundaries", () => { (step) => step.name === "Download acceptance public key" ); const previousShellBlockmapIndex = publishSteps.findIndex( - (step) => step.name === "Carry forward previous Shell blockmap for differential updates" + (step) => step.name === "Carry forward previous Shell blockmap for legacy updater fallback" + ); + const forceFullDownloadIndex = publishSteps.findIndex( + (step) => step.name === "Force full Shell installer download" ); const validation = publishSteps.find( (step) => step.name === "Validate complete signed acceptance channel" @@ -325,7 +328,8 @@ describe("GitHub workflow boundaries", () => { path: "release/desktop-ci-signing", }); expect(previousShellBlockmapIndex).toBeGreaterThan(-1); - expect(previousShellBlockmapIndex).toBeLessThan(validationIndex); + expect(forceFullDownloadIndex).toBeGreaterThan(previousShellBlockmapIndex); + expect(forceFullDownloadIndex).toBeLessThan(validationIndex); expect(publishSteps[previousShellBlockmapIndex]?.if).toBe( "needs.prepare.outputs.has_previous_desktop == 'true' && needs.prepare.outputs.release_kind == 'full'" ); @@ -337,6 +341,12 @@ describe("GitHub workflow boundaries", () => { expect(publishSteps[previousShellBlockmapIndex]?.run).toContain( "--dir release/desktop-acceptance" ); + expect(publishSteps[forceFullDownloadIndex]?.if).toBe( + "needs.prepare.outputs.release_kind == 'full'" + ); + expect(publishSteps[forceFullDownloadIndex]?.run).toContain( + "desktop:force-full-download -- --directory release/desktop-acceptance" + ); expect(validation?.run).toContain( "validate --directory release/desktop-acceptance --components 'desktop,win-runtime,wsl-engine,wsl-runtime'" ); @@ -461,7 +471,10 @@ describe("GitHub workflow boundaries", () => { (step) => step.name === "Build signed Desktop channel" ); const previousShellBlockmapIndex = publishSteps.findIndex( - (step) => step.name === "Carry forward previous Shell blockmap for differential updates" + (step) => step.name === "Carry forward previous Shell blockmap for legacy updater fallback" + ); + const forceFullDownloadIndex = publishSteps.findIndex( + (step) => step.name === "Force full Shell installer download" ); const productionValidateIndex = publishSteps.findIndex( (step) => step.name === "Validate complete production release" @@ -473,7 +486,8 @@ describe("GitHub workflow boundaries", () => { expect(previousIndex).toBeGreaterThan(-1); expect(carryIndex).toBeGreaterThan(previousIndex); expect(previousShellBlockmapIndex).toBeGreaterThan(carryIndex); - expect(channelIndex).toBeGreaterThan(previousShellBlockmapIndex); + expect(forceFullDownloadIndex).toBeGreaterThan(previousShellBlockmapIndex); + expect(channelIndex).toBeGreaterThan(forceFullDownloadIndex); expect(productionValidateIndex).toBeGreaterThan(channelIndex); expect(attestIndex).toBeGreaterThan(productionValidateIndex); expect(releaseIndex).toBeGreaterThan(attestIndex); @@ -488,6 +502,12 @@ describe("GitHub workflow boundaries", () => { expect(publishSteps[previousShellBlockmapIndex]?.run).toContain( "--dir release/desktop-release-final" ); + expect(publishSteps[forceFullDownloadIndex]?.if).toBe( + "needs.prepare.outputs.release_kind == 'full'" + ); + expect(publishSteps[forceFullDownloadIndex]?.run).toContain( + "desktop:force-full-download -- --directory release/desktop-release-final" + ); expect(publishSteps[productionValidateIndex]?.run).toContain("--release-kind"); expect(publishSteps[releaseIndex]?.run).toContain("--prerelease --latest=false"); expect(publishSteps[releaseIndex]?.run).toContain("not Authenticode-signed"); diff --git a/scripts/verify-desktop-installed-update.test.ts b/scripts/verify-desktop-installed-update.test.ts index f9d0946c..f1bc1cdb 100644 --- a/scripts/verify-desktop-installed-update.test.ts +++ b/scripts/verify-desktop-installed-update.test.ts @@ -310,4 +310,18 @@ describe("verify-desktop-installed-update", () => { "component" ); }); + + it("fails a stalled download internally so the runner can preserve diagnostics", async () => { + const deps = createDeps({ + invoke: vi.fn(async (method) => { + if (method === "checkForUpdates") return state("available"); + if (method === "downloadUpdate") return new Promise(() => undefined); + throw new Error(`Unexpected method: ${method}`); + }), + }); + + await expect( + verifyInstalledDesktopScenario(combinedScenario, deps, { downloadTimeoutMs: 0 }) + ).rejects.toThrow("downloadUpdate timed out"); + }); }); diff --git a/scripts/verify-desktop-installed-update.ts b/scripts/verify-desktop-installed-update.ts index 5cf90a21..2743c389 100644 --- a/scripts/verify-desktop-installed-update.ts +++ b/scripts/verify-desktop-installed-update.ts @@ -65,6 +65,10 @@ export interface VerifyInstalledDesktopDeps { readEvidence(): Promise; } +export interface VerifyInstalledDesktopOptions { + downloadTimeoutMs?: number; +} + export interface InstalledDesktopScenarioReport extends InstalledEvidence { schemaVersion: 1; scenario: InstalledDesktopScenarioName; @@ -96,9 +100,38 @@ function isWslScenario(name: InstalledDesktopScenarioName): boolean { return name === "fresh-wsl" || name === "wsl" || name === "wsl-combined"; } +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1_000; + +async function downloadUpdateWithTimeout( + deps: VerifyInstalledDesktopDeps, + timeoutMs: number +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + deps.invoke("downloadUpdate"), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => { + reject( + new Error( + `Installed Desktop downloadUpdate timed out after ${Math.ceil(timeoutMs / 1_000)} seconds` + ) + ); + }, + Math.max(0, timeoutMs) + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + export async function verifyInstalledDesktopScenario( scenario: InstalledDesktopScenario, - deps: VerifyInstalledDesktopDeps + deps: VerifyInstalledDesktopDeps, + options: VerifyInstalledDesktopOptions = {} ): Promise { if (scenario.name === "fresh-native" || scenario.name === "fresh-wsl") { const evidence = await deps.readEvidence(); @@ -160,13 +193,14 @@ export async function verifyInstalledDesktopScenario( isWslScenario(scenario.name) && scenario.expectedComponentIds.includes("runtime:win32-x64"); let checked = asState(await deps.invoke("checkForUpdates"), "checkForUpdates"); assertPlanComponents(checked, scenario.expectedComponentIds); - await deps.invoke("downloadUpdate"); + const downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; + await downloadUpdateWithTimeout(deps, downloadTimeoutMs); if (scenario.name === "interrupted-download") { await deps.interruptAtPhase("downloading"); await deps.reconnectAfterRestart(); checked = asState(await deps.invoke("checkForUpdates"), "checkForUpdates after interruption"); assertPlanComponents(checked, scenario.expectedComponentIds); - await deps.invoke("downloadUpdate"); + await downloadUpdateWithTimeout(deps, downloadTimeoutMs); } await deps.waitForState("ready"); await deps.prepareActivity();