Skip to content

fix(producer): let buildReady setup that awaits a frame finish - #7213

Closed
jrusso1020 wants to merge 1 commit into
mainfrom
fix/producer-setup-animation-frames
Closed

jrusso1020 wants to merge 1 commit into
mainfrom
fix/producer-setup-animation-frames

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

What

A composition that registers window.__hf.buildReady can now await an animation frame during setup without hanging the render. Until the first seek, the virtual-time shim gives each frame request a real 16 ms tick and runs it at the frozen render time (0).

Why

The shim queues every requestAnimationFrame callback until the next seekToTime. The first seek happens only after render-ready, and render-ready waits for every buildReady promise. So setup like await new Promise((r) => requestAnimationFrame(r)) deadlocks, and the render fails at the 45 s readiness timeout.

Waiting a frame during setup is a common shader and path-tracer pattern, used to poll compile status or give the GPU a turn. We hit it on a Three.js path-tracing study.

Repro: a 1 s, 320x180 Three.js composition whose setup awaits one frame before resolving buildReady.scene. Rendered with the CLI on software GL:

Setup waits on main this PR
setTimeout(50) (control) renders renders, 4.0 s
one requestAnimationFrame fails after 45 s per attempt (93.7 s total) renders, 3.0 s; frames match the control (SSIM 1.0)
performance.now() advancing fails still fails, on purpose (see below)

Related work

Companion to #7211, which renames this failure from "Composition has zero duration" to "Composition setup did not finish".

How

  • requestAnimationFrame still pushes onto rafQueue. Before capture starts, it also schedules a 16 ms timer on the original setTimeout.
  • When that timer fires, the callback runs at virtualNowMs, which is still 0, but only if all of the following hold:
    • capture hasn't started;
    • the entry is still queued and not cancelled;
    • the page has registered at least one window.__hf.buildReady key.
  • seekToTime sets captureStarted. From then on, frames queue for the next seek exactly as before. A pre-capture frame still pending at the first seek runs once, in that seek's flush.

Design notes:

  • Why gate on buildReady: a frame-counting loop (x += 1 per frame) would otherwise tick a load-time-dependent number of times before the first seek, which would make the first captured frame nondeterministic. Pages without buildReady keep today's behavior byte for byte. Only pages that opted into async setup, where a frame wait would otherwise deadlock, get pre-capture frames.
  • performance.now and Date stay frozen on purpose: advancing them would make output depend on how long setup took. fix(engine): report a pending setup as a setup timeout, not zero duration #7211's diagnostic tells authors that setup waiting on the clock never finishes.

Test plan

  • Unit tests added/updated: fileServer-preCaptureFrames.test.ts loads the shim in a node:vm context with a manual timer queue. It covers:
    • a setup frame runs at frozen time 0 (fails on main, passes here);
    • with no buildReady registered, the frame waits for the first seek;
    • cancelAnimationFrame is honored;
    • a frame pending at the first seek runs once, in the flush;
    • after capture starts, frames queue for the next seek.
  • Manual testing performed: the repro table above, with exported frames checked at 0 s, 0.5 s and 0.9 s against the setTimeout control.
  • Other test results:
    • bun run test:classification passes, and the producer bun unit lane passes except one planV2.test.ts case ("publishes the compiled tree while the audio mixes"). That case hits its 60 s timeout on this box with main's fileServer.ts too, so it's environmental.
    • oxfmt, oxlint and tsc are clean.
  • Documentation updated (if applicable): the path-tracing starter recipe (separate PR) documents the setup wait patterns.
  • Comments follow CONTRIBUTING.md "Comments".

🤖 Generated with Claude Code

The render-time virtual-time shim queues every requestAnimationFrame
callback until the next seekToTime. The first seek only happens after
render-ready, and render-ready waits on window.__hf.buildReady, so a
composition whose setup awaits one animation frame (a common shader
warm-up or compile-poll pattern) deadlocked and timed out.

When the page has registered window.__hf.buildReady and capture has not
started, give each frame request a real 16 ms tick and run it at the
frozen virtual time. Nothing is captured before the first seek, so frame
output is unchanged. Pages without buildReady keep the old behavior, so
a frame-counting loop there still sees no frames before the first seek.
Once capture starts, frames queue for the next seek exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits
Pre-capture frames keep running after setup settles, so on any buildReady page an rAF loop ticks a wall-clock-dependent number of times before the first captured frame (↓ inline, with repro). CI was still running at review time.

— Genesis

// Only pages with buildReady setup can deadlock on a frame. Everywhere else
// a frame-counting loop must keep seeing no frames before the first seek.
var buildReady = window.__hf && window.__hf.buildReady;
if (!buildReady || Object.keys(buildReady).length === 0) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker (determinism): this gate checks that buildReady has keys, not that one is still pending. The runtime never removes settled keys (only teardown resets them, core/src/runtime/init.ts:6459). So from registration to the first seek, every rAF gets a real 16 ms tick, and a self-rescheduling loop runs once per 16 ms of setup time plus readiness polling.

That loop is the normal Three.js shape: setAnimationLoop, or a path tracer adding one sample per frame. The first captured frame then holds a load-time-dependent number of samples. Each parallel worker and distributed chunk loads its own page, so neighbouring chunks disagree at their boundaries. It also changes buildReady pages that never wait on a frame, like this PR's setTimeout(50) control: on main their loops saw exactly one frame per seek.

Repro: I loaded this head's shim in node:vm, with setup that waits one frame and function loop() { samples++; requestAnimationFrame(loop) }. After 3 / 40 / 200 timer ticks, seekToTime(0) leaves samples at 4 / 41 / 201. On main it is 1 / 1 / 1.

Second issue, same root: the gate is read when the timer fires. A frame requested just before the page registers buildReady gets served only if registration lands within 16 ms.

Suggested fix: serve pre-capture frames only while a registered promise is still unsettled. Track settlement per promise, the way the runtime's keyedBuildReadyPromises does, and stop once every promise settles. That limits the window to setup itself. A loop started during setup still ticks a timing-dependent number of times, so document "start render loops after buildReady resolves" in the recipe. Add a test with a self-rescheduling loop and a settled buildReady that pins how many frames run before the first seek.

@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Closing. As Genesis reproduced, ticking frames before the first seek makes frame-counting and sample-accumulating loops depend on load time, and that breaks HyperFrames' determinism guarantee. Even ticking only while setup is pending would still let wall-clock-paced setup (shader compiles) decide how many loop ticks happen. So the render clock stays strict. #7211 now tells authors that animation frames and the clock wait for the first frame, and to wait with setTimeout or scheduler.yield() instead. The path-tracing starter recipe will document the same pattern.

@jrusso1020 jrusso1020 closed this Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 132 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqrotatenudge-none-pct-r0-root-z100: tracking 0.03, pressJump 0, drop 0, reload 3.03, render 0.04, renderKey -, undo false, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.08, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.08, renderKey -, undo true, teleport true

@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Correction to my closing note: don't wait with scheduler.yield(). Chrome runs yield continuations ahead of ordinary tasks, so a loop that polls with it starves the setTimeout work it waits on. Plain headless Chrome 152 hangs on a ten-line page that does this. #7211's hint now says to use setTimeout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant