Files
heygen-com__hyperframes/packages/cli/src/browser/manager.ts
T
miga-heygen 4ed25995b1 fix(cli): stop chrome-headless-shell download hangs from stalling forever
@puppeteer/browsers' download path has no AbortController/timeout anywhere
in its chain, and its proxy support is inert (proxy-agent is only an
optional peer dependency hyperframes doesn't install) — so on a network
that requires a proxy, the download attempts a direct connection and hangs
at "Downloading Chrome... 0%" forever instead of failing.

Add a stall watchdog (resets on every progress tick, not a total-duration
cap, so a slow-but-live download is unaffected) around the install() call,
and name HTTP(S)_PROXY as the likely cause in the existing
HYPERFRAMES_BROWSER_PATH remediation hint when one is configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:28:33 +00:00

909 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fallow-ignore-file code-duplication
import { execSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs";
import { basename } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
type PuppeteerBrowsers = typeof import("@puppeteer/browsers");
async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
try {
return await import("@puppeteer/browsers");
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Failed to load @puppeteer/browsers: ${cause}\n` +
`Fix: run \`npm install\` or \`bun install\` to restore missing packages, then retry.`,
);
}
}
// Bumped from 152.0.7928.2 on 2026-08-10 to pick up crbug 522872457's fix
// (CL 8032671, "Force-merge PendingLayer for canvas child descendant", merged
// 2026-07-07 — after the 152.0.7935.0 canary cut, so the old pin predated it).
//
// Measured on the shipping headless-shell binary, drawElementImage vs a CDP
// screenshot of the identical state:
// sibling content dropped from 3D captures — FIXED
// background lost from 3D captures — FIXED
// backface-visibility:hidden ignored — STILL BROKEN (1.4 dB -> 14.8 dB;
// better, still far under the
// 32 dB floor)
// So the 3D compile gate must stay. See PRINFRA-486.
//
// Deliberately Beta, not Canary. 153.0.8000.0 measured identical to this build
// on every probe variant, so crossing a major buys nothing measurable.
const CHROME_VERSION = "152.0.7977.30";
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
// Puppeteer's managed cache — where `@puppeteer/browsers install
// chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's
// `resolveHeadlessShellPath` scans the same directory; the CLI must look here
// 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
// from that race can leave a binary that merely *exists* (so doctor/lint/
// validate all report healthy) while missing bits a clean install sets (e.g.
// macOS Gatekeeper/quarantine + GPU/Metal entitlements) — reported as headless
// GPU frame capture silently returning all-black frames despite --browser-gpu
// auto/hardware, invisible until someone inspects the actual pixels.
//
// mkdirSync is atomic (EEXIST if another process already holds it), so it
// doubles as a zero-dependency cross-process mutex — no lockfile library needed.
const INSTALL_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.lock");
const INSTALL_RECLAIM_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.reclaim.lock");
const INSTALL_LOCK_TIMINGS = {
staleMs: 120_000,
pollMs: 200,
heartbeatMs: 15_000,
waitNoticeMs: 10_000,
};
interface InstallLockTimings {
staleMs: number;
pollMs: number;
heartbeatMs: number;
waitNoticeMs: number;
}
function isErrno(err: unknown, code: string): boolean {
return (err as NodeJS.ErrnoException).code === code;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function tryAcquireDirLock(lockDir: string): boolean {
try {
// recursive:false is load-bearing: it's what makes this throw EEXIST
// (and therefore act as a mutex) instead of silently no-op'ing like
// `mkdir -p` when the lock dir already exists.
mkdirSync(lockDir, { recursive: false });
return true;
} catch (err) {
if (!isErrno(err, "EEXIST")) throw err;
return false;
}
}
function isDirLockStale(lockDir: string, timeoutMs: number): boolean {
try {
return Date.now() - statSync(lockDir).mtimeMs > timeoutMs;
} catch (err) {
if (isErrno(err, "ENOENT")) return false;
throw err;
}
}
function reclaimStaleInstallLock(timeoutMs: number): void {
if (!tryAcquireDirLock(INSTALL_RECLAIM_LOCK_DIR)) return;
try {
const mtimeMs = statSync(INSTALL_LOCK_DIR).mtimeMs;
if (Date.now() - mtimeMs > timeoutMs) {
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
}
} catch (err) {
if (!isErrno(err, "ENOENT")) throw err;
} finally {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
}
}
function reclaimAbandonedReclaimLock(timeoutMs: number): void {
try {
if (isDirLockStale(INSTALL_RECLAIM_LOCK_DIR, timeoutMs)) {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
}
} catch (err) {
if (!isErrno(err, "ENOENT")) throw err;
}
}
function touchInstallLock(): void {
try {
const now = new Date();
utimesSync(INSTALL_LOCK_DIR, now, now);
} catch {
// ponytail: heartbeat is best-effort; stale-lock reclaim remains the fallback.
}
}
export async function withInstallLock<T>(
fn: () => Promise<T>,
timings: InstallLockTimings = INSTALL_LOCK_TIMINGS,
): Promise<T> {
// recursive:false below needs the parent to already exist (unlike `mkdir -p`).
// Keep lock dirs outside CACHE_DIR so force-clearing the Chrome cache cannot
// delete another installer's in-flight lock.
if (!existsSync(CACHE_ROOT_DIR)) mkdirSync(CACHE_ROOT_DIR, { recursive: true });
let deadline = Date.now() + timings.staleMs;
const waitStart = Date.now();
let lastNoticeMs = 0;
for (;;) {
if (existsSync(INSTALL_RECLAIM_LOCK_DIR)) {
// A process can die after acquiring the reclaim gate but before its
// synchronous cleanup runs. Without aging out that gate, every future
// installer sleeps here forever and never reaches the install-lock
// timeout/reclaim path below.
reclaimAbandonedReclaimLock(timings.staleMs);
await sleep(timings.pollMs);
continue;
}
if (tryAcquireDirLock(INSTALL_LOCK_DIR)) {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
break;
}
const waitedMs = Date.now() - waitStart;
if (waitedMs - lastNoticeMs >= timings.waitNoticeMs) {
lastNoticeMs = waitedMs;
console.warn(
`[browser] Waiting for another hyperframes process to finish installing chrome-headless-shell (${Math.round(waitedMs / 1000)}s elapsed)...`,
);
}
if (isDirLockStale(INSTALL_LOCK_DIR, timings.staleMs) || Date.now() > deadline) {
// The reclaim gate matters when multiple waiters cross the timeout at
// once: without it, waiter A can delete the stale lock and acquire a
// fresh one, then waiter B (whose old deadline also expired) can delete
// A's fresh lock. The gate serializes reclaimers, and the mtime re-check
// after the gate prevents deleting a fresh lock another waiter just won.
reclaimStaleInstallLock(timings.staleMs);
deadline = Date.now() + timings.staleMs;
continue;
}
await sleep(timings.pollMs);
}
const heartbeat = setInterval(touchInstallLock, timings.heartbeatMs);
if (typeof heartbeat.unref === "function") heartbeat.unref();
try {
return await fn();
} finally {
clearInterval(heartbeat);
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
}
}
export type BrowserSource = "env" | "cache" | "system" | "download";
export interface BrowserResult {
executablePath: string;
source: BrowserSource;
}
export interface EnsureBrowserOptions {
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
// Purge any cached HF-managed download before resolving, so a stale or
// partially-extracted install can't make the retry look like a no-op.
force?: boolean;
// Always resolve to OUR pinned `CHROME_VERSION` build (cached, or freshly
// downloaded) — skip both the shared puppeteer-cache preference (some other
// tool's install, arbitrary version) and system Chrome (tracks Stable,
// arbitrary version, doesn't get updated in lockstep with this codebase).
// Rendering behavior should not vary with whatever Chrome happens to be
// sitting on the machine: it's the version we've actually tested against,
// and the one that implements `canvas.drawElementImage` (Dev/Canary-only —
// Stable doesn't have it, so system Chrome used to crash drawElement-
// eligible renders outright; HF#2060). `HYPERFRAMES_BROWSER_PATH` still
// wins over this — an explicit override is still an explicit override.
preferManagedChrome?: boolean;
}
interface CacheLookupResult {
result?: BrowserResult;
staleHyperframesCachePath?: string;
// Root install-folder path for the stale entry (InstalledBrowser#path), NOT
// the missing executablePath above — this is what actually needs deleting.
staleInstallPath?: string;
}
/**
* Remove one browser version's install directory (not the whole CACHE_DIR).
* @puppeteer/browsers' install() treats an existing-but-incomplete directory
* as already installed and throws "folder exists but executable is missing"
* rather than re-extracting — so an extraction interrupted by a Windows AV
* lock, a sleep/wake cycle, or ctrl-C (left with only alphabetically-early
* files like ABOUT/LICENSE, no exe) wedges every subsequent ensure/render
* with the same error until someone manually deletes the directory. Purging
* it first makes the retry actually retry.
*/
function purgeStaleInstall(installPath: string): void {
rmSync(installPath, { recursive: true, force: true });
}
// --- Internal helpers -------------------------------------------------------
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
process.platform === "darwin"
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
: process.platform === "win32"
? [
join(
process.env["ProgramW6432"] ?? process.env["PROGRAMFILES"] ?? "C:\\Program Files",
"Google",
"Chrome",
"Application",
"chrome.exe",
),
join(
process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)",
"Google",
"Chrome",
"Application",
"chrome.exe",
),
...(process.env["LOCALAPPDATA"]
? [join(process.env["LOCALAPPDATA"], "Google", "Chrome", "Application", "chrome.exe")]
: []),
]
: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
function whichBinary(name: string): string | undefined {
try {
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
windowsHide: true,
});
const first = output
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
return first || undefined;
} catch {
return undefined;
}
}
// Env-var aliases for a caller-supplied browser executable path. The CLI-native
// name `HYPERFRAMES_BROWSER_PATH` is the canonical spelling (documented via the
// download-failure hint added in #2443). `PRODUCER_HEADLESS_SHELL_PATH` is the
// engine-side name that per-worker render launches already honor (see
// `packages/engine/src/services/browserManager.ts` and `render.ts` which even
// propagates the CLI-resolved executable into it). Docs in
// `skills/hyperframes-animation/adapters/typegpu.md`,
// `packages/gcp-cloud-run/Dockerfile`, and `examples/k8s-jobs/Dockerfile.example`
// all instruct users to set `PRODUCER_HEADLESS_SHELL_PATH`, so field reports
// (e.g. `#hyperframes-cli-feedback` ts=1784095034 on win32/x64) hit the case
// where `render` completes via that env var while `check`, `snapshot`, and
// `compare` all ignore it and crash on the cached headless-shell instead.
// Alias them here so every consumer of `openSettledCompositionPage` (which
// calls `ensureBrowser` → `findFromEnv`) picks up the same escape hatch.
// Tiebreak: HYPERFRAMES_BROWSER_PATH wins when both are set, matching the
// CLI-native canonicalization in `render.ts` (which only sets
// `PRODUCER_HEADLESS_SHELL_PATH` if not already present).
function findFromEnv(): BrowserResult | undefined {
const envPath =
process.env["HYPERFRAMES_BROWSER_PATH"] ?? process.env["PRODUCER_HEADLESS_SHELL_PATH"];
if (envPath && existsSync(envPath)) {
return { executablePath: envPath, source: "env" };
}
return undefined;
}
/**
* Hyperframes-managed cache only (populated by `ensureBrowser` as a
* download-of-last-resort, pinned to `CHROME_VERSION`).
*/
async function findFromHyperframesCache(): Promise<CacheLookupResult> {
if (!existsSync(CACHE_DIR)) return {};
const { Browser, detectBrowserPlatform, getInstalledBrowsers } = await loadPuppeteerBrowsers();
// A corrupt cache (stub file where a browser dir is expected, malformed
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
// browser" so resolution falls through to system/download instead of
// crashing every caller.
let installed: Awaited<ReturnType<typeof getInstalledBrowsers>>;
try {
installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const suffix = code ? ` (${code})` : "";
console.warn(
`[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`,
);
installed = [];
}
// Match on buildId too, not just browser type — an install left over from
// an older hyperframes version (this pin has moved 131 → 151 → 152 across
// releases) must NOT satisfy resolution, or an upgrade silently keeps
// running whatever build happened to be cached instead of ever fetching
// the version this release actually needs (HF#2060 review). Match platform
// as well so a shared/migrated cache cannot return a foreign executable.
const hostPlatform = detectBrowserPlatform();
const match = installed.find(
(b) =>
b.browser === Browser.CHROMEHEADLESSSHELL &&
b.buildId === CHROME_VERSION &&
b.platform === hostPlatform,
);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
}
return {};
}
async function findFromCache(): Promise<CacheLookupResult> {
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
// chrome-headless-shell` lands, and where `puppeteer install` from a project
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
// `resolveHeadlessShellPath` reads from here and selects newest-version-
// first; the CLI must match that semantic or it will silently hand the
// engine an older binary than the engine itself would pick.
//
// We intentionally check puppeteer BEFORE the hyperframes-managed cache:
// this is the non-`preferManagedChrome` path, which exists so a user who
// installed chrome-headless-shell separately (via `@puppeteer/browsers
// install`) keeps using that binary instead of being silently switched to
// the HF-pinned one. Note `CHROME_VERSION` (above) is a pre-Stable pin
// that may be NEWER than a user's puppeteer-cache Stable build — this is
// about respecting an explicit prior choice, not "newest wins".
const fromPuppeteer = findFromPuppeteerCache();
if (fromPuppeteer) {
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache. This is the fallback path: only reached
// when no puppeteer-cache binary exists.
return findFromHyperframesCache();
}
/**
* Parse a puppeteer-cache version directory name (`linux-148.0.7778.97`,
* `mac_arm-131.0.6778.85`, etc.) into a numeric tuple for ordering.
*
* Lexicographic sort on these strings is buggy because `"99"` > `"148"` (the
* `9` outranks the `1` character-wise), so a 99-era binary would beat a
* 148-era binary in `.sort().reverse()`. We split on `-` to drop the platform
* prefix, then on `.` to get integer segments. Returns `undefined` for names
* that don't have at least one parseable numeric segment so they sort last.
*/
function parseVersionSegments(versionDir: string): number[] | undefined {
const dashIdx = versionDir.indexOf("-");
const versionPart = dashIdx >= 0 ? versionDir.slice(dashIdx + 1) : versionDir;
const segments = versionPart.split(".");
const parsed: number[] = [];
for (const seg of segments) {
const n = parseInt(seg, 10);
if (!Number.isFinite(n)) {
// Stop at the first non-numeric segment but keep what we've collected.
break;
}
parsed.push(n);
}
return parsed.length > 0 ? parsed : undefined;
}
/** Numeric semver-style descending comparator for puppeteer cache dirs. */
function compareVersionDirsDescending(a: string, b: string): number {
const pa = parseVersionSegments(a);
const pb = parseVersionSegments(b);
// Unparseable names sort after parseable ones (so we still try them, just last).
if (!pa && !pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const av = pa[i] ?? 0;
const bv = pb[i] ?? 0;
if (av !== bv) return bv - av; // descending (newest first)
}
return 0;
}
const CACHED_HEADLESS_SHELL_EXECUTABLES: Readonly<
Record<string, readonly [directory: string, executable: string]>
> = {
"darwin/arm64": ["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
"darwin/x64": ["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"win32/ia32": ["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
"win32/x64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
};
function cachedHeadlessShellExecutable(
hostPlatform = process.platform,
hostArch = process.arch,
): readonly [directory: string, executable: string] | undefined {
// Chrome for Testing cache entries are host-specific. Resolve exactly one
// platform/architecture directory so a foreign binary cannot win by probe order.
// Chrome for Testing does not publish Linux ARM64 binaries. Windows ARM64 can
// emulate x64 only on supported Windows 11 builds, which this platform/arch-only
// resolver cannot prove, so both hosts deliberately fall through to system
// browser discovery instead of attempting a potentially foreign cached binary.
return CACHED_HEADLESS_SHELL_EXECUTABLES[`${hostPlatform}/${hostArch}`];
}
function findFromPuppeteerCache(): BrowserResult | undefined {
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
const executable = cachedHeadlessShellExecutable();
if (!executable) return undefined;
let versions: string[];
try {
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
// (the previous implementation, still in engine `resolveHeadlessShellPath`)
// mis-orders `linux-99...` ahead of `linux-148...` because character `'9'`
// outranks `'1'`. See `parseVersionSegments` above.
versions = [...readdirSync(PUPPETEER_CACHE_DIR)].sort(compareVersionDirsDescending);
} catch {
return undefined;
}
for (const version of versions) {
// Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts —
// keep them aligned. If puppeteer ever changes the on-disk layout the two
// need to move together.
const binary = join(PUPPETEER_CACHE_DIR, version, ...executable);
if (existsSync(binary)) {
return { executablePath: binary, source: "cache" };
}
}
return undefined;
}
/**
* True iff the binary at `executablePath` is `chrome-headless-shell` (i.e. the
* Chromium build that still exposes `HeadlessExperimental.enable` /
* `beginFrame`). Regular Chrome and `chromium` have dropped those domains, so
* the engine's perf-optimized BeginFrame capture path silently degrades to
* screenshot mode when those are used.
*/
function isHeadlessShellBinary(executablePath: string): boolean {
const name = basename(executablePath).toLowerCase();
return name === "chrome-headless-shell" || name === "chrome-headless-shell.exe";
}
/**
* Emit a one-time warning when the CLI selects a non-headless-shell binary on
* Linux. Idempotent across repeated `findBrowser()` calls so a long-running
* `hyperframes studio` process doesn't get spammed.
*/
let _warnedSystemFallback = false;
function warnSystemFallbackOnce(executablePath: string): void {
if (_warnedSystemFallback) return;
if (process.platform !== "linux") return;
if (isHeadlessShellBinary(executablePath)) return;
_warnedSystemFallback = true;
console.warn(
`[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell\n(Or set HYPERFRAMES_BROWSER_PATH to point at an existing chrome-headless-shell binary.)`,
);
}
/** Test-only: reset the one-shot warn latch. */
export function _resetSystemFallbackWarnForTests(): void {
_warnedSystemFallback = false;
}
export function findSystemBrowser(): BrowserResult | undefined {
for (const p of SYSTEM_CHROME_PATHS) {
if (existsSync(p)) {
return { executablePath: p, source: "system" };
}
}
const fromWhich = whichBinary("google-chrome") ?? whichBinary("chromium");
if (fromWhich) {
return { executablePath: fromWhich, source: "system" };
}
return undefined;
}
// --- Public API -------------------------------------------------------------
/**
* Find an existing browser without downloading.
* Resolution: env var -> cached download -> system Chrome.
*/
export async function findBrowser(): Promise<BrowserResult | undefined> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
const fromCache = await findFromCache();
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await withInstallLock(async () => {
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
return downloadBrowser();
});
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
);
}
}
const fromSystem = findSystemBrowser();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
}
return fromSystem;
}
/**
* On Linux ARM64, attempt to auto-install system Chromium if not found.
* This makes `hyperframes render` work out-of-the-box on DGX Spark / GB10 / Jetson.
*/
async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
void options;
// If already available (env var or system path), use it directly.
const existing = await findBrowser();
if (existing) return existing;
// Try auto-installing via apt (common on Ubuntu-based ARM systems).
const hasApt = existsSync("/usr/bin/apt-get");
if (hasApt) {
console.error(
"\n🔍 Linux ARM64 detected — Chrome Headless Shell is not available for this platform.",
);
console.error("📦 Auto-installing system Chromium via apt-get (this only happens once)...\n");
// Use spawnSync so output streams to the terminal in real time.
const result = spawnSync("apt-get", ["install", "-y", "chromium-browser"], {
stdio: "inherit",
timeout: 120_000,
});
if (result.status === 0) {
const afterInstall = await findBrowser();
if (afterInstall) {
console.error(`\n✅ Chromium installed at ${afterInstall.executablePath}\n`);
return afterInstall;
}
} else {
// apt succeeded but binary not found, or apt failed — fall through to helpful error.
console.error("\n⚠️ apt-get exited with errors. Trying anyway...\n");
const afterAttempt = await findBrowser();
if (afterAttempt) return afterAttempt;
}
}
// Could not auto-install — give clear manual instructions.
throw new Error(
`Chrome Headless Shell is not available for Linux ARM64 (DGX Spark, GB10, Jetson).\n\n` +
`Install Chromium manually and point hyperframes to it:\n\n` +
` sudo apt-get install -y chromium-browser\n` +
` export HYPERFRAMES_BROWSER_PATH=$(which chromium-browser)\n\n` +
`Then re-run your command. The HYPERFRAMES_BROWSER_PATH env var persists for the session.`,
);
}
/**
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
* With `preferManagedChrome`: env var -> OUR pinned cache -> auto-download
* (puppeteer-cache preference and system Chrome are both skipped).
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
if (!options?.force) {
const fromCache = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
return withInstallLock(async () => {
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
return downloadBrowser(options);
});
}
if (!options?.preferManagedChrome) {
const fromSystem = findSystemBrowser();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
}
}
}
return withInstallLock(async () => {
if (options?.force) {
// `--force` means "always get a fresh managed download" — purging the
// whole HF-managed cache after acquiring the install lock keeps two
// concurrent force retries from deleting each other's in-flight lock or
// partially extracted install.
clearBrowser();
}
// Re-check after acquiring the lock: a concurrent invocation may have
// finished installing while we were waiting, in which case reuse its
// result instead of downloading and extracting a second time. Skipped
// under --force, which already purged and always wants a fresh download.
if (!options?.force) {
const afterLock = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (afterLock.result) return afterLock.result;
if (afterLock.staleInstallPath) purgeStaleInstall(afterLock.staleInstallPath);
}
return downloadBrowser(options);
});
}
/**
* 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();
}
}
/**
* 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
* `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";
}
/**
* `@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();
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.` +
proxyDownloadStallHint();
return new Error(message, { cause: cause instanceof Error ? cause : undefined });
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
const platform = detectBrowserPlatform();
if (!platform) {
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
const runInstall = () =>
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 {
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" };
}
/**
* Remove the cached Chrome download directory.
* Returns true if anything was removed.
*/
export function clearBrowser(): boolean {
if (!existsSync(CACHE_DIR)) {
return false;
}
rmSync(CACHE_DIR, { recursive: true, force: true });
return true;
}
export function isLinuxArm(): boolean {
return process.platform === "linux" && process.arch === "arm64";
}
export { CHROME_VERSION, CACHE_DIR };