fix(engine): a render fails when a composition's own script throws before its timeline binds - #5033
Draft
miguel-heygen wants to merge 7 commits into
Draft
miguel-heygen wants to merge 7 commits into
miguel-heygen wants to merge 7 commits into
Conversation
Edit accuracy: accurate 2040 (base branch 2040), smooth 1585 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…ts timeline binds
…imeline registers late
miguel-heygen
force-pushed
the
fix/render-fails-unbound-timeline
branch
from
October 5, 2026 00:37
ce35e95 to
1c26ced
Compare
This branch has not been deployed
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 runtime's[HyperFrames] composition script error:console line for sub-compositions. An uncaught error in a composition's own top-level script was only logged.renderChunkcollected every capture session's warnings and never applied the render warning policy to them.Change
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 on the render file server's origin, or, for an error with no stack (a parse error), the script file it names is. 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.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, and code without a server URL (
new Function,data:/blob:scripts,srcdociframes).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 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):
sub_timeline_readiness_timeoutsub_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):img.decode(),r.json())throw "string"inlineTests:
scriptFailureAttribution.test.ts(new, integration lane, real Chromium): a timeline that never registers fails withsub_timeline_script_failurewhen an inline script throws, a project script throws, a project script does not parse, or a project script is missing; it stayssub_timeline_readiness_timeoutwhen a script from another origin throws, makesimg.decode()reject, or changes the hash and then makes it reject. Against main's engine the three throwing cases fail; against the first version of this PR (any page error counted) the widget throw fails; against the version before the frameless rule the widget rejection fails; against the version that compared a rejection's URL with the document the hash-change rejection fails.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.scriptFailureAttribution.test.ts, and the newrenderChunk.test.tscase.Before
hyperframes renderon main, the composition that builds its timeline before loading GSAP:After