fix(engine): auto-fall back to screenshot mode when chrome-headless-shell drops HeadlessExperimental.beginFrame (#296)

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.
This commit is contained in:
Miguel Ángel
2026-04-17 01:10:08 +02:00
committed by GitHub
parent 96376c9be0
commit 5e52e27872
+89 -3
View File
@@ -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<boolean> {
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<never>((_, 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;