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

    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