From 6f04983e2012da58981c7bb812a974e36b08a698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 9 Apr 2026 19:46:39 +0200 Subject: [PATCH] fix(engine): retry beginFrame on parallel render contention (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When 2-3 renders run in parallel on Linux (beginFrame mode), Chrome's `HeadlessExperimental.beginFrame` fails with "Another frame is pending" due to CPU contention - Extracts `sendBeginFrame` helper with exponential backoff retry (50ms–800ms, 5 attempts) — used by both the main capture path and the hasDamage=false fallback - After retries exhaust, throws an actionable error instead of a raw protocol error ## Testing ### Environment - Linux (Ubuntu 20.04), 8 cores - `chrome-headless-shell` 146.0.7680.153 (beginFrame mode active) - Test composition: 1920×1080, 5s duration, 30fps, 150 frames, 3 GSAP-animated elements ### Before fix (main) Ran 3 parallel renders of the same composition simultaneously: | Render | Result | Details | |--------|--------|---------| | R1 | Completed | 304 KB, 6.6s | | R2 | **FAILED** | `Protocol error (HeadlessExperimental.beginFrame): Another frame is pending` at frame 120/150 | | R3 | Completed | 304 KB, 6.7s | The error is non-deterministic — it hits whichever worker loses the CDP frame contention race under CPU pressure. ### After fix (this branch) Same 3 parallel renders: | Render | Result | Details | |--------|--------|---------| | R1 | Completed | 304 KB, 7.3s | | R2 | Completed | 304 KB, 7.3s | | R3 | Completed | 304 KB, 7.3s | All 3 succeeded. The slight increase in wall time (6.6s → 7.3s) is consistent with occasional retries absorbing transient contention without failing. ### Code review - Both `beginFrame` call sites in `beginFrameCapture` (main capture path + hasDamage=false fallback) use the shared `sendBeginFrame` helper - Backoff ceiling is 1.55s per frame (50+100+200+400+800ms), acceptable for transient contention - beginFrame mode is Linux-only (`chrome-headless-shell` + `--enable-begin-frame-control`); macOS uses screenshot mode so the retry code path isn't exercised there --- .../engine/src/services/screenshotService.ts | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 90539d5e7..5be0f3245 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -43,6 +43,33 @@ export interface BeginFrameResult { // the compositor is paused). const lastFrameCache = new WeakMap(); +const PENDING_FRAME_RETRIES = 5; + +async function sendBeginFrame( + client: import("puppeteer-core").CDPSession, + params: Parameters>[1], +) { + for (let attempt = 0; ; attempt++) { + try { + return await client.send("HeadlessExperimental.beginFrame", params); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + const isPending = msg.includes("Another frame is pending"); + if (isPending && attempt < PENDING_FRAME_RETRIES) { + await new Promise((r) => setTimeout(r, 50 * 2 ** attempt)); + continue; + } + if (isPending) { + throw new Error( + `[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries — CPU overloaded by parallel renders. ` + + `Reduce concurrent renders or use --docker for isolation.`, + ); + } + throw err; + } + } +} + export async function beginFrameCapture( page: Page, options: CaptureOptions, @@ -51,39 +78,34 @@ export async function beginFrameCapture( ): Promise { const client = await getCdpSession(page); - const format = options.format === "png" ? "png" : "jpeg"; - const result = await client.send("HeadlessExperimental.beginFrame", { - frameTimeTicks, - interval, - screenshot: { - format, - quality: format === "jpeg" ? (options.quality ?? 80) : undefined, - optimizeForSpeed: true, - }, - }); + const isPng = options.format === "png"; + const screenshot = { + format: isPng ? "png" : "jpeg", + quality: isPng ? undefined : (options.quality ?? 80), + optimizeForSpeed: true, + } as const; + + const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot }); let buffer: Buffer; if (result.screenshotData) { buffer = Buffer.from(result.screenshotData, "base64"); lastFrameCache.set(page, buffer); } else { - // hasDamage=false — nothing changed visually. Reuse the last frame. const cached = lastFrameCache.get(page); if (cached) { buffer = cached; } else { - // No cached frame yet (shouldn't happen — frame 0 always has damage). - // Issue another beginFrame with a tiny time advance to force a composite. - const retry = await client.send("HeadlessExperimental.beginFrame", { + // Frame 0 always has damage, so this path is near-unreachable. + // Force a composite with a tiny time advance. + const fallback = await sendBeginFrame(client, { frameTimeTicks: frameTimeTicks + 0.001, interval, - screenshot: { - format, - quality: format === "jpeg" ? (options.quality ?? 80) : undefined, - optimizeForSpeed: true, - }, + screenshot, }); - buffer = retry.screenshotData ? Buffer.from(retry.screenshotData, "base64") : Buffer.alloc(0); + buffer = fallback.screenshotData + ? Buffer.from(fallback.screenshotData, "base64") + : Buffer.alloc(0); if (buffer.length > 0) lastFrameCache.set(page, buffer); } }