From 5e52e278724df95b2eb95484d3151a54d952f4d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 17 Apr 2026 01:10:08 +0200 Subject: [PATCH] fix(engine): auto-fall back to screenshot mode when chrome-headless-shell drops HeadlessExperimental.beginFrame (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #294. ## Summary Recent `chrome-headless-shell` builds (observed on 147) no longer expose `HeadlessExperimental.beginFrame`. The domain's `enable`/`disable` methods are deprecated upstream and appear to have been dropped alongside `beginFrame` in these builds, so on Linux with chrome-headless-shell the engine aborts with \`\`\` Protocol error (HeadlessExperimental.beginFrame): 'HeadlessExperimental.beginFrame' wasn't found \`\`\` and — because the browser was launched with `--enable-begin-frame-control` — the compositor waits for beginFrames the engine can no longer deliver, so every subsequent screenshot also comes back blank. Today users have to discover `PRODUCER_FORCE_SCREENSHOT=true` themselves (openclaw did exactly that — see the issue body). ## Fix One-time probe, right after the browser launches in beginframe mode: 1. Create a disposable CDP session. 2. `await client.send("HeadlessExperimental.enable")`. 3. Send one no-op `HeadlessExperimental.beginFrame` raced against a 2s timeout. 4. If anything throws / times out — missing method, protocol error, stuck call — close the browser, strip beginframe-only chrome flags, relaunch in screenshot mode, and set \`captureMode = "screenshot"\` for the returned session. Probing `beginFrame` directly rather than `enable` alone is important because some builds keep the domain registered (so `.enable()` succeeds) while dropping the method itself — that's exactly the failure shape in #294. Cost on happy path: one extra CDP round-trip per browser acquisition (≈ a few ms, since in beginframe-control mode the command returns as soon as the compositor acks). Cost on broken path: one extra launch, which is what the env-var escape hatch already forces manually. The beginframe-only flag set is enumerated in-module and matched by the stripper, so adding/removing flags stays in one place with `buildChromeArgs`. ## Test plan - [x] `bun run --filter=@hyperframes/engine test` — all 42 tests pass - [x] `bun run --filter=@hyperframes/engine build` — typechecks - [x] `bunx oxlint` + `bunx oxfmt --check` clean - [x] Manual: standalone test on Linux x86_64 with chrome-headless-shell 146 — probe returns `supported=true`, no fallback (happy path) - [x] Manual: same test with `--force-fail` simulating openclaw's missing-method condition — fallback triggers, flags stripped, relaunch succeeds, 6.8 KB PNG captured (broken path) - [ ] Verify on openclaw / real chrome-headless-shell 147 build that the fallback triggers automatically without `PRODUCER_FORCE_SCREENSHOT` ## Notes - `probeBeginFrameSupport` catches any failure generically; we trust that a working browser answers the no-op beginFrame in well under 2s. - Warning is logged once per browser acquisition, not per frame. - Browser pool interaction: pooled browsers cache the resolved `captureMode`, so subsequent acquires in the same process reuse the post-fallback mode without re-probing. --- .../engine/src/services/browserManager.ts | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) 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;