Skip to content

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
mainfrom
fix/render-fails-unbound-timeline
Draft

miguel-heygen wants to merge 7 commits into
mainfrom
fix/render-fails-unbound-timeline

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

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 a sub_timeline_readiness_timeout warning. Distributed renders shipped the still video even for a timeline script that failed to load, which local renders already reject.

Cause

  • A script failure already fails a local render: the orchestrator raises RenderQualityError on a sub_timeline_script_failure warning (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.
  • renderChunk collected every capture session's warnings and never applied the render warning policy to them.

Change

  • Which errors count. The engine listens to Runtime.exceptionThrown on 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 own dispatchEvent runs). 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, a replaceState, or inside an iframe, that URL changes), whichever script caused it, so no stackless rejection is kept. classifyPageError is the one place that decides. The CDP event is used rather than Puppeteer's pageerror because 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.
  • When they fail the render. Errors never cut the timeline wait short: a page may throw and still register its timeline later, as the timeout warning tells async compositions to do. When the wait times out with a kept error, recordSubTimelineWarning records sub_timeline_script_failure instead of sub_timeline_readiness_timeout, naming the error and the data-no-timeline remedy for a composition animated by CSS or rAF. The wait outcome itself stays timeout, so telemetry keeps its meaning.
  • Distributed renders. renderChunk passes its capture warnings to applyRenderWarningPolicy, the function the local renderer calls after capture, so a chunk throws RenderQualityError where 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.
  • When a script failed to load and the composition then threw because of it, the warning names the load failure, not the error it caused.

Behaviour change: a composition with no timeline that is not marked data-no-timeline now 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, srcdoc iframes).

Retry cost: AWS and GCP retry a failed chunk up to 4 times, and RenderQualityError stays 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):

Composition main this branch
Builds its timeline before loading GSAP (inline script throws) exit 0 after 95 s, 4 KB still MP4, warning sub_timeline_readiness_timeout exit 1 after 93 s, no output, sub_timeline_script_failure naming the error
Throws an unrelated error at load, registers its timeline after 3 s exit 0 (page errors were only logged) exit 0 after 9 s, animated
CSS animation, no timeline, a script from another origin throws exit 0 (page errors were only logged) exit 0 after 93 s, animated, readiness warning

How Chromium 152 reports each error through Runtime.exceptionThrown (probed with a real page served from one origin, a widget from another):

Error Script URL Stack frames Kept
Inline throw (sync, timer, rejected promise) composition composition yes
Project script file throws project file project file yes
Project script file does not parse project file none yes
Inline script does not parse, or a browser API rejects (img.decode(), r.json()) composition none no
throw "string" inline composition composition yes
CDN library throws when the composition calls it CDN CDN, composition yes
Widget from another origin throws other origin other origin no

Tests:

  • scriptFailureAttribution.test.ts (new, integration lane, real Chromium): a timeline that never registers fails with sub_timeline_script_failure when an inline script throws, a project script throws, a project script does not parse, or a project script is missing; it stays sub_timeline_readiness_timeout when a script from another origin throws, makes img.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, then renderChunk) of a composition whose timeline script is missing rejects with RenderQualityError naming sub_timeline_script_failure; with main's renderChunk it resolves.
  • frameCapture.test.ts: classifyPageError on Chromium-shaped exception details (each row of the table above, and the play/pause race), and the CDP listener wiring through initializeSession.
  • frameCapture-subTimelineWarning.test.ts: a timeout with a kept error becomes sub_timeline_script_failure naming data-no-timeline and leaves the outcome timeout; registered timelines record nothing; a load failure is named over the error it caused.
  • Single-line mutations each turn a unit test and a real-Chromium case red: accepting errors from any origin (widget throw), counting a frameless error that names the document (widget rejection), counting a stackless rejection (widget rejection after a hash change), and ignoring a frameless error's script URL (project parse error).
  • 3 green runs of the engine test files above, scriptFailureAttribution.test.ts, and the new renderChunk.test.ts case.

Before

hyperframes render on main, the composition that builds its timeline before loading GSAP:

[Browser:PAGEERROR] Cannot read properties of null (reading 'timeline')
[FrameCapture] Sub-composition timelines not registered after 45000ms: main. ...
[FrameCapture:sub_timeline_readiness_timeout] Sub-composition timelines did not become ready within 45000ms (still unregistered: main). This can be intentional: ...
[WARN] Render completed capture with correctness warnings {"strictness":"best-effort","warningCodes":["sub_timeline_readiness_timeout"]}
exit 0 after 95 s, a 4 KB MP4 of the first frame

After

[Browser:PAGEERROR] Cannot read properties of null (reading 'timeline')
[FrameCapture:sub_timeline_script_failure] A composition script threw and no timeline registered within 45000ms (still unregistered: main) (runtime-error:TypeError: Cannot read properties of null (reading 'timeline')). A composition animated by CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.
✗  Render failed
   Render blocked by 1 correctness warning:
   - sub_timeline_script_failure: A composition script threw and no timeline registered within 45000ms (still unregistered: main) (runtime-error:TypeError: Cannot read properties of null (reading 'timeline')). ...
exit 1 after 93 s, no output file

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2040 (base branch 2040), smooth 1585 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 (0)

@miguel-heygen miguel-heygen changed the title fix(engine): a render fails when a composition script throws before its timeline binds fix(engine): a render fails when a composition's own script throws before its timeline binds Oct 4, 2026
@miguel-heygen
miguel-heygen force-pushed the fix/render-fails-unbound-timeline branch from ce35e95 to 1c26ced Compare October 5, 2026 00:37

This branch has not been deployed

No deployments
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