Skip to content

fix(runtime): resume readiness after deferred GSAP batching#2491

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/runtime-ready-deferred
Jul 15, 2026
Merged

fix(runtime): resume readiness after deferred GSAP batching#2491
miguel-heygen merged 1 commit into
mainfrom
fix/runtime-ready-deferred

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

  • re-arm the runtime readiness listener whenever GSAP batching begins after initialization
  • clean up the listener during runtime teardown
  • cover the deferred batching race with a focused runtime regression test

Root cause

The runtime only subscribed to hf-timelines-built when batching was already active during initial setup. A timeline created from setTimeout(..., 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 with Composition has zero duration.

Verification

  • bun run --cwd packages/core test:watch --run src/runtime/init.test.ts — 52 passed
  • bun run --cwd packages/core typecheck
  • bun packages/producer/src/regression-harness.ts three-boundary-deferred --sequential — pass; 20/20 frames, visual and audio validation passed
  • pre-commit lint, format, fallow, tracked-artifact, and typecheck hooks passed

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 falsetrue 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.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

  1. Root-cause claim verified against main HEAD. At main, the hf-timelines-built listener is installed only inside the top-level if (window.__hfTimelinesBuilding) block that runs once during init. If batching starts after that check — e.g. a setTimeout(0) composition script that opens a proxy queue on the next task, or the runtime's own deferred setTimeout(() => maybePublishRenderReady(), 0) at line ~2531 landing while __hfTimelinesBuilding has just flipped true — maybePublishRenderReady flips __renderReady = false with no listener to ever resume it. The fix moves listener re-arm inside the batching branch of maybePublishRenderReady, so every time we hit "batching in progress" state a listener is (idempotently) re-armed.

  2. Funnel-completeness. Every deferred readiness state terminates: either hf-timelines-built fires → listener → maybePublishRenderReady → resumes; or the event never fires (genuine GSAP hang / crash) → __renderReady stays false → downstream capture timeout fires as it should. No terminal state is silently swallowed.

  3. Dispatcher contract holds. packages/producer/stubs/hf-early-stub.ts:254 sets __hfTimelinesBuilding = false before dispatchEvent("hf-timelines-built") — so the listener callback observes __hfTimelinesBuilding === false on the next maybePublishRenderReady entry. No flag/event ordering race between producer + runtime.

  4. Helper caller-contract intent (waitForTimelinesBuilt). Single caller (line 2503). Idempotent (early-return if timelinesBuiltListener non-null). Listener callback nulls the module-scoped ref before re-calling maybePublishRenderReady, so a re-entered batching cycle can install a fresh listener without collision. Cleanup registered via registerRuntimeCleanup — teardown-safe.

  5. Full dispatch chain of the readiness signal. __renderReady consumers observe via polling of the window flag; there is no postState(false) on the true → false transition either before or after this PR, so downstream behavior is byte-identical on the transition. Semantics preserved.

  6. Deleted-guard reachability. No timeout removed. The 45s downstream capture timeout (and the sub_timeline_readiness_timeout warning) 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.

  7. 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-built dispatch (see flushBatch line 289 and scheduleTimelinesBuiltCheck line 269). No false-positive-ready path: maybePublishRenderReady reads __hfTimelinesBuilding before 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

@miguel-heygen miguel-heygen merged commit b874c44 into main Jul 15, 2026
52 checks passed
@miguel-heygen miguel-heygen deleted the fix/runtime-ready-deferred branch July 15, 2026 19:50
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.

3 participants