From 24f64f02c09136dde06343119775044c9ef27023 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 14 May 2026 17:43:09 +0000 Subject: [PATCH] fix(producer): remove discardWarmupCapture call entirely Validating the harness against a multi-chunk render (chunkSize=50 on many-cuts, N=4 chunks) revealed that the previous "discard at startFrame-1 for chunk N>0" fix had a second deadlock mode: the discard's frameTimeTicks (base + 49*interval) ended up LARGER than the captureStage first-call's frameTimeTicks (base + 0). Chrome's compositor wedges when asked to go backward in time as predictably as it wedges on a same-time duplicate. Both attempted fixes were trying to work around a problem that doesn't exist: lastFrameCache is only consulted when Chrome returns hasDamage=false, and every chunk frame seeks fresh DOM via __hf.seek() before the screenshot, so hasDamage is always true and the cache is never read. The priming step is unnecessary. Validated: - many-cuts at chunkSize=50 (N=4 chunks): distributed-simulated PASSED - many-cuts at default chunkSize (N=1): distributed-simulated PASSED - font-variant-numeric (N=1): distributed-simulated PASSED - 39 unit tests across distributed/ : PASSED in Docker - in-process mode unchanged: font-variant-numeric + many-cuts PASSED Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/distributed/renderChunk.ts | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 5f17dabf8..510873403 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -25,8 +25,11 @@ * (`buildVirtualTimeShim({ seedRandomFromFrame: true })`) so any * composition that uses `Math.random` / `crypto.getRandomValues` * produces byte-identical pixels per `(planDir, chunkIndex)`. - * - One `discardWarmupCapture` runs before the chunk's first real frame - * to prime the BeginFrame `lastFrameCache`. + * - The `lastFrameCache` priming step that earlier versions tried to + * emit before the first capture was removed — every frame in the + * capture loop seeks fresh DOM, so the cache is never consulted on + * the read path, and emitting the priming beginFrame at the chunk's + * first absolute frame deadlocked Chrome's compositor. * - The chunk's encode runs with `lockGopForChunkConcat: true` and * `gopSize === framesInChunk` so concat-copy at assemble time is safe. * @@ -45,7 +48,6 @@ import { type CaptureSession, closeCaptureSession, createCaptureSession, - discardWarmupCapture, type EngineConfig, getEncoderPreset, initializeSession, @@ -412,30 +414,36 @@ export async function renderChunk( await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas); await initializeSession(session); - // Prime BeginFrame's `lastFrameCache` so the chunk's first real capture - // reports `hasDamage` the same as an in-process render at the same - // absolute frame would. The in-process renderer that produces the - // baseline has captured frame N-1 by the time it captures frame N, so - // frame N-1's bytes are in the cache. The distributed chunk worker - // starts cold — without priming, the first real capture for chunk N - // would see `hasDamage=true` (no cache hit) while in-process sees - // whatever frame N-1 produced. + // Note: `discardWarmupCapture` is intentionally NOT called here. // - // The discard MUST target frame N-1, not frame N: BeginFrame deadlocks - // when called twice with the same `frameTimeTicks` (the compositor has - // no new damage to advance for, and the second call hangs until - // protocolTimeout). Earlier wiring called the discard at startFrame - // itself, which immediately collided with captureStage's first call - // and hung every chunk's render. + // The helper was originally added to prime Chrome's + // `lastFrameCache` so that — if the chunk's first real capture + // happened to return `hasDamage=false` — the cached bytes would + // match what an in-process renderer's cache held at the same + // absolute frame index. In practice, every frame in the chunk's + // capture loop seeks fresh DOM via `__hf.seek(absoluteTime)` + // before the screenshot, which means `hasDamage=true` on every + // frame and `lastFrameCache` is never consulted on the read path. // - // For chunk 0 there is no frame -1 to prime against — and the in-process - // renderer's first frame also captures with an empty cache, so the - // hasDamage signal matches by construction. Skip the discard entirely. - if (slice.startFrame > 0) { - const priorFrame = slice.startFrame - 1; - const priorTime = (priorFrame * plan.dimensions.fpsDen) / plan.dimensions.fpsNum; - await discardWarmupCapture(session, priorFrame, priorTime); - } + // Two failure modes made the call actively harmful: + // - calling it with the chunk's first absolute frame caused + // captureStage to issue a second `HeadlessExperimental.beginFrame` + // at the same `frameTimeTicks` Chrome's compositor had just + // advanced to. Chrome blocks the second call indefinitely + // waiting for new damage; the render hangs at protocolTimeout. + // - calling it with `startFrame - 1` (an attempt to prime against + // the prior frame) advanced compositor time past the chunk's + // first capture, then the first real capture asked the + // compositor to go backward in time, which wedged it the same + // way. + // + // The byte-identical-retry contract holds without the priming: + // both retries of the same `(planDir, chunkIndex)` enter the + // capture loop in identical state (fresh session, locked warmup, + // virtual-time-shimmed page) and seek the same absolute times in + // the same order. The lastFrameCache fallback only matters for a + // hasDamage=false frame, which doesn't appear under fresh-seek + // captures. // ── Capture the chunk's range via runCaptureStage ── await runCaptureStage({