Files
heygen-com__hyperframes/packages/cli/src/browser/manager.ts
T
Miguel Ángel 92f3116dee 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.
2026-07-07 17:10:06 -04:00

611 lines
23 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 } 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.`,
);
}
}
const CHROME_VERSION = "131.0.6778.85";
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`' 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_TIMEOUT_MS = 120_000; // generous: a real download+extract can take a while
const INSTALL_LOCK_POLL_MS = 200;
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 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 });
}
}
// timeoutMs/pollMs are parameters (not just the module constants) so tests can
// exercise the reclaim-on-timeout branch with real but tiny waits instead of
// mocking Date.now()/setTimeout through the full ensureBrowser call graph.
export async function withInstallLock<T>(
fn: () => Promise<T>,
timeoutMs = INSTALL_LOCK_TIMEOUT_MS,
pollMs = INSTALL_LOCK_POLL_MS,
): 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() + timeoutMs;
for (;;) {
if (existsSync(INSTALL_RECLAIM_LOCK_DIR)) {
await sleep(pollMs);
continue;
}
if (tryAcquireDirLock(INSTALL_LOCK_DIR)) {
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
break;
}
if (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(timeoutMs);
deadline = Date.now() + timeoutMs;
continue;
}
await sleep(pollMs);
}
try {
return await fn();
} finally {
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;
}
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"]
: [
"/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,
});
const first = output
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
return first || undefined;
} catch {
return undefined;
}
}
function findFromEnv(): BrowserResult | undefined {
const envPath = process.env["HYPERFRAMES_BROWSER_PATH"];
if (envPath && existsSync(envPath)) {
return { executablePath: envPath, source: "env" };
}
return undefined;
}
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:
// the HF cache is pinned to `CHROME_VERSION` (above) which lags behind
// upstream Chrome by many releases. If a user installed chrome-headless-shell
// separately (via `@puppeteer/browsers install`) we want to use that
// newer binary, not the pinned-stale fallback.
const fromPuppeteer = findFromPuppeteerCache();
if (fromPuppeteer) {
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
// download-of-last-resort). This is the fallback path: only reached when
// no puppeteer-cache binary exists.
if (existsSync(CACHE_DIR)) {
const { Browser, 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 = [];
}
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
}
}
return {};
}
/**
* 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;
}
function findFromPuppeteerCache(): BrowserResult | undefined {
if (!existsSync(PUPPETEER_CACHE_DIR)) 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 candidates = [
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-mac-arm64",
"chrome-headless-shell",
),
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-win64",
"chrome-headless-shell.exe",
),
];
for (const binary of candidates) {
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;
}
function findFromSystem(): 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 = findFromSystem();
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.
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
if (!options?.force) {
const fromCache = await 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);
});
}
const fromSystem = findFromSystem();
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 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();
}
}
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 = () =>
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" };
}
/**
* 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 };