diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 1262f1c75..b7c89555b 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -77,6 +77,65 @@ let pooledCaptureMode: CaptureMode = "screenshot"; // Preserve the producer-era export so re-export shims keep the same public API. export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool; +// Flags only meaningful when Chrome's compositor is driven by +// HeadlessExperimental.beginFrame. If we fall back to screenshot mode they +// must be stripped — `--enable-begin-frame-control` in particular makes the +// compositor wait for frames we'll never send, producing blank screenshots. +const BEGINFRAME_ONLY_FLAGS = new Set([ + "--deterministic-mode", + "--enable-begin-frame-control", + "--disable-new-content-rendering-timeout", + "--run-all-compositor-stages-before-draw", + "--disable-threaded-animation", + "--disable-threaded-scrolling", + "--disable-checker-imaging", + "--disable-image-animation-resync", + "--enable-surface-synchronization", +]); + +function stripBeginFrameFlags(args: string[]): string[] { + return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a)); +} + +/** + * Probe whether the browser still speaks HeadlessExperimental.beginFrame. + * + * Recent chrome-headless-shell builds (observed on 147) expose the domain + * well enough that HeadlessExperimental.enable succeeds but drop the + * beginFrame method itself — the capture loop then dies on first frame with + * `'HeadlessExperimental.beginFrame' wasn't found`. So we probe BOTH: enable + * + one cheap beginFrame raced against a 2s timeout. In beginframe-control + * mode the command completes as soon as the compositor acks, so a real + * supported browser returns well under the timeout. + * + * Any failure (method missing, timeout, protocol error) is treated as + * unsupported. Real errors after launch would surface in the warmup loop and + * fall out through the caller's try/catch. + */ +async function probeBeginFrameSupport(browser: Browser): Promise { + let page; + try { + page = await browser.newPage(); + const client = await page.createCDPSession(); + await client.send("HeadlessExperimental.enable"); + const beginFrame = client.send("HeadlessExperimental.beginFrame", { + frameTimeTicks: 0, + interval: 33, + noDisplayUpdates: true, + }); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("beginFrame probe timeout")), 2000), + ); + await Promise.race([beginFrame, timeout]); + await client.detach().catch(() => {}); + return true; + } catch { + return false; + } finally { + await page?.close().catch(() => {}); + } +} + export async function acquireBrowser( chromeArgs: string[], config?: Partial< @@ -112,14 +171,41 @@ export async function acquireBrowser( } const ppt = await getPuppeteer(); - const browser = await ppt.launch({ + const browserTimeout = config?.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; + const protocolTimeout = config?.protocolTimeout ?? DEFAULT_CONFIG.protocolTimeout; + let browser = await ppt.launch({ headless: true, args: chromeArgs, defaultViewport: null, executablePath, - timeout: config?.browserTimeout ?? DEFAULT_CONFIG.browserTimeout, - protocolTimeout: config?.protocolTimeout ?? DEFAULT_CONFIG.protocolTimeout, + timeout: browserTimeout, + protocolTimeout, }); + + // Probe HeadlessExperimental.beginFrame — recent chrome-headless-shell + // builds (observed on 147) dropped the method while keeping the flags + // valid, so `--enable-begin-frame-control` leaves the compositor waiting + // for beginFrames the engine can no longer send. Auto-fall back to + // screenshot mode with the appropriate flags. + if (captureMode === "beginframe") { + const supported = await probeBeginFrameSupport(browser).catch(() => true); + if (!supported) { + await browser.close().catch(() => {}); + console.warn( + "[BrowserManager] HeadlessExperimental.beginFrame unavailable in this Chromium build; falling back to screenshot mode.", + ); + captureMode = "screenshot"; + browser = await ppt.launch({ + headless: true, + args: stripBeginFrameFlags(chromeArgs), + defaultViewport: null, + executablePath, + timeout: browserTimeout, + protocolTimeout, + }); + } + } + if (enablePool) { pooledBrowser = browser; pooledBrowserRefCount = 1;