fix(engine): retry beginFrame on parallel render contention (#230)

## 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
This commit is contained in:
Miguel Ángel
2026-04-09 19:46:39 +02:00
committed by GitHub
parent 0cf03016b2
commit 6f04983e20
@@ -43,6 +43,33 @@ export interface BeginFrameResult {
// the compositor is paused).
const lastFrameCache = new WeakMap<Page, Buffer>();
const PENDING_FRAME_RETRIES = 5;
async function sendBeginFrame(
client: import("puppeteer-core").CDPSession,
params: Parameters<typeof client.send<"HeadlessExperimental.beginFrame">>[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<BeginFrameResult> {
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);
}
}