Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 179 additions & 1 deletion packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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<void>(() => {}), {
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<void>((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
Expand Down Expand Up @@ -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
Expand Down
119 changes: 111 additions & 8 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -707,6 +722,62 @@ export async function installWithCorruptArchiveRecovery<T>(
}
}

/**
* 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<T>(
run: (onProgress: (downloadedBytes: number, totalBytes: number) => void) => Promise<T>,
options: {
timeoutMs: number;
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
},
): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
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
Expand All @@ -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();
Expand All @@ -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 });
}

Expand All @@ -758,13 +857,17 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
}

const runInstall = () =>
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 {
Expand Down
Loading