mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user