From 131780fe9627ee7fb17cb871ef6a144a6ef02735 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 4 Aug 2026 11:01:16 -0700 Subject: [PATCH] fix(engine): verify explicit browserGpuMode=hardware instead of trusting it Chrome's hardware GL args are advisory: with no usable GPU it silently falls back to software WebGL and the capture runs at CPU speed. Run the existing WebGL probe for explicit hardware mode too and warn loudly with the platform's remediation. The requested mode is still honoured. Fixes #2967 --- .../src/services/browserManager.test.ts | 29 +++++++- .../engine/src/services/browserManager.ts | 74 +++++++++++++++---- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index db6e7e468..2421d42c7 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -241,11 +241,35 @@ describe("resolveBrowserGpuMode", () => { expect(mode).toBe("software"); }); - it("passes 'hardware' through unchanged without probing", async () => { + it("passes 'hardware' through unchanged", async () => { + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); const mode = await resolveBrowserGpuMode("hardware"); expect(mode).toBe("hardware"); }); + it("warns when explicit 'hardware' probes to software, but still honours it", async () => { + // heygen-com/hyperframes#2967: `--browser-gpu` inside a container with no + // GPU passthrough rendered 19186 frames on CPU with no diagnostic. + setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (Google)", + renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mode = await resolveBrowserGpuMode("hardware", { platform: "linux" }); + expect(mode).toBe("hardware"); + const warning = warn.mock.calls.map((call) => String(call[0])).join("\n"); + expect(warning).toContain("browserGpuMode=hardware was requested"); + expect(warning).toContain("--gpus all"); + }); + + it("stays quiet when explicit 'hardware' probes to hardware", async () => { + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveBrowserGpuMode("hardware")).toBe("hardware"); + expect(warn).not.toHaveBeenCalled(); + }); + it("falls back to 'software' when the probe browser cannot launch", async () => { // No chromePath, env unset, and (in the test env) no system Chrome to find // → puppeteer.launch will throw → caller catches → software fallback. @@ -272,7 +296,8 @@ describe("resolveBrowserGpuMode", () => { expect(second).toBe("software"); // Reset and re-probe to confirm the test-only reset works. _resetAutoBrowserGpuModeCacheForTests(); - const third = await resolveBrowserGpuMode("hardware"); + setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" }); + const third = await resolveBrowserGpuMode("auto"); expect(third).toBe("hardware"); }); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 762626eb3..9346f1802 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -507,14 +507,25 @@ async function probeAutoBrowserGpuMode(options: { /** * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. * - * 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 Promise is cached for the process - * lifetime, so concurrent callers (parallel workers) share the same probe. + * For `"software"` 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 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 + * `"hardware"` (an explicit `--browser-gpu` / `PRODUCER_BROWSER_GPU_MODE= + * hardware`) is honoured verbatim — the operator asked for it — but runs the + * SAME probe to VERIFY it, because Chrome's hardware GL args are advisory: + * with no usable GPU in the sandbox (no `/dev/dri`, no NVIDIA container + * runtime, missing EGL/driver libraries) Chrome silently falls back to + * software WebGL and the render just runs at CPU speed. Without this check + * the only trace is a buried `Automatic fallback to software WebGL` browser + * warning — heygen-com/hyperframes#2967 rendered 19186 frames on CPU while + * `--browser-gpu` was set and nothing said so. The probe result never + * changes the returned mode; it only makes the fallback loud. + * + * Any probe failure (Chrome launch error, navigation timeout, missing canvas + * API, etc.) is treated as a `"software"` result. The render path with * SwiftShader always works, so a misclassification toward software is the * safe failure mode; misclassifying toward hardware would error on the real * render. @@ -527,21 +538,56 @@ export function resolveBrowserGpuMode( platform?: NodeJS.Platform; } = {}, ): Promise<"software" | "hardware"> { - if (mode !== "auto") return Promise.resolve(mode); - if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache; + if (mode === "software") return Promise.resolve(mode); - _autoBrowserGpuModeCache = probeAutoBrowserGpuMode(options); - return _autoBrowserGpuModeCache; + _autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options); + if (mode === "auto") return _autoBrowserGpuModeCache; + + return _autoBrowserGpuModeCache.then((probed) => { + if (probed === "software") { + console.warn(buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform)); + } + return "hardware"; + }); } /** - * Single observability surface for the auto-detect outcome. Logged exactly + * Warning text for "you asked for hardware GPU, the probe found none". + * + * Names the observable symptom (the render still completes, just on CPU) and + * the platform's actual remediation, so the operator doesn't have to infer it + * from Chrome's `Automatic fallback to software WebGL` warning. Exported for + * tests. + */ +export function buildUnverifiedHardwareGpuWarning(platform: NodeJS.Platform | string): string { + const remediation = + platform === "linux" + ? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " + + "Container Toolkit installed, or `--device /dev/dri` for Mesa/AMD/Intel. The image " + + "also needs the matching userspace driver + libEGL. Verify with " + + "`hyperframes render --browser-gpu` and watch for this warning disappearing." + : "Check that the host exposes a GPU to this process and that the graphics drivers are " + + "installed."; + return ( + "[hyperframes] browserGpuMode=hardware was requested, but the WebGL probe found no " + + "hardware GPU — Chrome will silently fall back to software WebGL and the capture will " + + "run at CPU speed. Honouring the explicit request anyway.\n" + + ` ${remediation}\n` + + " Pass --no-browser-gpu to select deterministic SwiftShader instead of waiting on a " + + "hardware path that is not there." + ); +} + +/** + * Single observability surface for the GPU probe 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. + * production. Goes to stderr to stay out of stdout pipelines. Says "probe" + * rather than "auto" because explicit `browserGpuMode=hardware` runs the + * same probe to verify itself. */ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void { - console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`); + console.error(`[hyperframes] browserGpuMode probe → ${resolved} (${reason})`); } function createBrowserLaunchFingerprint(