fix(runtime): resume readiness after deferred GSAP batching#2491
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 9bab0fec. CI green.
Blockers
(none)
Concerns
(none)
Nits
(none)
Green notes
🟢 Listener move from init-time to maybePublishRenderReady() is the right shape. The pre-PR init-time subscription only attached if __hfTimelinesBuilding was true AT init, so any batching that STARTED after init (a sub-composition timeline built from a setTimeout(…, 0)) fired hf-timelines-built into empty air and readiness never recovered. Post-PR, every entry into maybePublishRenderReady() that sees the building flag re-arms the listener via waitForTimelinesBuilt(), so late batching cycles are covered without leaking multiple listeners.
🟢 if (timelinesBuiltListener) return idempotence. Guards against attaching a second listener while one is still pending — the reference is nulled inside onTimelinesBuilt before it calls maybePublishRenderReady(), so the next tick's re-arm-if-still-building path is safe.
🟢 Cleanup via registerRuntimeCleanup — a runtime teardown while a listener is still parked removes it and nulls the ref. Prevents a dangling listener across sandbox re-inits.
🟢 Old init-time subscription removed cleanly (lines 2499-2506 in the deleted block). Was strictly redundant after waitForTimelinesBuilt moved into the shared publish path; no behavior regression on the "batching already true at init" case since maybePublishRenderReady() still hits the same branch.
🟢 Red-first regression pin. The test flips __hfTimelinesBuilding from false → true AFTER initSandboxRuntimeModular() returns (via microtask), then dispatches hf-timelines-built. Against pre-PR code, no listener would be attached at the flip → readiness stays true when the flag flips (init-time check was false) OR the built event fires into nothing after the transient. Post-PR, the second maybePublishRenderReady() invocation attaches a fresh listener that catches the build event. Real pin.
Verdict framing
Small, well-scoped runtime fix that closes a deferred-batching race without expanding the readiness state machine. LGTM from my side.
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: APPROVE — RIGHT. Fix is correct, minimal, idempotent, and cleanup-safe. R1 at 9bab0fecb47e9fbfa4393f314e6d65d033625010; CI green (48 ✓, 4 skipped); mergeable=MERGEABLE (blocked only on approval). No prior human reviews.
Adversarial pass — lifecycle / state / dispatch chain
-
Root-cause claim verified against
mainHEAD. Atmain, thehf-timelines-builtlistener is installed only inside the top-levelif (window.__hfTimelinesBuilding)block that runs once during init. If batching starts after that check — e.g. asetTimeout(0)composition script that opens a proxy queue on the next task, or the runtime's own deferredsetTimeout(() => maybePublishRenderReady(), 0)at line ~2531 landing while__hfTimelinesBuildinghas just flipped true —maybePublishRenderReadyflips__renderReady = falsewith no listener to ever resume it. The fix moves listener re-arm inside the batching branch ofmaybePublishRenderReady, so every time we hit "batching in progress" state a listener is (idempotently) re-armed. -
Funnel-completeness. Every deferred readiness state terminates: either
hf-timelines-builtfires → listener →maybePublishRenderReady→ resumes; or the event never fires (genuine GSAP hang / crash) →__renderReadystays false → downstream capture timeout fires as it should. No terminal state is silently swallowed. -
Dispatcher contract holds.
packages/producer/stubs/hf-early-stub.ts:254sets__hfTimelinesBuilding = falsebeforedispatchEvent("hf-timelines-built")— so the listener callback observes__hfTimelinesBuilding === falseon the nextmaybePublishRenderReadyentry. No flag/event ordering race between producer + runtime. -
Helper caller-contract intent (
waitForTimelinesBuilt). Single caller (line 2503). Idempotent (early-return iftimelinesBuiltListenernon-null). Listener callback nulls the module-scoped ref before re-callingmaybePublishRenderReady, so a re-entered batching cycle can install a fresh listener without collision. Cleanup registered viaregisterRuntimeCleanup— teardown-safe. -
Full dispatch chain of the readiness signal.
__renderReadyconsumers observe via polling of the window flag; there is nopostState(false)on thetrue → falsetransition either before or after this PR, so downstream behavior is byte-identical on the transition. Semantics preserved. -
Deleted-guard reachability. No timeout removed. The 45s downstream capture timeout (and the
sub_timeline_readiness_timeoutwarning) still fire on a genuine unresponsive timeline. The fix strictly enlarges the "unblocks correctly" set without shrinking the "blocks + times-out on real hangs" set. -
Extrapolation across batching scenarios. Multi-cycle batching (batch → done → batch again → done) is handled — each cycle installs a fresh listener, event fires, listener removes itself and nulls the ref, next cycle can re-install. Nested/child timelines built in one batch cycle collapse to a single
hf-timelines-builtdispatch (seeflushBatchline 289 andscheduleTimelinesBuiltCheckline 269). No false-positive-ready path:maybePublishRenderReadyreads__hfTimelinesBuildingbefore deciding, and JS single-threading rules out any interleaving between read and listener install.
Field-signal cross-ref (advisory, not a blocker). The batch-2 sourcing thread cited sub_timeline_readiness_timeout warning during a successful 80s slideshow-export render (batch source: p1784142048663179). Magi's triage on that thread found no sub_timeline_readiness_timeout string in current source and declined to open a speculative fix. This PR fixes a different, harder symptom — 45s hard wait + Composition has zero duration — reproduced independently via three-boundary-deferred. The link between the field warning and this fix is plausible but not proven; the PR stands on its own reproduction. Not a blocker, but the PR body could acknowledge the field-signal connection is inferential rather than direct.
Regression coverage (nit, not a blocker). The new test covers the specific reported race (batch starts after init → ready flips false → listener re-arms → event resumes). Not asserted, but present in the fix: idempotency of waitForTimelinesBuilt under repeated maybePublishRenderReady entries during a single batching cycle, and cleanup-on-teardown. Existing suite likely has broader coverage of the surrounding code; scope on a targeted regression is appropriate.
Envelope. Clean — no Co-Authored-By: Claude / 🤖 Generated with [Claude Code] markers.
Approving. — Via
Summary
Root cause
The runtime only subscribed to
hf-timelines-builtwhen batching was already active during initial setup. A timeline created fromsetTimeout(..., 0)could begin batching just before the runtime's own deferred rebinding pass. That pass set__renderReady = false, but no listener remained to resume readiness, so capture waited 45 seconds and failed withComposition has zero duration.Verification
bun run --cwd packages/core test:watch --run src/runtime/init.test.ts— 52 passedbun run --cwd packages/core typecheckbun packages/producer/src/regression-harness.ts three-boundary-deferred --sequential— pass; 20/20 frames, visual and audio validation passed