During x.com boot the log shows:
[Error] Uncaught (in timer) ScrollStateStore offset write during a layout pass. Scroll offsets are paint/hit-test state; layout must never read or write them (browser-plan/scroll-model.md).
The guard is right — the threading is wrong. A JS timer callback wrote a scroll offset while a layout pass was open on another thread. The interactive path runs page JS and layout concurrently on two threads, violating the engine's single-thread-per-page contract. The guard only exists in DEBUG builds, so Release does the unsynchronized cross-thread access silently.
What happens
The guard ScrollStateStore.AssertNotInLayoutPass (src/Starling.Layout/Scroll/ScrollStateStore.cs:447-453, [Conditional("DEBUG")]) fires from Write/WriteRoot (lines 241/253) when _layoutPassDepth > 0. The depth field is a plain int (around line 77), and the class is documented "Not thread-safe". Both bracket sites are finally-balanced (src/Starling.Layout/Incremental/LayoutSession.cs:107/148, src/Starling.Layout/LayoutEngine.cs:83/105) and layout never re-enters JS on one thread — so a tripped guard requires a layout pass running concurrently on another thread.
Two code-supported interleavings, both on the interactive path:
- Progressive first paint hands the page to the GUI before the deferred phase settles (
src/Starling.Engine/Engine.cs:766). RunDeferredScriptsAsync → PumpToQuiescenceAsync → PumpOnce then fires timer callbacks on the navigation task's thread-pool thread for minutes, while the UI thread's live timer sees React commits bump LayoutInvalidationVersion and runs RefreshLiveLayout → LayoutSession.Layout → BeginLayoutPass on the same store (src/Starling.Gui/Controls/WebviewPanel.cs:1636-1642, 1727-1732).
StarlingScriptSession.PumpFrame fire-and-forgets _dynamicRunner.DrainAsync (src/Starling.Bindings/Backend/StarlingScriptSession.cs:444-445), so a lazy chunk executes on a thread-pool continuation. Any geometry read there forces BoxLayoutHost.EnsureFresh → a layout pass on that thread (src/Starling.Engine/BoxLayoutHost.cs:207-230; the scroll write sites are 393-406).
This breaks the documented contracts of ScrollStateStore and PageScripting (src/Starling.Engine/PageScripting.cs:31-37 — "the load-time async phase has fully settled before the page is handed to the shell" — no longer true on the progressive path). Two extra consequences:
- the guard's
InvalidOperationException is a raw .NET exception, so it unwinds through the JS frames uncatchable by page code and kills that page timer callback mid-flight (on x.com this can prolong boot)
- concurrent
BeginLayoutPass/EndLayoutPass on a plain int can lose an increment or decrement, so the gate can also read non-zero spuriously
Fix
Real fix: restore one logical thread per page (realm + layout host + scroll store).
- (a) Marshal the deferred-phase pump and all dynamic-script drains onto the same single-threaded scheduler/queue the GUI live timer uses for the page, so JS callbacks and layout passes serialize. This keeps progressive paint and the single-thread contract — the durable model.
- (b) Narrower variant: do not run the UI live timer's
RefreshLiveLayout while the deferred settle still owns the page — gate liveness on the hand-off (HandOff, Engine.cs:790-800) instead of first paint — and make PumpFrame's DrainAsync awaited/serialized into the pump rather than fire-and-forget.
Band-aids (name them for what they are): taking a lock inside ScrollStateStore, making _layoutPassDepth interlocked, or catching the guard exception. Each hides the cross-thread JS/layout execution, which also races every other non-thread-safe structure the page touches — the Starling DOM tree, the layout host caches. The scroll store guard is just the tripwire that happened to catch it.
Context
Found during the x.com/nasa boot investigation on 2026-06-10 (branch feat/js-stack-trampoline). See browser-plan/scroll-model.md for the offsets-are-paint-state contract the guard enforces.
During x.com boot the log shows:
The guard is right — the threading is wrong. A JS timer callback wrote a scroll offset while a layout pass was open on another thread. The interactive path runs page JS and layout concurrently on two threads, violating the engine's single-thread-per-page contract. The guard only exists in DEBUG builds, so Release does the unsynchronized cross-thread access silently.
What happens
The guard
ScrollStateStore.AssertNotInLayoutPass(src/Starling.Layout/Scroll/ScrollStateStore.cs:447-453,[Conditional("DEBUG")]) fires fromWrite/WriteRoot(lines 241/253) when_layoutPassDepth > 0. The depth field is a plainint(around line 77), and the class is documented "Not thread-safe". Both bracket sites are finally-balanced (src/Starling.Layout/Incremental/LayoutSession.cs:107/148,src/Starling.Layout/LayoutEngine.cs:83/105) and layout never re-enters JS on one thread — so a tripped guard requires a layout pass running concurrently on another thread.Two code-supported interleavings, both on the interactive path:
src/Starling.Engine/Engine.cs:766).RunDeferredScriptsAsync→PumpToQuiescenceAsync→PumpOncethen fires timer callbacks on the navigation task's thread-pool thread for minutes, while the UI thread's live timer sees React commits bumpLayoutInvalidationVersionand runsRefreshLiveLayout→LayoutSession.Layout→BeginLayoutPasson the same store (src/Starling.Gui/Controls/WebviewPanel.cs:1636-1642,1727-1732).StarlingScriptSession.PumpFramefire-and-forgets_dynamicRunner.DrainAsync(src/Starling.Bindings/Backend/StarlingScriptSession.cs:444-445), so a lazy chunk executes on a thread-pool continuation. Any geometry read there forcesBoxLayoutHost.EnsureFresh→ a layout pass on that thread (src/Starling.Engine/BoxLayoutHost.cs:207-230; the scroll write sites are 393-406).This breaks the documented contracts of
ScrollStateStoreandPageScripting(src/Starling.Engine/PageScripting.cs:31-37— "the load-time async phase has fully settled before the page is handed to the shell" — no longer true on the progressive path). Two extra consequences:InvalidOperationExceptionis a raw .NET exception, so it unwinds through the JS frames uncatchable by page code and kills that page timer callback mid-flight (on x.com this can prolong boot)BeginLayoutPass/EndLayoutPasson a plainintcan lose an increment or decrement, so the gate can also read non-zero spuriouslyFix
Real fix: restore one logical thread per page (realm + layout host + scroll store).
RefreshLiveLayoutwhile the deferred settle still owns the page — gate liveness on the hand-off (HandOff,Engine.cs:790-800) instead of first paint — and makePumpFrame'sDrainAsyncawaited/serialized into the pump rather than fire-and-forget.Band-aids (name them for what they are): taking a lock inside
ScrollStateStore, making_layoutPassDepthinterlocked, or catching the guard exception. Each hides the cross-thread JS/layout execution, which also races every other non-thread-safe structure the page touches — the Starling DOM tree, the layout host caches. The scroll store guard is just the tripwire that happened to catch it.Context
Found during the x.com/nasa boot investigation on 2026-06-10 (branch
feat/js-stack-trampoline). Seebrowser-plan/scroll-model.mdfor the offsets-are-paint-state contract the guard enforces.