Repository navigation
fix(engine): a render fails when a composition's own script throws before its timeline binds - #5033
Merged
Merged
Conversation
Edit accuracy: accurate 2040 (base branch 2040), smooth 1554 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (3)
|
miguel-heygen
force-pushed
the
fix/render-fails-unbound-timeline
branch
from
October 5, 2026 00:37
ce35e95 to
1c26ced
Compare
…ts timeline binds
…imeline registers late
…er misreports a render
…thin 120 characters
miguel-heygen
force-pushed
the
fix/render-fails-unbound-timeline
branch
from
October 5, 2026 06:30
e90006d to
bf6a948
Compare
…nger cuts a render short
…hipping a missing layer
…ad of failing or going stale
…skips hidden hosts
miguel-heygen
marked this pull request as ready for review
October 5, 2026 15:51
jrusso1020
approved these changes
Oct 5, 2026
jrusso1020
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed at d22b01eb. Approving.
Scope of the new failure (the production risk):
- A render fails only when both hold: the timeline wait times out, and a page error was kept (
recordSubTimelineWarning,frameCapture.ts:2024). Errors never cut the wait short, so a composition that throws and then registers, or throws after it registered, still renders. The "registers after 3 s" row and the "scene throws after registering" case cover this. - Who owns an error: only code the composition owns produces a kept error (
classifyPageError,frameCapture.ts:2261). That means a stack frame namedhyperframes-composition://, or a script file served by the render file server with a status under 400. Other code is ignored:- the framework's injected scripts (
hyperframes://injected/N,htmlDocument.ts:205); - inlined CDN scripts, which keep their CDN URL (
htmlCompiler.ts:1261); - other origins;
- stackless rejections;
- inline handlers.
- the framework's injected scripts (
- Inline vs
src: an inline script is named byprepareCompositionScripts. Asrcfile from the project counts through the response set. Asrcfrom a CDN is inlined under its own URL, so it only counts when a composition frame is below it on the stack. I don't see a way to make a widget's error count as the composition's, short of the widget writing the composition'ssourceURLitself. - Local and distributed are the same:
renderChunknow runs the sameapplyRenderWarningPolicyafter capture (renderChunk.ts:928).RenderQualityErroris retried at most 4 times by AWS/GCP, then the render fails, so a failing chunk doesn't retry forever. - Code scanning: the three code-scanning alerts from
a432781donscriptRuns.ts(1126–1128) are marked fixed at this head. That file now only adds a constant and a fixedsourceURLline.
Reuse:
renderChunkcalls the existing warning policy rather than a chunk copy, asplan.tsalready does.- The CDP listener uses the engine's cached
getCdpSession. - The sub-composition path reuses the existing
[HyperFrames] composition script error:label. isJavaScriptTypeis exported from core rather than re-written.- The readiness/
getReadyPromisepattern doesn't fit here: this decides whose error it was, not when the page is ready.
Tests at this head:
- Engine
frameCapture+ sub-timeline suites: 70 of 70. - Core
htmlDocument+vfx: 101 of 101. - Producer
scriptFailureAttribution+vfxDeterminism+renderOrchestrator: 337 of 337, with real Chromium. renderChunk.test.ts: 15 of 15.- Removing the VFX re-insert fails 5 of the 31
vfxDeterminismtests, so those tests do exercise it.
Non-blocking:
- The VFX re-insert restarts CSS animations under the wrapper for one frame.
reinsertRegrownSources(vfx.ts:1323) runs insidepaintVfx, whichrenderSeekcalls after the CSS adapter has seeked and paused (init.ts:3868-3876). A remove and re-insert in Chrome 152 discards a seeked CSS animation and starts a new running one at 0. I measured opacity going from 0.5 atcurrentTime5000 to 0 atcurrentTime0, as a newrunninganimation. So on the frame a wrapper regrows from 0×0, CSS-animated children inside it are captured at their start pose.moveBeforekeeps the state (0.5, same animation,paused), and withmoveBeforeswapped in,vfxDeterminismstill passes all 31. Preview also runs this in browsers withoutmoveBefore, so it needsmoveBefore ?? insertBefore. - A kept error isn't tied to the composition that failed to register. Any kept error plus any unregistered timeline fails the render. For example, a project with a harmless inline throw (an analytics snippet pasted inline counts as the composition's) and an unmarked CSS-only sub-composition shipped on
mainand now fails after the wait. The description states this as the intended behaviour change. It's worth checking how oftensub_timeline_readiness_timeoutcomes with a[Browser:PAGEERROR]in production renders before this ships widely. - Simpler: the VFX half is a separate failure mode from the script attribution: the
vfx_failurestop, the empty paint at 0×0, and the regrow re-insert. That's about half the commits,vfx.tsand thevfxDeterminismcases. As its own PR it would be easier to review and to revert. The script-attribution half earns its size: eachsourceURLsite exists so a widget's error isn't counted, and each has a case that fails without it.
Verdict: APPROVE
Reasoning: A render now fails only when the timeline never registers and the composition's own code threw. Third-party, injected, and unattributable errors still only warn, and chunk renders apply the same policy as local ones. The suites are green, including the real-Chromium attribution cases, and the one-frame CSS restart in the VFX re-insert has a one-line fix.
— Rames Jusso
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
A composition whose own script throws before it registers its GSAP timeline (for example, it calls
gsap.timeline()above the<script>that loads GSAP) rendered as a still video and the CLI exited 0. The page error was printed as[Browser:PAGEERROR]and nothing acted on it, so the render waited out the 45 s timeline wait and shipped a static output with only asub_timeline_readiness_timeoutwarning. Distributed renders shipped the still video even for a timeline script that failed to load, which local renders already reject.Cause
RenderQualityErroron asub_timeline_script_failurewarning (decision-tree example renders 2 unique frames, and check + render both report success #3352). Only two routes produced that warning: a script that failed to load, and the[HyperFrames] composition script error:console line a sub-composition's script prints when it throws. An uncaught error in a composition's own top-level script was only logged, and the render compiler printed a sub-composition's throw under a different label ([Compiler] Composition script failed), so that route never matched in a render either.renderChunkcollected every capture session's warnings and never applied the render warning policy to them.Change
//# sourceURL=name: an inlined CDN script keeps its original URL, normalised throughnew URL(src).href(inlineExternalScripts); the framework's injected scripts (early stub, runtime, render mode, bridge) are namedhyperframes://injected/N(N counts within each injection), and the compiler's own after-fonts fallback, position-edit and position-reapply scripts are namedhyperframes://…too, all off the page's origin. A later directive wins over one the code already carries. The render compiler (prepareCompositionScripts, before deferring body scripts) names every other inline classic or module scripthyperframes-composition://body(COMPOSITION_SOURCE_URL): the composition's head and body scripts and its sub-compositions' scripts. A script that already ends with its own name, such as an inlined CDN script, keeps it. Only the render compiler does this, so Studio preview and scene swapping, which read live script text, see it as authored.Runtime.exceptionThrownon the page's CDP session and keeps an uncaught error only when it came from the composition: a frame of its stack is namedhyperframes-composition://, or is a script file the page loaded from the render file server (a script response under 400 from the server, recorded ininitializeSession), or, for an error with no stack (a parse error), the script file it names is one of those. The document URL does not count as such: an inline event handler reports the current document, and a widget can insert one and move the document anywhere on the server withreplaceState(measured). A widget that moves it onto the path of a script the page already loaded is still counted (contrived; not handled). That covers inline scripts in the composition document, the project's script files, and foreign code running inside a composition call (a CDN library the composition calls, or a foreign listener its owndispatchEventruns). Errors from scripts on other origins (a third-party widget) are not kept. A promise rejection raised by a browser API (img.decode(),Response.json()) arrives with no stack, naming only whatever document is current (after a hash change, areplaceState, or inside an iframe, that URL changes), whichever script caused it, so no stackless rejection is kept.classifyPageErroris the one place that decides. The CDP event is used rather than Puppeteer'spageerrorbecause the latter drops the script URL of syntax errors and of thrown non-errors (measured in Chromium 152). The benign play/pause race is still ignored.recordSubTimelineWarningrecordssub_timeline_script_failureinstead ofsub_timeline_readiness_timeout, naming the error and thedata-no-timelineremedy for a composition animated by CSS or rAF. The wait outcome itself staystimeout, so telemetry keeps its meaning.renderChunkpasses its capture warnings toapplyRenderWarningPolicy, the function the local renderer calls after capture, so a chunk throwsRenderQualityErrorwhere a local render fails. Chunks run that policy as best-effort (their job carries no strictness), so they fail on script and audio failures as a local best-effort render does.[HyperFrames] composition script error:label the engine matches. That logged throw is now a kept page error like an uncaught one (classifyConsoleScriptError): it fails the render only when the timeline wait times out. On main that label took the 2 s script-load fail-fast, which also cut the wait when the scene had already registered its timeline and the root registered later; an integrity-blocked script keeps the fail-fast. Cost: a composition script that throws through a framework wrapper (scoped sub-composition, VFX) and never registers now fails after the full wait, about 94 s end to end (the probe session and the capture session each wait 45 s), instead of about 6 s. A VFX error is not one of these: see the next bullet.runtime-error:vfx: ...) as avfx_failurewarning and stops the render at once: the timeline wait ends on it, no further frame or frame batch is captured, and the render warning policy fails on it like a script failure, which also covers an error from the last frame and chunk renders. Only chain-level errors stop it: a chain that does not parse, an unknown effect, a shader or program that does not build, no WebGL2, a missing ref element or capture structure, a lost context (there is no restore path), or adrawElementImagethat failed on the render's authoritative capture (that frame would ship stale). Those leave frames wrong. Two per-frame reports can leave correct frames, an empty capture box and a paint wait that timed out, sovfx.tsreports them asvfx-frame:and they stay page errors. A frame whose wrapper measures 0×0 now paints from an empty capture, the same path a hidden source takes, instead of repeating the previous frame. And a wrapper that measured 0×0 when the runtime registered it or at a seek is re-inserted into its canvas before the next paint at which it has a size, whether or not anything captured it at 0×0: in the runtime's capture setup, Chrome 152 can draw nothing of it, even afterrequestPaint, until it is re-inserted (measured through the runtime and the CLI; a barelayoutsubtreecanvas does not show it). Both marks matter: GSAP applies afromTostart value lazily, on its next tick, so in the CLI's script order the runtime registers the wrapper at its CSS size and only the seek sees it at 0×0, and a wrapper that is 0×0 at load but sized before the first seek is only seen at registration. On main such a layer stayed missing for the whole shot, including when it was hidden or its host started later while it was 0×0. Measured per frame: a wipe-in from width 0 draws from frame 1, a jump from 0 draws from the frame it is sized, a wrapper hidden at 0×0 or under a host that starts later draws once shown and sized, a dip to 0 recovers, a shrink to 0 is empty from that frame, and a wrapper that is always 0×0 stays empty (as in preview) with a page error. CLI: a broken chain fails in 3 s naming the error, also when a timeline wait is pending (about 94 s before this change); a working chain renders as before. A VFX error that arrives after the last frame, or in the HDR path (which seeks without these checks), still fails the render through the warning policy before the output is assembled.Behaviour change: a composition with no timeline that is not marked
data-no-timelinenow fails after the 45 s wait when one of its own scripts throws, where before it warned and shipped. An error from a script on another origin still only warns.Errors that cannot be attributed still ship a still video as on main, with the readiness warning: browser API rejections as above, inline parse errors (on the render path they carry only runtime frames),
data:/blob:scripts, inline event handlers andjavascript:URLs in the composition's own markup (they report the document), and an authored script that ends with its ownsourceURLname. Code built from text at runtime (eval,new Function, an inserted inline script, a stringsetTimeout) has no URL in Chromium, so it counts only through a named frame below it on the stack: a widget's synchronousevalnames the widget (not kept), the composition's synchronousevalnames the composition (kept). Thrown later, from a timer or callback inside such code, it has no named frame and no creator in V8's stack text, so it is not kept, whoever wrote it (measured in Chromium 152).Retry cost: AWS and GCP retry a failed chunk up to 4 times, and
RenderQualityErrorstays retryable here, because a CDN script that failed to load shows up as an authored error (gsap is not defined) and can succeed on retry. A real throw therefore costs up to 4 retries of the 45 s wait before the distributed render fails.Known cost of a VFX stop: auto-worker calibration logs it and tries once more in a second browser (about 1 s), and a composition routed to drawElement capture retries the render once before failing.
Known limit: plan-level
strictness: "strict"does not reach chunks, so a distributed strict render still fails only on script and audio failures; carrying it needs a plan format change.Evidence
CLI renders on this branch (main in the first row; on main every page error was only logged):
sub_timeline_readiness_timeoutsub_timeline_script_failurenaming the errorawaitsub_timeline_script_failurenaming the errorHow Chromium 152 reports each error through
Runtime.exceptionThrown(probed with a real page served from one origin, a widget from another; on the render path the inlined and injected scripts carry these URLs through theirsourceURLnames, and the composition's inline scripts reporthyperframes-composition://body):img.decode(),r.json())throw "string"inlinereplaceState)Tests:
scriptFailureAttribution.test.ts(new, integration lane, real Chromium on the render path:compileForRender,writeCompiledArtifacts, the producer file server): a timeline that never registers fails withsub_timeline_script_failurewhen an inline script throws at load or after anawait, a project script throws, a project script does not parse, or a project script is missing; an inline script in the head throws, or a sub-composition's inline script throws; a scene that throws after registering its timeline is kept as a page error and never as a load failure, so it cannot cut the wait short; a VFX chain naming an unknown effect fails the render, while a working chain and a wrapper with no size do not; invfxDeterminism.test.tsa layer that grows from 0×0, or is hidden at 0×0 and then shown and sized, draws on the next frame, one sized at registration but first painted at 0×0 (a GSAPfromTo) draws once it grows, one at 0×0 under a host that starts later draws once the host shows, one at 0×0 at load and sized before the first seek draws on that seek, and one that shrinks to 0×0 draws empty; it stayssub_timeline_readiness_timeoutwhen a script from another origin (inlined by the compiler) throws at load, throws from a timer, makesimg.decode()reject, changes the hash and then makes it reject,evals a throw,evals or inserts code that throws later, throws from a stringsetTimeout, inserts markup whose inline handler throws, or moves the document withreplaceStatefirst; and a composition thatevals its own throw fails. Red-first at each step: with the engine before the VFX change both the VFX case and the scene case fail; with the sources before that (origin rule, runtime naming) the sub-composition case and both handler cases fail; with the sources that counted unnamed code, the four widget runtime-code cases fail; with the sources before scripts were named, theawaitcase and the widget-at-load case fail.renderChunk.test.ts: a real chunk render (plan, thenrenderChunk) of a composition whose timeline script is missing rejects withRenderQualityErrornamingsub_timeline_script_failure; with main'srenderChunkit resolves.frameCapture.test.ts:classifyPageErroron Chromium-shaped exception details (each row of the table above, and the play/pause race), and the CDP listener wiring throughinitializeSession.frameCapture-subTimelineWarning.test.ts: a timeout with a kept error becomessub_timeline_script_failurenamingdata-no-timelineand leaves the outcometimeout; registered timelines record nothing; a load failure is named over the error it caused.await, in the head, and the composition's owneval), overriding a name a script already carries (widget at load, from a timer, and itseval), counting the document URL (both handler cases), restoring the compiler's own error label (the sub-composition case), dropping the logged throw (the sub-composition case), treating a VFX error as a page error (the VFX case), stopping on a per-frame empty box (the no-size wrapper case), repeating the last frame on a 0×0 wrapper (the shrink case), dropping the re-insert (the grow, GSAP, hidden, late-host and at-load cases), not marking a wrapper that registers at 0×0 (the at-load case), marking it only at registration (the GSAP and late-host cases), skipping hidden hosts in the per-seek walk (the late-host case), dropping the per-frame VFX check, dropping the early stop of the timeline wait, and leavingvfx_failureout of the warning policy each turn cases red.scriptFailureAttribution.test.ts(22 cases, 5 green runs with the box under load),vfxDeterminism.test.ts(31 cases, 3 green runs); core compiler and runtime suites (2347, scene swapping included), engine units, producer compiler and file-server suites, and therenderChunk.test.tscase pass.Before
hyperframes renderon main, the composition that builds its timeline before loading GSAP:After