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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions