fix(cli): re-download the browser when the cached archive is corrupt

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.
This commit is contained in:
Miguel Ángel
2026-07-07 17:10:06 -04:00
committed by GitHub
parent 4834de37f4
commit 92f3116dee
2 changed files with 128 additions and 7 deletions
+66
View File
@@ -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);
});
});
+62 -7
View File
@@ -513,6 +513,48 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
});
}
/**
* True when `err` is a corrupt/truncated-archive extraction failure, as opposed
* to a network error or a genuine platform problem. A partially-downloaded or
* interrupted browser archive left in the cache makes `install()`'s extraction
* throw "invalid end-of-central-directory" (a zip whose central directory is
* missing/truncated). Left unhandled, that hard-blocks every render on the box
* until the user manually clears the cache — so we detect it and re-download.
*/
export function isCorruptArchiveError(err: unknown): boolean {
const msg = normalizeErrorMessage(err).toLowerCase();
return (
msg.includes("end of central directory") ||
msg.includes("end-of-central-directory") ||
msg.includes("invalid or corrupt") ||
msg.includes("corrupt zip") ||
msg.includes("not a zip") ||
msg.includes("unexpected end of") ||
msg.includes("corrupted")
);
}
/**
* Run a browser install; if it fails because the cached archive is corrupt,
* clear the cache (dropping the bad archive) and retry the download exactly
* once. Non-corruption errors propagate unchanged, and a second corruption
* propagates too (no infinite retry).
*/
export async function installWithCorruptArchiveRecovery<T>(
runInstall: () => Promise<T>,
clearCache: () => void,
onRecover?: (err: unknown) => void,
): Promise<T> {
try {
return await runInstall();
} catch (err) {
if (!isCorruptArchiveError(err)) throw err;
onRecover?.(err);
clearCache();
return await runInstall();
}
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
@@ -525,13 +567,26 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
const installed = await install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,
buildId: CHROME_VERSION,
platform,
downloadProgressCallback: options?.onProgress,
});
const runInstall = () =>
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" };
}