mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user