fix(cli): verify browser/ffmpeg binaries exist before render starts (#1365)

## Problem

Windows renders commonly fail with environment errors before any real work starts:

- `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists.
- `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling.

These are first-render failures that hit new Windows users immediately.

## Fix

- Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path.
- New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints.
- Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH.
- Map opaque Windows ffmpeg exit codes to actionable messages.

## Testing

- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
This commit is contained in:
Miguel Ángel
2026-06-12 01:36:28 -04:00
committed by GitHub
parent c3554dcffe
commit cee6fd02d6
31 changed files with 896 additions and 151 deletions
+53 -13
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { execSync, spawnSync } from "node:child_process";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { basename } from "node:path";
@@ -37,6 +38,11 @@ export interface EnsureBrowserOptions {
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
}
interface CacheLookupResult {
result?: BrowserResult;
staleHyperframesCachePath?: string;
}
// --- Internal helpers -------------------------------------------------------
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
@@ -75,7 +81,7 @@ function findFromEnv(): BrowserResult | undefined {
return undefined;
}
async function findFromCache(): Promise<BrowserResult | 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
@@ -90,7 +96,7 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
// newer binary, not the pinned-stale fallback.
const fromPuppeteer = findFromPuppeteerCache();
if (fromPuppeteer) {
return fromPuppeteer;
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
@@ -100,12 +106,15 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { executablePath: match.executablePath, source: "cache" };
return { staleHyperframesCachePath: match.executablePath };
}
}
return undefined;
return {};
}
/**
@@ -251,7 +260,21 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
if (fromEnv) return fromEnv;
const fromCache = await findFromCache();
if (fromCache) return fromCache;
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await downloadBrowser();
} catch (err) {
const cause = err instanceof Error ? err.message : String(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) {
@@ -314,8 +337,31 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
* Resolution: env var -> cached download -> system Chrome -> auto-download.
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const existing = await findBrowser();
if (existing) return existing;
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...`,
);
return downloadBrowser(options);
}
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
}
return downloadBrowser(options);
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
@@ -324,12 +370,6 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
// Chrome headless shell has no Linux ARM64 build (e.g. DGX Spark, GB10).
// Try to auto-install system Chromium via apt, then find it.
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const installed = await install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,