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.
Navigating away from a busy page floods the
Starling.engine.jslog 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 .NETOperationCanceledExceptioninstances 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
CancellationTokenis published to the realm asJsRuntime.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) == 0is true atstepCount0).After
MainWindow.RunNavigationcancels the previous page's token (src/Starling.Gui/MainWindow.cs:585-588), the old realm keeps getting pumped:ShowPage(src/Starling.Gui/Controls/WebviewPanel.cs:428-430)PumpToQuiescenceAsyncchecks the token only at its loop head (src/Starling.Engine/Engine.csaround line 1588)Every pending timer, promise reaction, and animation-frame callback then throws
OperationCanceledExceptionon 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 theUncaughtHandlerinstalled atsrc/Starling.Js/Runtime/JsRuntime.cs:131-132The script-run path already filters correctly:
catch (Exception ex) when (ex is not OperationCanceledException)atEngine.cs:1489and1533.Repro
Verified twice on 2026-06-10 with a scratch harness (console app referencing
Starling.Js+Starling.Bindings): schedule a timeout, arequestAnimationFrame, 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.
LiveTick/PumpFrame, and between callbacks insidePumpToQuiescenceAsync/PumpOnce, so teardown cancellation never enters callback dispatchComplementary hygiene (worth applying either way, not a band-aid): add the
when (ex is not OperationCanceledException)filter to the three pump-side catch sites, matchingEngine.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 — theunhandledrejectionpipe into the page realm is explicitly not implemented yet (comment atsrc/Starling.Js/Runtime/JsRuntime.cs:128-130) — which is why the burst is log noise rather than page-visible behavior.