diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index 987493d0fa..697ae26167 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -135,7 +135,9 @@ function installPuppeteerBrowsersMock( browserPlatform?: string; installedInHfCacheError?: Error; installResult?: { executablePath: string }; - installImpl?: () => Promise<{ executablePath: string }>; + installImpl?: (installArgs: { + downloadProgressCallback?: (downloaded: number, total: number) => void; + }) => Promise<{ executablePath: string }>; } = {}, ) { vi.doMock("@puppeteer/browsers", () => ({ @@ -909,6 +911,121 @@ describe("installWithCorruptArchiveRecovery", () => { }); }); +// PRINFRA-682: a chrome-headless-shell download that never gets a response +// (e.g. a network that only egresses through a proxy `@puppeteer/browsers` +// silently fails to use — see `proxyDownloadStallHint`) hangs at "Downloading +// Chrome... 0%" forever, with no timeout anywhere in the install() call +// chain. This guards the watchdog that makes that fail loud instead. +describe("withDownloadStallGuard", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves normally when the run completes before any stall", async () => { + const { withDownloadStallGuard } = await import("./manager.js"); + const result = await withDownloadStallGuard( + async (onProgress) => { + onProgress(50, 100); + onProgress(100, 100); + return "done"; + }, + { timeoutMs: 1_000 }, + ); + expect(result).toBe("done"); + }); + + it("rejects if no progress arrives before the timeout (connection never responds)", async () => { + vi.useFakeTimers(); + const { withDownloadStallGuard } = await import("./manager.js"); + + // A run that never resolves and never reports progress — the "0% forever" + // symptom: no response ever comes back at all. + const guarded = withDownloadStallGuard(() => new Promise(() => {}), { + timeoutMs: 1_000, + }); + const outcome = guarded.then( + () => "resolved", + (err: unknown) => (err instanceof Error ? err.message : String(err)), + ); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(outcome).resolves.toContain("stalled"); + }); + + it("resets the watchdog on every progress tick, so a slow-but-live download never trips it", async () => { + vi.useFakeTimers(); + const { withDownloadStallGuard } = await import("./manager.js"); + + const result = withDownloadStallGuard( + async (onProgress) => { + for (let i = 1; i <= 5; i += 1) { + // Each tick arrives just under the timeout — only safe if the + // watchdog resets rather than measuring total elapsed duration. + await new Promise((resolve) => setTimeout(resolve, 900)); + onProgress(i * 20, 100); + } + return "done"; + }, + { timeoutMs: 1_000 }, + ); + + await vi.advanceTimersByTimeAsync(900 * 5 + 10); + + await expect(result).resolves.toBe("done"); + }); + + it("still trips if progress stops arriving partway through (mid-download stall)", async () => { + vi.useFakeTimers(); + const { withDownloadStallGuard } = await import("./manager.js"); + + const guarded = withDownloadStallGuard( + (onProgress) => + new Promise((resolve) => { + onProgress(10, 100); + // ...then nothing further ever arrives — resolve() is never called. + void resolve; + }), + { timeoutMs: 1_000 }, + ); + const outcome = guarded.then( + () => "resolved", + (err: unknown) => (err instanceof Error ? err.message : String(err)), + ); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(outcome).resolves.toContain("stalled"); + }); + + it("propagates a genuine rejection unchanged, not a stall error", async () => { + const { withDownloadStallGuard } = await import("./manager.js"); + await expect( + withDownloadStallGuard( + async () => { + throw new Error("ENOTFOUND"); + }, + { timeoutMs: 1_000 }, + ), + ).rejects.toThrow("ENOTFOUND"); + }); + + it("forwards progress ticks to the caller's onProgress callback", async () => { + const { withDownloadStallGuard } = await import("./manager.js"); + const onProgress = vi.fn(); + await withDownloadStallGuard( + async (tick) => { + tick(10, 100); + tick(100, 100); + return "done"; + }, + { timeoutMs: 1_000, onProgress }, + ); + expect(onProgress).toHaveBeenNthCalledWith(1, 10, 100); + expect(onProgress).toHaveBeenNthCalledWith(2, 100, 100); + }); +}); + // 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 @@ -1007,6 +1124,67 @@ describe("downloadBrowser — install failure surfaces HYPERFRAMES_BROWSER_PATH expect((caught as Error).cause).toBe(originalError); }, ); + + it("appends a proxy hint when HTTP(S)_PROXY is set", async () => { + process.env["HTTPS_PROXY"] = "http://127.0.0.1:18080"; + installFsMocks({ existing: new Set([CACHE_ROOT]) }); + installPuppeteerBrowsersMock({ + installedInHfCache: [], + installImpl: async () => { + throw new Error("connect ETIMEDOUT"); + }, + }); + + const { ensureBrowser } = await import("./manager.js"); + await expect(ensureBrowser()).rejects.toThrow(/HTTPS_PROXY.*is set/s); + + delete process.env["HTTPS_PROXY"]; + }); + + it("omits the proxy hint when no proxy env var is set", async () => { + installFsMocks({ existing: new Set([CACHE_ROOT]) }); + installPuppeteerBrowsersMock({ + installedInHfCache: [], + installImpl: async () => { + throw new Error("connect ETIMEDOUT"); + }, + }); + + const { ensureBrowser } = await import("./manager.js"); + let caught: unknown; + try { + await ensureBrowser(); + } catch (err) { + caught = err; + } + expect((caught as Error).message).not.toContain("HTTPS_PROXY"); + }); + + it("PRINFRA-682: a download that never responds surfaces a stall error with the HYPERFRAMES_BROWSER_PATH hint instead of hanging forever", async () => { + vi.useFakeTimers(); + installFsMocks({ existing: new Set([CACHE_ROOT]) }); + installPuppeteerBrowsersMock({ + installedInHfCache: [], + // Mirrors the real bug: install() never resolves, rejects, or reports + // progress — exactly what a proxy-only network's silently-bypassed + // direct connection attempt looks like from the caller's side. + installImpl: () => new Promise(() => {}), + }); + + const { ensureBrowser } = await import("./manager.js"); + const attempt = ensureBrowser(); + const outcome = attempt.then( + () => "resolved", + (err: unknown) => (err instanceof Error ? err.message : String(err)), + ); + + await vi.advanceTimersByTimeAsync(90_000); + + const message = await outcome; + expect(message).toContain("Download stalled"); + expect(message).toContain("HYPERFRAMES_BROWSER_PATH"); + vi.useRealTimers(); + }); }); // Regression guard for HF#2103: `hyperframes render` hung forever on macOS diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 1780459851..210eee5ec6 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -44,6 +44,21 @@ const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome"); // too or it silently picks system Chrome over a perfectly good headless-shell. const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell"); +// `@puppeteer/browsers`' downloadFile() (lib/httpUtil.js) opens a bare +// node:http(s) request with no AbortController/timeout anywhere in the chain +// (confirmed by source read of the resolved 3.x package) — a connection that +// never gets a response (e.g. a network that only egresses through an +// HTTP(S)_PROXY, which downloadFile's own proxy-agent support silently no-ops +// on: `proxy-agent` is only an *optional peer* dependency, so the package's +// dynamic import of it throws, is caught, and the request falls back to a +// bare agent that never reads proxy env vars) just hangs at "Downloading +// Chrome... 0%" forever. See PRINFRA-682. This is a stall watchdog, not a +// total-download-duration cap: it resets on every progress tick, so a +// slow-but-actually-progressing download (large binary over a slow line) +// never trips it — only a connection that stops producing any bytes for this +// long does. +const DOWNLOAD_STALL_TIMEOUT_MS = 45_000; + // `@puppeteer/browsers`' install() has no concurrency guard of its own — two // CLI invocations that both miss the cache at the same time both extract into // the same target directory simultaneously. A killed/interrupted extraction @@ -707,6 +722,62 @@ export async function installWithCorruptArchiveRecovery( } } +/** + * Race an install-style call against a "no progress" watchdog instead of a + * total-duration cap. The timer (re)arms on every progress tick — and once + * before the first one, since a connection that never gets a response never + * ticks at all — so a download that is merely slow keeps resetting it + * indefinitely, while one that has truly stalled (no bytes, ever, or bytes + * that stop arriving mid-stream) trips it after `timeoutMs` of silence. + * + * This cannot abort the underlying socket (`@puppeteer/browsers` exposes no + * `AbortSignal`), so a stalled connection lingers until the OS reclaims it — + * but the CLI's root exit handler calls `process.exit()` unconditionally once + * a command settles (see `cli.ts`'s `registerRootExitRequester`), so + * rejecting here still unblocks the process instead of leaving it hung. + */ +export function withDownloadStallGuard( + run: (onProgress: (downloadedBytes: number, totalBytes: number) => void) => Promise, + options: { + timeoutMs: number; + onProgress?: (downloadedBytes: number, totalBytes: number) => void; + }, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType; + const armTimer = () => { + clearTimeout(timer); + timer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error(`Download stalled: no progress for ${Math.round(options.timeoutMs / 1000)}s.`), + ); + }, options.timeoutMs); + }; + armTimer(); + run((downloadedBytes, totalBytes) => { + if (settled) return; + armTimer(); + options.onProgress?.(downloadedBytes, totalBytes); + }).then( + (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }, + (err: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }, + ); + }); +} + /** * When `@puppeteer/browsers`' install() rejects for any reason the corrupt- * archive recovery path can't handle (all CDN providers rejected — the @@ -731,6 +802,33 @@ function browserPathHintForPlatform(): string { return "/usr/bin/google-chrome"; } +/** + * `@puppeteer/browsers`' download request does attempt real proxy support + * (a dynamic import of the `proxy-agent` package), but that package is only + * an *optional peer* dependency that a normal hyperframes install never + * pulls in — so the import throws, is caught silently, and the request falls back + * to a bare node:http(s) agent that never reads HTTP_PROXY/HTTPS_PROXY on + * its own. On a network that only egresses through that proxy, the direct + * connection attempt doesn't fail fast; it stalls, which is what + * `withDownloadStallGuard` above catches. Naming the proxy as the likely + * cause (when one is actually configured) turns "download stalled, no idea + * why" into an actionable diagnosis instead of just repeating the same + * escape hatch with no context. + */ +function proxyDownloadStallHint(): string { + const proxyConfigured = ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"].some((key) => + Boolean(process.env[key]?.trim()), + ); + if (!proxyConfigured) return ""; + return ( + `\n\nHTTP_PROXY/HTTPS_PROXY is set in this environment, but chrome-headless-shell's ` + + `download does not honor it (the proxy support it depends on is an optional ` + + `dependency hyperframes does not install) — on a network that requires the proxy for ` + + `internet access, the download attempts a direct connection and stalls instead of ` + + `failing fast. Use HYPERFRAMES_BROWSER_PATH above to skip the download entirely.` + ); +} + function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error { const original = normalizeErrorMessage(cause); const example = browserPathHintForPlatform(); @@ -741,7 +839,8 @@ function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error { `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.`; + `Docker image which ships a compatible headless-shell.` + + proxyDownloadStallHint(); return new Error(message, { cause: cause instanceof Error ? cause : undefined }); } @@ -758,13 +857,17 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise - install({ - cacheDir: CACHE_DIR, - browser: Browser.CHROMEHEADLESSSHELL, - buildId: CHROME_VERSION, - platform, - downloadProgressCallback: options?.onProgress, - }); + withDownloadStallGuard( + (onProgress) => + install({ + cacheDir: CACHE_DIR, + browser: Browser.CHROMEHEADLESSSHELL, + buildId: CHROME_VERSION, + platform, + downloadProgressCallback: onProgress, + }), + { timeoutMs: DOWNLOAD_STALL_TIMEOUT_MS, onProgress: options?.onProgress }, + ); let installed; try {