fix(engine): pre-create __render_frame__ siblings in initializeSession (#2006)

* fix(engine): commit render-frame siblings with a visual BeginFrame at init

Chunk-lambda renders drop a periodic near-black frame — one every
chunk_frames/worker_count frames (every 60 on a 4-worker single-video chunk),
YAVG ~22 against YMAX ~240 in signalstats. Local single-process renders don't
show it because they don't run under BeginFrame.

It's the isNewImage branch in injectVideoFramesBatch: the first time a session
paints a given videoId there's no __render_frame__ sibling yet, so it creates
the <img> on the spot (createElement + insertBefore) right before capture.
Under HeadlessExperimental.BeginFrame the compositor doesn't have that fresh
layer in the immediately-next frame, so the first captured frame per session
paints only body background + already-composited overlays. Each lambda worker
is its own session, hence the worker-boundary periodicity.

Pre-create the hidden sibling at the end of initializeSession, then drive one
non-capture visual BeginFrame (noDisplayUpdates: false) to composite the new
layers before the first real capture. The warmup ticks are noDisplayUpdates:
true (they advance the clock but don't paint) and the per-frame seek doesn't
tick, so this explicit visual frame is what actually commits the layers; its
tick sits in the gap between warmup and frame 0 so ticks stay monotonic and no
render frame is consumed. Every subsequent inject then takes the hasImg=true
(src-update) path; the isNewImage branch stays as a fallback for callers that
don't go through initializeSession.

* fix(engine): place the render-frame commit tick before the liveness probe

The commit tick at init sends its BeginFrame at `beginFrameTimeTicks - 1·interval`.
The producer's liveness probe then fires right after init at
`beginFrameTimeTicks - 5·interval` — an earlier tick. Per-session BeginFrame time
has to be monotonic, so the probe running backwards past the commit tick stalls
chrome-headless-shell indefinitely; the engine reads that timeout as a SwiftShader
heavy-layer stall and routes the render to screenshot capture, which then dies
relaunching and hangs the shard to the job timeout.

Reproduced on a native x86 SwiftShader host and bisected: with the commit tick
present the probe times out even with zero render-frame siblings created, so it's
the tick ordering, not layer count. Moving the commit tick to `-6·interval` (below
the probe, above the warmup ticks) keeps warmup < commit < probe < capture
monotonic and clears the stall on every affected comp — sub-composition-video,
chat, style-5-prod — while a healthy comp (style-18-prod) is unchanged. The commit
tick itself is untouched, so the black-frame fix it exists for still holds.
This commit is contained in:
Varo
2026-07-07 16:21:59 -04:00
committed by GitHub
parent 2bd9bb6f69
commit 76204ec630
3 changed files with 170 additions and 6 deletions
@@ -542,6 +542,46 @@ export async function removeDomLayerMask(page: Page, _extraHideIds: string[]): P
);
}
/**
* Pre-create hidden `__render_frame__` sibling `<img>`s for every
* `video[data-start]` in the page. Idempotent — videos that already
* have a sibling are skipped.
*
* `injectVideoFramesBatch` creates the sibling on the fly the first time
* it paints a given videoId (the `isNewImage = !hasImg` branch below).
* Under chrome-headless-shell's deterministic + `HeadlessExperimental.
* BeginFrame` mode, the immediately-next BeginFrame captures before the
* freshly-inserted `<img>` layer lands in the compositor's layer tree;
* the layer arrives a frame later. That single frame paints only the
* body background + previously-composed overlays.
*
* Called from `initializeSession`: in the screenshot path at the end (that
* capture path flushes paint, so timing doesn't matter), and in the BeginFrame
* path followed by one explicit visual `HeadlessExperimental.beginFrame`
* (`noDisplayUpdates: false`) that composites the new layers before the first
* capture — the warmup ticks are `noDisplayUpdates: true` and don't paint.
* Every subsequent `injectVideoFramesBatch` then takes the `hasImg = true` path
* (just an `img.src` update). The `isNewImage` branch stays as a fallback for
* callers that don't run through `initializeSession`.
*/
export async function ensureRenderFrameSiblings(page: Page): Promise<void> {
await page.evaluate(() => {
for (const video of Array.from(
document.querySelectorAll<HTMLVideoElement>("video[data-start]"),
)) {
const next = video.nextElementSibling;
if (next !== null && next.classList.contains("__render_frame__")) continue;
const img = document.createElement("img");
img.classList.add("__render_frame__");
img.id = `__render_frame_${video.id}__`;
img.style.pointerEvents = "none";
img.style.position = "absolute";
img.style.visibility = "hidden";
video.parentNode?.insertBefore(img, video.nextSibling);
}
});
}
/**
* Returns the subset of `updates.videoId`s that were actually painted in
* this call. Videos skipped because of a hidden visual ancestor are NOT