Skip to content

Commit aab2d2c

Browse files
miguel-heygenclaude
andcommitted
fix(engine): retry beginFrame on "Another frame is pending" error
Running multiple renders in parallel causes Chrome's beginFrame to fail with "Another frame is pending" under CPU contention. Instead of crashing immediately, retry with exponential backoff (50ms–800ms, 5 attempts) and surface a clear error if retries exhaust. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1c61a0b commit aab2d2c

1 file changed

Lines changed: 42 additions & 20 deletions

File tree

packages/engine/src/services/screenshotService.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,33 @@ export interface BeginFrameResult {
4343
// the compositor is paused).
4444
const lastFrameCache = new WeakMap<Page, Buffer>();
4545

46+
const PENDING_FRAME_RETRIES = 5;
47+
48+
async function sendBeginFrame(
49+
client: import("puppeteer-core").CDPSession,
50+
params: Parameters<typeof client.send<"HeadlessExperimental.beginFrame">>[1],
51+
) {
52+
for (let attempt = 0; ; attempt++) {
53+
try {
54+
return await client.send("HeadlessExperimental.beginFrame", params);
55+
} catch (err: unknown) {
56+
const msg = err instanceof Error ? err.message : String(err);
57+
const isPending = msg.includes("Another frame is pending");
58+
if (isPending && attempt < PENDING_FRAME_RETRIES) {
59+
await new Promise((r) => setTimeout(r, 50 * 2 ** attempt));
60+
continue;
61+
}
62+
if (isPending) {
63+
throw new Error(
64+
`[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries — CPU overloaded by parallel renders. ` +
65+
`Reduce concurrent renders or use --docker for isolation.`,
66+
);
67+
}
68+
throw err;
69+
}
70+
}
71+
}
72+
4673
export async function beginFrameCapture(
4774
page: Page,
4875
options: CaptureOptions,
@@ -51,39 +78,34 @@ export async function beginFrameCapture(
5178
): Promise<BeginFrameResult> {
5279
const client = await getCdpSession(page);
5380

54-
const format = options.format === "png" ? "png" : "jpeg";
55-
const result = await client.send("HeadlessExperimental.beginFrame", {
56-
frameTimeTicks,
57-
interval,
58-
screenshot: {
59-
format,
60-
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
61-
optimizeForSpeed: true,
62-
},
63-
});
81+
const isPng = options.format === "png";
82+
const screenshot = {
83+
format: isPng ? "png" : "jpeg",
84+
quality: isPng ? undefined : (options.quality ?? 80),
85+
optimizeForSpeed: true,
86+
} as const;
87+
88+
const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot });
6489

6590
let buffer: Buffer;
6691
if (result.screenshotData) {
6792
buffer = Buffer.from(result.screenshotData, "base64");
6893
lastFrameCache.set(page, buffer);
6994
} else {
70-
// hasDamage=false — nothing changed visually. Reuse the last frame.
7195
const cached = lastFrameCache.get(page);
7296
if (cached) {
7397
buffer = cached;
7498
} else {
75-
// No cached frame yet (shouldn't happen — frame 0 always has damage).
76-
// Issue another beginFrame with a tiny time advance to force a composite.
77-
const retry = await client.send("HeadlessExperimental.beginFrame", {
99+
// Frame 0 always has damage, so this path is near-unreachable.
100+
// Force a composite with a tiny time advance.
101+
const fallback = await sendBeginFrame(client, {
78102
frameTimeTicks: frameTimeTicks + 0.001,
79103
interval,
80-
screenshot: {
81-
format,
82-
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
83-
optimizeForSpeed: true,
84-
},
104+
screenshot,
85105
});
86-
buffer = retry.screenshotData ? Buffer.from(retry.screenshotData, "base64") : Buffer.alloc(0);
106+
buffer = fallback.screenshotData
107+
? Buffer.from(fallback.screenshotData, "base64")
108+
: Buffer.alloc(0);
87109
if (buffer.length > 0) lastFrameCache.set(page, buffer);
88110
}
89111
}

0 commit comments

Comments
 (0)