feat(engine): cache probe Promise + log resolved mode + sync docs

Three follow-ups from Vai's staff-eng review:

1. Concurrent-probe race (real bug): the parallel coordinator runs N
   workers via Promise.all, so `--workers 4` on a no-GPU host fired 4
   simultaneous probe Chromes — each paying the same 240 ms launch cost.
   Cache the *Promise* (not the resolved value): first caller assigns
   the in-flight Promise, every other concurrent caller awaits the same
   one. Verified with a new test asserting all concurrent callers get
   the identical Promise reference.

2. Stale rendering.md (lines 23, 29): user-visible contract said
   "browser GPU enabled by default", which was wrong post-auto. Now
   describes the auto / hardware / software trichotomy explicitly.

3. Silent fallback: auto-mode produced no output, so a regression to
   "always falls back to software even with GPU present" would have
   been invisible in production logs. Added a single stderr line per
   process when the probe resolves: `[hyperframes] browserGpuMode auto
   → <mode> (<reason>)`. Cache hits don't re-log.

Verification:
- Engine 536/536 (incl. new concurrent-dedup test asserting Promise
  reference equality across simultaneous callers)
- CLI 256/256
- Format / lint / typecheck clean
This commit is contained in:
James
2026-05-06 17:33:55 +00:00
parent 2221647728
commit f635deb86a
3 changed files with 100 additions and 55 deletions
+2 -2
View File
@@ -20,13 +20,13 @@ Requires: Docker installed and running.
- `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`)
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI, QSV)
- `--browser-gpu` / `--no-browser-gpu`Use or opt out of host GPU acceleration for local Chrome/WebGL capture (enabled by default for local renders, disabled in Docker)
- `--browser-gpu` / `--no-browser-gpu`Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software.
- `-o, --output` — Custom output path
## Tips
- Use `draft` quality for fast previews during development
- Local renders use browser GPU capture automatically; use `--no-browser-gpu` to compare against the software-browser path
- Local renders auto-detect GPU on first launch; use `--browser-gpu` to force hardware (errors if no GPU) or `--no-browser-gpu` to force SwiftShader
- Use `--gpu` when a local render also benefits from hardware FFmpeg encoding
- Use `npx hyperframes benchmark` to find optimal settings
- 4 workers is usually the sweet spot for most compositions
@@ -103,6 +103,29 @@ describe("resolveBrowserGpuMode", () => {
const third = await resolveBrowserGpuMode("hardware");
expect(third).toBe("hardware");
});
it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => {
// Parallel coordinator fires N workers via Promise.all — without Promise-
// level caching, a `--workers 4` render against a no-GPU host would launch
// 4 simultaneous probe Chromes. Verify all concurrent callers get the
// exact same Promise reference (proving the probe runs once, not N times).
const p1 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
const p2 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
const p3 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
expect(p1).toBe(p2);
expect(p2).toBe(p3);
const results = await Promise.all([p1, p2, p3]);
expect(results).toEqual(["software", "software", "software"]);
});
});
describe("forceReleaseBrowser", () => {
+75 -53
View File
@@ -137,15 +137,18 @@ async function probeBeginFrameSupport(browser: Browser): Promise<boolean> {
}
/**
* Cached result of `resolveBrowserGpuMode("auto", ...)` for the lifetime of
* this process. The probe launches a transient Chrome with hardware args and
* checks whether `canvas.getContext("webgl")` returns a context. Result is
* memoised because the answer is a property of the host (GPU/driver
* availability) and cannot meaningfully change inside one process.
* Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
*
* Caching the Promise (rather than the resolved value) deduplicates concurrent
* callers — the parallel coordinator runs N workers via `Promise.all`, so a
* `--workers 4` render against a no-GPU host would otherwise fire 4
* simultaneous probe Chromes. The first call assigns the Promise and every
* other concurrent caller awaits the same one, paying the ~240 ms probe cost
* exactly once per process lifetime.
*
* Exported for tests; production callers go through `resolveBrowserGpuMode`.
*/
export let _autoBrowserGpuModeCache: "software" | "hardware" | undefined;
export let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;
/** Test-only: reset the cached probe result. */
export function _resetAutoBrowserGpuModeCacheForTests(): void {
@@ -158,8 +161,8 @@ export function _resetAutoBrowserGpuModeCacheForTests(): void {
* For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"`
* it launches a tiny Chrome with the platform's hardware GPU args, runs a
* one-shot WebGL availability probe, and falls back to `"software"` if
* hardware-mode WebGL is unavailable. The probe result is cached for the
* process lifetime — a multi-render run pays the ~1-2 s cost once.
* hardware-mode WebGL is unavailable. The Promise is cached for the process
* lifetime, so concurrent callers (parallel workers) share the same probe.
*
* Any failure (Chrome launch error, navigation timeout, missing canvas API,
* etc.) is treated as a `"software"` fallback. The render path with
@@ -167,7 +170,7 @@ export function _resetAutoBrowserGpuModeCacheForTests(): void {
* safe failure mode; misclassifying toward hardware would error on the real
* render.
*/
export async function resolveBrowserGpuMode(
export function resolveBrowserGpuMode(
mode: EngineConfig["browserGpuMode"],
options: {
chromePath?: string;
@@ -175,58 +178,77 @@ export async function resolveBrowserGpuMode(
platform?: NodeJS.Platform;
} = {},
): Promise<"software" | "hardware"> {
if (mode !== "auto") return mode;
if (_autoBrowserGpuModeCache !== undefined) return _autoBrowserGpuModeCache;
if (mode !== "auto") return Promise.resolve(mode);
if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache;
const platform = options.platform ?? process.platform;
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
_autoBrowserGpuModeCache = (async () => {
const platform = options.platform ?? process.platform;
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
const probeArgs = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
...getBrowserGpuArgs("hardware", platform),
];
const probeArgs = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
...getBrowserGpuArgs("hardware", platform),
];
const ppt = await getPuppeteer().catch(() => null);
if (!ppt) {
_autoBrowserGpuModeCache = "software";
return _autoBrowserGpuModeCache;
}
const ppt = await getPuppeteer().catch(() => null);
if (!ppt) {
logResolvedBrowserGpuMode("software", "puppeteer unavailable");
return "software" as const;
}
let probeBrowser: Browser | undefined;
try {
probeBrowser = await ppt.launch({
headless: true,
args: probeArgs,
defaultViewport: { width: 64, height: 64 },
executablePath,
timeout: browserTimeout,
});
const page = await probeBrowser.newPage();
const hasWebGL = await page.evaluate(() => {
try {
const c = document.createElement("canvas");
const gl =
c.getContext("webgl") || (c.getContext("experimental-webgl") as RenderingContext | null);
return gl !== null;
} catch {
return false;
}
});
_autoBrowserGpuModeCache = hasWebGL ? "hardware" : "software";
} catch {
_autoBrowserGpuModeCache = "software";
} finally {
await probeBrowser?.close().catch(() => {});
}
let probeBrowser: Browser | undefined;
try {
probeBrowser = await ppt.launch({
headless: true,
args: probeArgs,
defaultViewport: { width: 64, height: 64 },
executablePath,
timeout: browserTimeout,
});
const page = await probeBrowser.newPage();
const hasWebGL = await page.evaluate(() => {
try {
const c = document.createElement("canvas");
const gl =
c.getContext("webgl") ||
(c.getContext("experimental-webgl") as RenderingContext | null);
return gl !== null;
} catch {
return false;
}
});
const resolved = hasWebGL ? ("hardware" as const) : ("software" as const);
logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable");
return resolved;
} catch (err) {
logResolvedBrowserGpuMode(
"software",
`probe failed (${err instanceof Error ? err.message : String(err)})`,
);
return "software" as const;
} finally {
await probeBrowser?.close().catch(() => {});
}
})();
return _autoBrowserGpuModeCache;
}
/**
* Single observability surface for the auto-detect outcome. Logged exactly
* once per process (the probe runs once); without this line, a regression
* to "always software even with a GPU present" would be invisible in
* production. Goes to stderr to stay out of stdout pipelines.
*/
function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void {
console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`);
}
export async function acquireBrowser(
chromeArgs: string[],
config?: Partial<