Skip to content

Navigating away floods the log with "Uncaught … The operation was canceled." — cancellation escapes into the uncaught-error reporters #78

Description

@codymullins

Navigating away from a busy page floods the Starling.engine.js log with errors. Observed leaving x.com: a 36-line burst of [Error] Uncaught (in timer) The operation was canceled., [Error] Uncaught (in promise) ... (34 of them), and [Error] Uncaught (in animation frame) .... These are .NET OperationCanceledException instances from the page's own teardown token, misreported as uncaught page errors. They never reach site JavaScript (host-side log only), but they bury real errors and inflate console-error counters.

What happens

The navigation CancellationToken is published to the realm as JsRuntime.AbortToken (src/Starling.Bindings/Backend/StarlingScriptSession.cs:78-81, src/Starling.Engine/Engine.cs:1280). The VM's abort poll throws on the first dispatch iteration of every callback entry (src/Starling.Js/Runtime/JsVm.cs:697-709(stepCount++ & 0x3FF) == 0 is true at stepCount 0).

After MainWindow.RunNavigation cancels the previous page's token (src/Starling.Gui/MainWindow.cs:585-588), the old realm keeps getting pumped:

  • the GUI live timer stops only when the new page arrives in ShowPage (src/Starling.Gui/Controls/WebviewPanel.cs:428-430)
  • an in-flight PumpToQuiescenceAsync checks the token only at its loop head (src/Starling.Engine/Engine.cs around line 1588)

Every pending timer, promise reaction, and animation-frame callback then throws OperationCanceledException on entry, and three catch sites report it as an uncaught error because they lack the filter the engine uses elsewhere:

  • TimersBinding.InvokeHandler (src/Starling.Bindings/TimersBinding.cs:186-207)
  • AnimationFrameBinding.InvokeCallback (src/Starling.Bindings/AnimationFrameBinding.cs:70-92)
  • MicrotaskQueue.DrainAll (src/Starling.Js/Runtime/MicrotaskQueue.cs:84-96), routed to the UncaughtHandler installed at src/Starling.Js/Runtime/JsRuntime.cs:131-132

The script-run path already filters correctly: catch (Exception ex) when (ex is not OperationCanceledException) at Engine.cs:1489 and 1533.

Repro

Verified twice on 2026-06-10 with a scratch harness (console app referencing Starling.Js + Starling.Bindings): schedule a timeout, a requestAnimationFrame, and queued microtask jobs, cancel the token, pump once. Output is the exact log lines, and the callbacks never ran.

Fix

Real fix: stop pumping a cancelled realm at all.

  • stop the GUI live timer when navigation starts, not when the new page shows
  • check the abort token before LiveTick/PumpFrame, and between callbacks inside PumpToQuiescenceAsync/PumpOnce, so teardown cancellation never enters callback dispatch

Complementary hygiene (worth applying either way, not a band-aid): add the when (ex is not OperationCanceledException) filter to the three pump-side catch sites, matching Engine.cs:1489/1533. Cancellation is never an "uncaught page error" no matter how it arrives. It also defends the races where cancel lands mid-pump.

Context

Found during the x.com/nasa boot investigation on 2026-06-10 (branch feat/js-stack-trampoline). These reporters are host-side only today — the unhandledrejection pipe into the page realm is explicitly not implemented yet (comment at src/Starling.Js/Runtime/JsRuntime.cs:128-130) — which is why the burst is log noise rather than page-visible behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    browserRelates to the browser subsystembugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions