feat(engine): browserGpuMode "auto" — probe WebGL once, fall back to software

When the host doesn't have a usable GPU (CI containers, eval rigs without
GPU passthrough, dev VMs), Chrome's hardware-mode WebGL flags
(`--use-gl=egl/metal/d3d11`) silently leave WebGL unavailable —
`getContext("webgl")` returns null, three.js' WebGLRenderer dies, the
canvas stays black. Surfaced today by Abhay's c2v-eval failing on a
docker render of an hf bundle that uses three.js + a custom fragment
shader.

The fix that's been there: `--use-gl=angle --use-angle=swiftshader` (CPU
software WebGL, ~5-50× slower but pixel-identical). The engine already
exposed `browserGpuMode: "software"` for this. The gap was discovery —
users had to know to pass `--no-browser-gpu` on no-GPU hosts.

This change adds `browserGpuMode: "auto"` (now the CLI default for local
renders): on first launch in the process, probe Chrome with hardware
args, check `canvas.getContext("webgl") !== null`, cache the result.
~1-2 s on first render, free on every subsequent render in the same
worker. Hardware GPUs keep their fast path; no-GPU hosts get SwiftShader
without ceremony.

Behaviour matrix:
- No flag, no env, local       → "auto" (NEW default)
- `--browser-gpu`              → "hardware" (force; errors if no GPU)
- `--no-browser-gpu`           → "software" (force SwiftShader)
- `PRODUCER_BROWSER_GPU_MODE`  → "hardware" / "software" / "auto" / unset
- Docker mode                  → forced "software" (unchanged)

Engine-config default stays "software" (conservative for embedders); the
"auto" default lives in the CLI's `resolveBrowserGpuForCli` so producer
embedders aren't surprised by a probe-on-launch.

Also adds `--enable-unsafe-swiftshader` to the software flag set —
Chrome 120+ deprecated implicit SwiftShader fallback and emits a
deprecation warning unless the flag is set explicitly. Despite the
"unsafe" name this is exactly the pre-deprecation behaviour; the rename
is about Chrome's threat model on the open web, not about the rendering
itself.

Verification:
- Engine 535/535 + CLI 256/256 (incl. new probe tests + tri-state CLI test)
- Empirical: probe on this no-GPU devbox returns "software" in 240 ms,
  cached 0 ms on subsequent calls
- Format / lint / typecheck clean across all packages

Refs the Abhay/Slack thread on c2v-eval rendering without a GPU node.
This commit is contained in:
James
2026-05-06 17:33:55 +00:00
parent 7174c4cdcd
commit 67bb56c703
8 changed files with 263 additions and 28 deletions
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildChromeArgs, forceReleaseBrowser } from "./browserManager.js";
import {
_resetAutoBrowserGpuModeCacheForTests,
buildChromeArgs,
forceReleaseBrowser,
resolveBrowserGpuMode,
} from "./browserManager.js";
describe("buildChromeArgs browser GPU mode", () => {
const base = { width: 1920, height: 1080 };
@@ -10,6 +15,7 @@ describe("buildChromeArgs browser GPU mode", () => {
expect(args).toContain("--enable-features=CanvasDrawElement");
expect(args).toContain("--use-gl=angle");
expect(args).toContain("--use-angle=swiftshader");
expect(args).toContain("--enable-unsafe-swiftshader");
expect(args).not.toContain("--enable-gpu-rasterization");
});
@@ -48,6 +54,57 @@ describe("buildChromeArgs browser GPU mode", () => {
});
});
describe("resolveBrowserGpuMode", () => {
beforeEach(() => {
_resetAutoBrowserGpuModeCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
_resetAutoBrowserGpuModeCacheForTests();
});
it("passes 'software' through unchanged without probing", async () => {
const mode = await resolveBrowserGpuMode("software");
expect(mode).toBe("software");
});
it("passes 'hardware' through unchanged without probing", async () => {
const mode = await resolveBrowserGpuMode("hardware");
expect(mode).toBe("hardware");
});
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.
// Force a definitely-missing chrome binary so the launch path errors fast.
const mode = await resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
expect(mode).toBe("software");
});
it("caches the probe result across calls", async () => {
const first = await resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
// Second call uses cache — no new launch. Assert the same answer comes back
// even with a different chromePath that would have a different probe outcome.
const second = await resolveBrowserGpuMode("auto", {
chromePath: "/another/definitely/missing/path",
browserTimeout: 2000,
});
expect(first).toBe("software");
expect(second).toBe("software");
// Reset and re-probe to confirm the test-only reset works.
_resetAutoBrowserGpuModeCacheForTests();
const third = await resolveBrowserGpuMode("hardware");
expect(third).toBe("hardware");
});
});
describe("forceReleaseBrowser", () => {
it("kills the browser process and disconnects", () => {
const killFn = vi.fn(() => true);
+105 -1
View File
@@ -136,6 +136,97 @@ 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.
*
* Exported for tests; production callers go through `resolveBrowserGpuMode`.
*/
export let _autoBrowserGpuModeCache: "software" | "hardware" | undefined;
/** Test-only: reset the cached probe result. */
export function _resetAutoBrowserGpuModeCacheForTests(): void {
_autoBrowserGpuModeCache = undefined;
}
/**
* 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 probe result is cached for the
* process lifetime — a multi-render run pays the ~1-2 s cost once.
*
* Any failure (Chrome launch error, navigation timeout, missing canvas API,
* etc.) is treated as a `"software"` fallback. 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.
*/
export async function resolveBrowserGpuMode(
mode: EngineConfig["browserGpuMode"],
options: {
chromePath?: string;
browserTimeout?: number;
platform?: NodeJS.Platform;
} = {},
): Promise<"software" | "hardware"> {
if (mode !== "auto") return mode;
if (_autoBrowserGpuModeCache !== undefined) return _autoBrowserGpuModeCache;
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 ppt = await getPuppeteer().catch(() => null);
if (!ppt) {
_autoBrowserGpuModeCache = "software";
return _autoBrowserGpuModeCache;
}
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(() => {});
}
return _autoBrowserGpuModeCache;
}
export async function acquireBrowser(
chromeArgs: string[],
config?: Partial<
@@ -344,7 +435,20 @@ function getBrowserGpuArgs(
platform: NodeJS.Platform,
): string[] {
if (mode === "software") {
return ["--use-gl=angle", "--use-angle=swiftshader"];
// Chrome 120+ deprecated implicit SwiftShader fallback; the explicit
// path (--use-angle=swiftshader) keeps working but Chrome emits a
// deprecation warning unless --enable-unsafe-swiftshader is also set.
// Despite the name, this is exactly the behaviour Chrome had before;
// the flag exists to make CPU rasterisation an explicit opt-in rather
// than an implicit fallback for end users on the open web.
return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
}
if (mode === "auto") {
// Should not reach here — `resolveBrowserGpuMode` collapses "auto" to
// "software" or "hardware" before args are built. Be defensive: software
// is the always-safe fallback.
return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
}
switch (platform) {
+7 -1
View File
@@ -18,6 +18,7 @@ import {
releaseBrowser,
forceReleaseBrowser,
buildChromeArgs,
resolveBrowserGpuMode,
resolveHeadlessShellPath,
type CaptureMode,
} from "./browserManager.js";
@@ -115,9 +116,14 @@ export async function createCaptureSession(
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
const preMode: CaptureMode =
headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
chromePath: headlessShell ?? undefined,
browserTimeout: config?.browserTimeout,
});
const chromeArgs = buildChromeArgs(
{ width: options.width, height: options.height, captureMode: preMode },
config,
{ ...config, browserGpuMode: resolvedGpuMode },
);
const { browser, captureMode } = await acquireBrowser(chromeArgs, config);