fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on pinned browser download failures

When `@puppeteer/browsers`' install() rejected for any reason the corrupt-
archive recovery path couldn't handle (`All providers failed for
chrome-headless-shell <ver>`, DNS/network, macOS Gatekeeper, a second
corruption), the raw error propagated with no mention of the escape
hatch. Wrap the surfaced error to name `HYPERFRAMES_BROWSER_PATH` with a
platform-specific example (macOS/Windows/Linux) and keep the original
via `cause`.

Field feedback ts 1784055194.202169 in #hyperframes-cli-feedback
(darwin/arm64, HF CLI 0.7.57) hit this and recovered by pointing the
env var at system Google Chrome — they discovered the workaround on
their own because the error didn't tell them.

Download-time sibling of #2078 (SIGTRAP at launch, same remediation,
different trigger).

— Via

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 19:32:45 +00:00
co-authored by Claude Opus 4.7
parent e05debe1af
commit fd02e6efc9
2 changed files with 119 additions and 11 deletions
+65
View File
@@ -697,6 +697,71 @@ describe("installWithCorruptArchiveRecovery", () => {
});
});
// Sibling failure mode to #2078 (SIGTRAP at launch): the field feedback in
// #hyperframes-cli-feedback ts 1784055194.202169 (darwin/arm64, HF CLI 0.7.57)
// hit `All providers failed for chrome-headless-shell 152.0.7928.2` at download
// time and had to discover `HYPERFRAMES_BROWSER_PATH` on their own. The raw
// error propagated straight through `downloadBrowser` without naming the
// escape hatch. This guards the rewrap so the next reporter sees the hint.
describe("downloadBrowser — install failure surfaces HYPERFRAMES_BROWSER_PATH hint", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
// Simulate the reporter's environment: macOS on Apple Silicon.
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
delete process.env["HYPERFRAMES_BROWSER_PATH"];
installChildProcessMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
vi.doUnmock("node:child_process");
vi.doUnmock("@puppeteer/browsers");
});
it("rethrows a non-corrupt install failure with an HYPERFRAMES_BROWSER_PATH hint and preserves the original via cause", async () => {
// No cache, no system Chrome — forces the download-of-last-resort path
// that ends in @puppeteer/browsers install().
installFsMocks({ existing: new Set([CACHE_ROOT]) });
const rawMsg = "All providers failed for chrome-headless-shell 152.0.7928.2";
const originalError = new Error(rawMsg);
installPuppeteerBrowsersMock({
installedInHfCache: [],
installImpl: async () => {
throw originalError;
},
});
const { ensureBrowser } = await import("./manager.js");
let caught: unknown;
try {
await ensureBrowser();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
const msg = (caught as Error).message;
// Names the escape-hatch env var by name (that's the entire point).
expect(msg).toContain("HYPERFRAMES_BROWSER_PATH");
// Includes the platform-specific example path (macOS here).
expect(msg).toContain("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
// Keeps the original provider-failure text so the user can still
// diagnose the underlying cause from the surfaced message.
expect(msg).toContain(rawMsg);
// Structured `cause` chain intact for tooling that walks it.
expect((caught as Error).cause).toBe(originalError);
});
});
// Regression guard for HF#2103: `hyperframes render` hung forever on macOS
// (Apple Silicon) under Node >= 24.16. Root cause was NOT in this file — it was
// the extractor `@puppeteer/browsers` <3.0.2 shells out to. That chain
+54 -11
View File
@@ -640,6 +640,44 @@ export async function installWithCorruptArchiveRecovery<T>(
}
}
/**
* When `@puppeteer/browsers`' install() rejects for any reason the corrupt-
* archive recovery path can't handle (all CDN providers rejected — the
* `All providers failed for chrome-headless-shell <ver>` case reported from the
* field on darwin/arm64 with CLI 0.7.57; DNS/network failure; a macOS Gatekeeper
* quarantine that blocks the pinned Dev-channel binary from launching a probe;
* a second corruption that trips the retry gate), the raw error names none of
* the escape hatches that would unblock the user. Rewrap it in one that does:
* `HYPERFRAMES_BROWSER_PATH` wins over both the managed download and system
* lookup (see `findFromEnv` above), so pointing it at an already-installed
* Chrome renders successfully via the screenshot fallback while the pinned
* chrome-headless-shell download is broken. Sibling failure mode: #2078
* (SIGTRAP at launch), same remediation, different trigger.
*/
function browserPathHintForPlatform(): string {
if (process.platform === "darwin") {
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
}
if (process.platform === "win32") {
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
}
return "/usr/bin/google-chrome";
}
function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error {
const original = normalizeErrorMessage(cause);
const example = browserPathHintForPlatform();
const message =
`Failed to download chrome-headless-shell ${CHROME_VERSION}: ${original}\n\n` +
`Point hyperframes at an already-installed Chrome/Chromium instead:\n\n` +
` export HYPERFRAMES_BROWSER_PATH="${example}"\n\n` +
`Then re-run your command. Any Chrome build works for the screenshot ` +
`capture path; install a real chrome-headless-shell later if you need the ` +
`perf-optimized BeginFrame path. Alternatively, run inside the hyperframes ` +
`Docker image which ships a compatible headless-shell.`;
return new Error(message, { cause: cause instanceof Error ? cause : undefined });
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
@@ -661,17 +699,22 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
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.`,
),
);
let installed;
try {
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.`,
),
);
} catch (err) {
throw wrapDownloadFailureWithBrowserPathHint(err);
}
return { executablePath: installed.executablePath, source: "download" };
}