Skip to content

Commit d20e28a

Browse files
vanceingallsclaude
andcommitted
fix(engine): gate software-GPU warn + thread resolved mode (staff review)
Three fixes from PR #822 self-review: 1. Gate the "Software GPU detected" console.warn so it only fires when the guard actually changed the outcome. Previously the warn condition (captureMode==="screenshot" && isSoftwareRenderer && headlessShell && isLinux) was satisfied even when the caller had explicitly set forceScreenshot=true — misleading operators into thinking the guard kicked in when they had already picked screenshot mode. Add `!forceScreenshot` to the condition and one-shot the warn with a process-level latch to avoid spam across multi-worker renders. 2. Thread the already-resolved browserGpuMode from frameCapture.ts into acquireBrowser via a new AcquireBrowserOptions.resolvedBrowserGpuMode. The Promise cache made the duplicate resolution cheap, but threading the value removes the smell of two parallel resolutions of the same thing with no static guarantee they agree under future refactors. 3. Add fallback-launch-retry test: simulate probeBeginFrameSupport failure via a CDP mock whose `send` rejects, assert the second launch strips beginframe-only flags. Also add positive-control tests for "Linux + hardware + headless-shell does NOT warn" and "forceScreenshot suppresses the software-GPU warn". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 926c4c2 commit d20e28a

3 files changed

Lines changed: 157 additions & 19 deletions

File tree

packages/engine/src/services/browserManager.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ describe("acquireBrowser — software-renderer guard", () => {
170170
lastLaunchArgs = undefined;
171171
lastLaunchExecutablePath = undefined;
172172
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
173+
// `vi.resetModules()` already gives each test a fresh `browserManager.js`
174+
// module — so `_softwareGuardWarned` starts at false per test. No
175+
// additional reset needed.
173176
});
174177

175178
afterEach(() => {
@@ -281,6 +284,102 @@ describe("acquireBrowser — software-renderer guard", () => {
281284
const messages = warnSpy.mock.calls.map((c) => String(c[0]));
282285
expect(messages.find((m) => m.includes("Software GPU detected"))).toBeUndefined();
283286
});
287+
288+
it("does NOT emit the software-GPU warning when caller explicitly set forceScreenshot=true", async () => {
289+
// If the caller already picked screenshot mode, the software-GPU guard
290+
// didn't change the outcome — warning would misleadingly tell operators
291+
// the guard kicked in. Same shape as the macOS test, but here the
292+
// platform satisfies all the original warn-firing preconditions and only
293+
// forceScreenshot suppresses the warn.
294+
installPuppeteerMock();
295+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
296+
const { acquireBrowser: acquire, releaseBrowser: release } =
297+
await import("./browserManager.js");
298+
299+
const acquired = await acquire(["--no-sandbox"], {
300+
chromePath: "/fake/chrome-headless-shell",
301+
browserGpuMode: "software",
302+
forceScreenshot: true,
303+
});
304+
await release(acquired.browser);
305+
306+
const messages = warnSpy.mock.calls.map((c) => String(c[0]));
307+
expect(messages.find((m) => m.includes("Software GPU detected"))).toBeUndefined();
308+
});
309+
310+
it("does NOT emit the software-GPU warning on Linux + hardware + headless-shell (positive control)", async () => {
311+
// On the very env where the guard COULD fire (Linux + headless-shell), a
312+
// hardware-resolved GPU mode must NOT trip the warning. Catches the
313+
// failure mode where the gate condition gets inverted in a refactor.
314+
installPuppeteerMock();
315+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
316+
const { acquireBrowser: acquire, releaseBrowser: release } =
317+
await import("./browserManager.js");
318+
319+
const acquired = await acquire(["--no-sandbox"], {
320+
chromePath: "/fake/chrome-headless-shell",
321+
browserGpuMode: "hardware",
322+
});
323+
await release(acquired.browser);
324+
325+
const messages = warnSpy.mock.calls.map((c) => String(c[0]));
326+
expect(messages.find((m) => m.includes("Software GPU detected"))).toBeUndefined();
327+
});
328+
329+
it("falls back to screenshot mode + strips beginframe flags when probeBeginFrameSupport fails", async () => {
330+
// Pre-existing defense-in-depth: even if the software-GPU guard misses
331+
// (e.g. resolveBrowserGpuMode returns "hardware" but the binary itself
332+
// has dropped HeadlessExperimental.beginFrame — observed on chrome
333+
// builds 147+), the post-launch probe should detect it, close the
334+
// browser, and re-launch with stripped flags. This test pins that path
335+
// via a CDP session whose `send` rejects.
336+
const launchCalls: Array<{ args: string[]; executablePath?: string }> = [];
337+
const browserStub = {
338+
close: vi.fn().mockResolvedValue(undefined),
339+
newPage: vi.fn().mockResolvedValue({
340+
close: vi.fn().mockResolvedValue(undefined),
341+
createCDPSession: vi.fn().mockResolvedValue({
342+
// Probe failure: HeadlessExperimental.enable rejects → probe returns
343+
// false → acquireBrowser closes browser + re-launches with stripped
344+
// flags.
345+
send: vi.fn().mockRejectedValue(new Error("HeadlessExperimental not supported")),
346+
detach: vi.fn().mockResolvedValue(undefined),
347+
}),
348+
}),
349+
};
350+
const launch = vi.fn(async (opts: { args: string[]; executablePath?: string }) => {
351+
launchCalls.push({ args: [...opts.args], executablePath: opts.executablePath });
352+
return browserStub;
353+
});
354+
vi.doMock("puppeteer", () => ({ default: { launch } }));
355+
vi.doMock("puppeteer-core", () => ({ default: { launch } }));
356+
357+
const { acquireBrowser: acquire } = await import("./browserManager.js");
358+
359+
const chromeArgs = [
360+
"--no-sandbox",
361+
"--enable-begin-frame-control",
362+
"--deterministic-mode",
363+
"--run-all-compositor-stages-before-draw",
364+
];
365+
const result = await acquire(chromeArgs, {
366+
chromePath: "/fake/chrome-headless-shell",
367+
browserGpuMode: "hardware",
368+
});
369+
370+
// Two launches: first with beginframe flags (initial attempt), then
371+
// re-launch with flags stripped after probe failure.
372+
expect(launchCalls.length).toBe(2);
373+
expect(launchCalls[0]?.args).toContain("--enable-begin-frame-control");
374+
expect(launchCalls[1]?.args).not.toContain("--enable-begin-frame-control");
375+
expect(launchCalls[1]?.args).not.toContain("--deterministic-mode");
376+
expect(launchCalls[1]?.args).not.toContain("--run-all-compositor-stages-before-draw");
377+
expect(launchCalls[1]?.args).toContain("--no-sandbox");
378+
// First browser closed before the re-launch.
379+
expect(browserStub.close).toHaveBeenCalled();
380+
// Final captureMode is screenshot, reflecting the fallback.
381+
expect(result.captureMode).toBe("screenshot");
382+
});
284383
});
285384

286385
describe("forceReleaseBrowser", () => {

packages/engine/src/services/browserManager.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,29 @@ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: st
249249
console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`);
250250
}
251251

252+
/**
253+
* One-shot latch for the "Software GPU detected" warning. The guard fires per
254+
* acquire() (each render worker calls acquireBrowser), so without a latch we'd
255+
* spam the operator with N copies of the same message on a multi-worker run.
256+
* Exported reset for tests.
257+
*/
258+
let _softwareGuardWarned = false;
259+
export function _resetSoftwareGuardWarnedForTests(): void {
260+
_softwareGuardWarned = false;
261+
}
262+
263+
export interface AcquireBrowserOptions {
264+
/**
265+
* If the caller already resolved `browserGpuMode` (e.g. `frameCapture.ts`
266+
* computes it before building chromeArgs), pass the resolved value here so
267+
* `acquireBrowser` doesn't redundantly re-resolve from the raw config. The
268+
* Promise cache makes the duplicate call cheap, but threading the value
269+
* through removes the smell of two parallel resolutions of the same thing
270+
* with no static guarantee they agree.
271+
*/
272+
resolvedBrowserGpuMode?: "software" | "hardware";
273+
}
274+
252275
export async function acquireBrowser(
253276
chromeArgs: string[],
254277
config?: Partial<
@@ -262,6 +285,7 @@ export async function acquireBrowser(
262285
| "browserGpuMode"
263286
>
264287
>,
288+
options: AcquireBrowserOptions = {},
265289
): Promise<AcquiredBrowser> {
266290
const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
267291

@@ -280,20 +304,27 @@ export async function acquireBrowser(
280304
// Resolve browserGpuMode. On software-renderer hosts (no hardware GPU /
281305
// SwiftShader) HeadlessExperimental.beginFrame is unreliable — the
282306
// compositor stalls indefinitely on shader-heavy frames and the CDP call
283-
// times out at protocolTimeout (default 5 min). Force screenshot mode for
284-
// the entire process whenever resolveBrowserGpuMode resolves to "software",
285-
// independent of platform/binary. Broader than the per-stage HDR guard in
286-
// the producer (captureHdrStage's `cfg.forceScreenshot = true`) — that
287-
// guard only covered the layered-composite path, not the BeginFrame loop
288-
// for SDR content on a software host.
307+
// times out at protocolTimeout (default 5 min). Force screenshot mode
308+
// whenever resolveBrowserGpuMode resolves to "software", independent of
309+
// platform/binary. This is a separate defense from the producer-side
310+
// `captureHdrStage` guard (which unconditionally forces screenshot for the
311+
// HDR layered-composite path); this guard covers the SDR-render-on-software-
312+
// host case that captureHdrStage doesn't touch.
289313
//
290-
// resolveBrowserGpuMode caches its probe Promise for the process lifetime,
291-
// so calling it here is cheap after the first invocation.
292-
const browserGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
293-
const resolvedGpuMode = await resolveBrowserGpuMode(browserGpuMode, {
294-
chromePath: headlessShell ?? undefined,
295-
browserTimeout: config?.browserTimeout,
296-
});
314+
// If the caller has already resolved the mode (frameCapture.ts does) it
315+
// hands us the value via options.resolvedBrowserGpuMode to avoid a second
316+
// resolution from raw config. The probe Promise is cached for the process
317+
// lifetime so the fallback path is still cheap.
318+
let resolvedGpuMode: "software" | "hardware";
319+
if (options.resolvedBrowserGpuMode) {
320+
resolvedGpuMode = options.resolvedBrowserGpuMode;
321+
} else {
322+
const browserGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
323+
resolvedGpuMode = await resolveBrowserGpuMode(browserGpuMode, {
324+
chromePath: headlessShell ?? undefined,
325+
browserTimeout: config?.browserTimeout,
326+
});
327+
}
297328
const isSoftwareRenderer = resolvedGpuMode === "software";
298329

299330
let captureMode: CaptureMode;
@@ -315,11 +346,14 @@ export async function acquireBrowser(
315346
// them defensively. No-op if caller already built args for screenshot mode.
316347
const launchArgs = captureMode === "screenshot" ? stripBeginFrameFlags(chromeArgs) : chromeArgs;
317348

318-
if (captureMode === "screenshot" && isSoftwareRenderer && headlessShell && isLinux) {
319-
// Log once when the software-renderer guard actually changed the outcome
320-
// (Linux + headless-shell available — the only env where beginframe would
321-
// have been the default). Silent on the always-screenshot platforms
322-
// (macOS, Windows) and on hardware hosts.
349+
// Warn ONLY when the software-renderer guard actually changed the outcome.
350+
// Conditions: Linux + headless-shell available + GPU resolved to software +
351+
// caller didn't already pick screenshot via forceScreenshot. (If the caller
352+
// explicitly set forceScreenshot=true the guard didn't change anything, so
353+
// warning would mislead operators into thinking the guard kicked in.)
354+
// One-shot per process to avoid spamming multi-worker renders.
355+
if (!forceScreenshot && isSoftwareRenderer && headlessShell && isLinux && !_softwareGuardWarned) {
356+
_softwareGuardWarned = true;
323357
console.warn(
324358
"[BrowserManager] Software GPU detected; forcing screenshot capture mode (HeadlessExperimental.beginFrame stalls on software-rendered compositors).",
325359
);

packages/engine/src/services/frameCapture.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,12 @@ export async function createCaptureSession(
213213
{ ...config, browserGpuMode: resolvedGpuMode },
214214
);
215215

216-
const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
216+
// Thread the already-resolved GPU mode into acquireBrowser so it doesn't
217+
// re-resolve from raw config. Promise-cached anyway, but removes the smell
218+
// of two parallel resolutions that future refactors could let diverge.
219+
const { browser, captureMode } = await acquireBrowser(chromeArgs, config, {
220+
resolvedBrowserGpuMode: resolvedGpuMode,
221+
});
217222

218223
const page = await browser.newPage();
219224
// Polyfill esbuild's keepNames helper inside the page.

0 commit comments

Comments
 (0)