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