diff --git a/packages/cli/src/browser/downloadStall.process.test.ts b/packages/cli/src/browser/downloadStall.process.test.ts index 6f0af07b11..20cf3d525c 100644 --- a/packages/cli/src/browser/downloadStall.process.test.ts +++ b/packages/cli/src/browser/downloadStall.process.test.ts @@ -40,15 +40,14 @@ async function runFixture(onConnection: (socket: Socket) => void) { }); let stderr = ""; child.stderr.on("data", (chunk) => (stderr += chunk)); - const startedAt = Date.now(); const code = await new Promise((resolve) => { - const giveUp = setTimeout(() => resolve(null), 20_000); + const giveUp = setTimeout(() => resolve(null), 110_000); child.once("exit", (exitCode) => { clearTimeout(giveUp); resolve(exitCode); }); }); - return { code, stderr, port, home, elapsedMs: Date.now() - startedAt }; + return { code, stderr, port, home }; } it("exits with code 1 when the download host accepts the connection and never answers", async () => { @@ -58,21 +57,4 @@ it("exits with code 1 when the download host accepts the connection and never an expect(run.stderr).toContain(`The Chrome download from 127.0.0.1:${run.port} stalled`); expect(run.stderr).toContain("Retrying (2/2)"); expect(existsSync(join(run.home, ".cache", "hyperframes", ".chrome.install.lock"))).toBe(false); -}, 30_000); - -it("does not call a download that keeps sending bytes a stall", async () => { - const run = await runFixture((socket) => { - socket.once("data", () => { - socket.write("HTTP/1.1 200 OK\r\ncontent-length: 100000\r\n\r\n"); - const trickle = setInterval(() => socket.write("x"), 80); - setTimeout(() => { - clearInterval(trickle); - socket.destroy(); - }, 1_200); - }); - }); - - expect(run.code).toBe(1); - expect(run.stderr).not.toContain("no data for"); - expect(run.elapsedMs).toBeGreaterThan(1_000); -}, 30_000); +}, 120_000); diff --git a/packages/cli/src/browser/installInChild.test.ts b/packages/cli/src/browser/installInChild.test.ts new file mode 100644 index 0000000000..0c1798ec85 --- /dev/null +++ b/packages/cli/src/browser/installInChild.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installProgressLine } from "./installProtocol.js"; + +const worker = vi.hoisted(() => ({ + options: undefined as { signal: AbortSignal; onStdout: (chunk: string) => void } | undefined, + finish: undefined as ((paths: string) => void) | undefined, +})); + +vi.mock("../utils/cancellableProcess.js", () => ({ + runRenderSetupWorker: (_mode: string, _input: unknown, options: typeof worker.options) => { + worker.options = options; + return new Promise((resolve, reject) => { + worker.finish = resolve; + options!.signal.addEventListener("abort", () => reject(options!.signal.reason)); + }); + }, +})); + +const { BrowserDownloadStalledError, installInChild } = await import("./installInChild.js"); + +const STALL_MS = 200; +const input = { + browser: "chrome-headless-shell", + buildId: "1", + cacheDir: "/x", + unpack: false, +} as const; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("installInChild stall watchdog", () => { + it("does not call a download that keeps reporting progress a stall", async () => { + const result = installInChild(input as never, { + stallMs: STALL_MS, + host: "h", + onLog: () => {}, + }); + for (let downloaded = 1; downloaded <= 100; downloaded++) { + await vi.advanceTimersByTimeAsync(STALL_MS - 1); + worker.options!.onStdout(installProgressLine(downloaded, 1000)); + } + expect(worker.options!.signal.aborted).toBe(false); + worker.finish!("done"); + await expect(result).resolves.toBe("done"); + }); + + it("aborts with the host and window once no progress arrives for the stall window", async () => { + const result = installInChild(input as never, { + stallMs: STALL_MS, + host: "h", + onLog: () => {}, + }); + const rejected = expect(result).rejects.toThrow(BrowserDownloadStalledError); + worker.options!.onStdout(installProgressLine(1, 100)); + await vi.advanceTimersByTimeAsync(STALL_MS); + await rejected; + await expect(result).rejects.toThrow("The Chrome download from h stalled: no data for 0.2s."); + }); +}); diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index e6199dffe9..d1c9f90431 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -36,6 +36,7 @@ import { openBlobStore } from "./blobStore"; // A test can hold a media copy, and run work after its blob is stored but before history records it. const mediaCopy = vi.hoisted(() => ({ held: null as Promise | null, + entered: null as (() => void) | null, stored: null as (() => Promise) | null, copies: [] as string[], fails: null as ((copy: number) => boolean) | null, @@ -50,6 +51,7 @@ vi.mock("./blobStore", async (importOriginal) => { ...store, put: async (path: string) => { if (path.endsWith(".bin")) { + mediaCopy.entered?.(); await mediaCopy.held; mediaCopy.copies.push(basename(path)); if (mediaCopy.fails?.(mediaCopy.copies.length)) throw new Error("The disk is full."); @@ -70,7 +72,7 @@ const cleanup: Array<() => unknown> = []; afterEach(async () => { for (const step of cleanup.splice(0).reverse()) await step(); - Object.assign(mediaCopy, { held: null, stored: null, copies: [], fails: null }); + Object.assign(mediaCopy, { held: null, entered: null, stored: null, copies: [], fails: null }); }); const inside = (dir: string, path: string) => readFileSync(join(dir, path), "utf-8"); @@ -366,11 +368,13 @@ describe("openProjectHistory", () => { it("never copies the media of a folder that replaced the project", async () => { let release = () => {}; mediaCopy.held = new Promise((resolve) => (release = resolve)); + const firstCopyRunning = new Promise((resolve) => (mediaCopy.entered = resolve)); const media = { "a.bin": Buffer.alloc(2 * 1024 ** 2, 1), "b.bin": Buffer.alloc(2 * 1024 ** 2, 2), }; const { history, write, projectDir } = await project({ "index.html": "A", ...media }); + await firstCopyRunning; renameSync(projectDir, `${projectDir}-moved`); cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true })); mkdirSync(projectDir);