Skip to content

Page JS and layout run on two threads at once — ScrollStateStore guard trips in DEBUG, silent data race in Release #79

Description

@codymullins

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:

  1. Progressive first paint hands the page to the GUI before the deferred phase settles (src/Starling.Engine/Engine.cs:766). RunDeferredScriptsAsyncPumpToQuiescenceAsyncPumpOnce 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 RefreshLiveLayoutLayoutSession.LayoutBeginLayoutPass on the same store (src/Starling.Gui/Controls/WebviewPanel.cs:1636-1642, 1727-1732).
  2. 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.

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