From 92f3116dee8d10090d1e6d18647b9445ee965a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 7 Jul 2026 17:10:06 -0400 Subject: [PATCH] fix(cli): re-download the browser when the cached archive is corrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partially-downloaded or interrupted chrome-headless-shell archive left in the cache makes @puppeteer/browsers' install() throw "invalid end-of-central-directory" during extraction. That error propagated out of the browser check and hard-blocked the render, forcing users onto the fallback renderer until they manually cleared the cache — a recurring Windows failure. Detect the corrupt-archive extraction error (isCorruptArchiveError), clear the cache to drop the bad archive, and retry the download exactly once; non-corrupt errors and a second corruption still propagate (no infinite retry). The pure predicate and the recovery wrapper are unit-tested. --- packages/cli/src/browser/manager.test.ts | 66 +++++++++++++++++++++++ packages/cli/src/browser/manager.ts | 69 +++++++++++++++++++++--- 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index 7f166efb9..3e60d6bb7 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -501,3 +501,69 @@ describe("findBrowser — cache resolution", () => { expect(warnSpy).toHaveBeenCalledTimes(1); }); }); + +describe("isCorruptArchiveError", () => { + it("matches truncated / corrupt archive extraction failures", async () => { + const { isCorruptArchiveError } = await import("./manager.js"); + for (const msg of [ + "invalid end-of-central-directory record", + "end of central directory record signature not found", + "invalid or corrupt zip file", + "File is not a zip file", + "unexpected end of file", + "the archive is corrupted", + ]) { + expect(isCorruptArchiveError(new Error(msg))).toBe(true); + } + }); + + it("does not match network or unrelated errors", async () => { + const { isCorruptArchiveError } = await import("./manager.js"); + for (const msg of ["ECONNRESET", "socket hang up", "ENOENT: no such file", "boom"]) { + expect(isCorruptArchiveError(new Error(msg))).toBe(false); + } + }); +}); + +describe("installWithCorruptArchiveRecovery", () => { + it("clears the cache and re-downloads once on a corrupt archive, then succeeds", async () => { + const { installWithCorruptArchiveRecovery } = await import("./manager.js"); + const runInstall = vi + .fn() + .mockRejectedValueOnce(new Error("invalid end-of-central-directory record")) + .mockResolvedValueOnce({ executablePath: "/ok" }); + const clearCache = vi.fn(); + const onRecover = vi.fn(); + + const result = await installWithCorruptArchiveRecovery(runInstall, clearCache, onRecover); + + expect(result).toEqual({ executablePath: "/ok" }); + expect(runInstall).toHaveBeenCalledTimes(2); + expect(clearCache).toHaveBeenCalledTimes(1); + expect(onRecover).toHaveBeenCalledTimes(1); + }); + + it("propagates a non-corruption error without clearing the cache", async () => { + const { installWithCorruptArchiveRecovery } = await import("./manager.js"); + const runInstall = vi.fn().mockRejectedValue(new Error("ECONNRESET")); + const clearCache = vi.fn(); + + await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow( + "ECONNRESET", + ); + expect(runInstall).toHaveBeenCalledTimes(1); + expect(clearCache).not.toHaveBeenCalled(); + }); + + it("does not retry forever: a second corruption propagates", async () => { + const { installWithCorruptArchiveRecovery } = await import("./manager.js"); + const runInstall = vi.fn().mockRejectedValue(new Error("end of central directory not found")); + const clearCache = vi.fn(); + + await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow( + "end of central directory", + ); + expect(runInstall).toHaveBeenCalledTimes(2); + expect(clearCache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index a2a345eb4..02a5437f9 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -513,6 +513,48 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise( + runInstall: () => Promise, + clearCache: () => void, + onRecover?: (err: unknown) => void, +): Promise { + try { + return await runInstall(); + } catch (err) { + if (!isCorruptArchiveError(err)) throw err; + onRecover?.(err); + clearCache(); + return await runInstall(); + } +} + async function downloadBrowser(options?: EnsureBrowserOptions): Promise { if (isLinuxArm()) { return ensureLinuxArmBrowser(options); @@ -525,13 +567,26 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise + install({ + cacheDir: CACHE_DIR, + browser: Browser.CHROMEHEADLESSSHELL, + buildId: CHROME_VERSION, + platform, + downloadProgressCallback: options?.onProgress, + }); + + const installed = await installWithCorruptArchiveRecovery( + runInstall, + () => { + rmSync(CACHE_DIR, { recursive: true, force: true }); + mkdirSync(CACHE_DIR, { recursive: true }); + }, + (err) => + console.warn( + `[hyperframes] Cached browser archive was corrupt (${normalizeErrorMessage(err)}); clearing the cache and re-downloading.`, + ), + ); return { executablePath: installed.executablePath, source: "download" }; }