Skip to content

fix(signals): the lane-membership ruling — F8 and adopted staging (F6 stopped on A18 (c)) - #3806

Merged
ryansolid merged 5 commits into
nextfrom
fix/fuzz-batch-c
Oct 5, 2026
Merged

ryansolid merged 5 commits into
nextfrom
fix/fuzz-batch-c

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Implements the lane-membership amendment (2026-10-05) as ruled. Reading A was chosen for all three cases. Two of the three fixes landed. F6 stopped: its fix flips Tier-A A18 pins, so it needs a ruling (details below). It stays it.fails.

The spec paragraph from spec/lane-membership (572ea7c) is cherry-picked with "proposed" replaced by the ruling, plus a "Pinned:" line and a note on F6.

F8: a direct leaf read of a lane node in flight is a stale reader, held or not (c4b4383)

  • Shape (fuzzer latest S2 "mount control"). An action re-asks a lane derivation that nobody had observed, so the seam left the lane unheld. A mainline mount whose child reads the derivation directly published its control. The child entered the lane and waited on the flight, so control and content disagreed.
  • Cause. laneRead's stale-reader arm (core/lanes.ts) was gated on l._held.
  • Rule. The A15 lanes corollary (Existing and newly revealed latest readers display different values #3460), plus the ruling: "a stale reader of the lane whether or not the lane is held at that moment".
  • Fix. The arm also takes a non-guess read of a member whose own flight is up (l._held || (!guess && status & STATUS_PENDING)). Removing the gate outright livelocks F7b and F12; the narrowed form does not.
  • Pins.
  • Size. About +9 B minified.

Adopted staging: an on-screen reader whose removal an action adopted follows the lane's work (acefaa5)

  • Shape (fuzzer latest, ex-"R-oracle"). An action writes source = 1. The same tick withdraws the mount around a render effect E that reads m = [latest(source)] (adopted, O1).
    • The lane recomputes m and dirties E. E was not a lane member, so laneDirty left it unmarked.
    • E became a zombie later in the same flush, _update deferred it, and the park cancelled its pass.
    • Result: E kept [0], while a sibling reader of m showed [1].
  • Rule. The ruling (reading A), A15 Pending removal releases latest while its async reader is still visible #3463 (a zombie is live for every hold until the commit that disposes it) and A28 (1).
  • Fix. laneDirty (core/scheduler.ts) also marks a render effect on no lane.
    • The mark is inert for that effect's pass, because recompute seats a lane only from the node's own _x.
    • If the effect becomes a zombie, the mark makes its zombie pass a live write, which _update runs now. That is the existing rule for the lane's own dirty members.
    • Memos stay unmarked: marking them broke the Reading a visible optimistic value in Show holds an independent signal update #3698 lane-frame child-memo pins.
  • Pin. New (none existed): "adopted staging: …".
  • Size. +4 B minified.

F6: STOPPED (needs a ruling on A18 (c))

The preferred one-flag shape works for the F6 case:

  • Shape of the fix. forward(b, tree) in boundaries.ts sets REACTIVE_FRAME_READ on the boundary's output when it forwards content.
  • Result. The lane holds through the boundary: [0,0], then [1,1] at the landing.

It is not committed, for two reasons:

  1. It flips Tier-A pins. These are spec-async-semantics.test.ts A18 "differing arrival…" and "sync wrapper…", and the store/lane-authority-twins 2.0.0-rc.7 async downstream of optimistic delays reveal of final result #3331 twin.
    • In each, the boundary-as-frame-reader holds a lane that the transaction dissolves before it is ever shown, and A18 (c) expects that lane to reveal.
    • Related: on next, the no-boundary analogue already gives the untracked read 0 where A18 (c) expects 3. The cause is supersede's if (l._shown) n._value = n._x!._lane.
    • The question for the maintainer: does A18 (c)'s reveal apply to a never-shown lane that is dissolved while a boundary frame-reader holds it?
  2. It is over budget. It costs +27 to +30 B minified on the boundary apps, against a ≤ 20 B budget.

Per the instruction, I did not fall back to a blockedBy walk. The patch is kept locally to apply once A18 (c) is ruled.

Verification

  • Seed 91501 latest case 827 is not fixed. It is unchanged before and after this PR, and it still fails with the F6 patch applied, so F6's fix does not cover it.
    • The minimized form is a different shape: an action starts, then a reader of [latest(-2), node0, latest(-2)] mounts with no boundary.
    • It gives S2 "mount control on, child absent" at the mount.
    • A hand translation does not reproduce the absent child. Instead it shows the torn [1, 0, 1] (the latest() reads are ahead of the committed node0) on both next and this branch.
    • It needs its own look.
  • Optimistic store update is hidden after a local derived-store write inside an action #3796 is not fixed. It reproduces identically on next and on this branch: with a derived store plus a local write, the optimistic view shows Old / saving: false while the action is pending. No Fixes.
  • The side finding (mount control on, no child, after withdraw and restore) is still present.

Interaction with F5, #3766 and #3794

The adopted-staging mark doesn't make render effects lane members: passLane stays null, and only the zombie-deferral decision in _update reads the flag. F5's settle walk (asyncWrite → settlePendingSource → enqueueSub) is untouched, so this neither fixes nor widens F5. The optimistic-readiness cohort is unchanged (0 and 2 findings, the same as before), and the F5 pin is still it.fails with no crash.

Fuzzer gate (1000 cases per run, --shrink)

Seed latest readiness derived-readiness optimistic-readiness ordinary
3289 before → after 90 → 89 0 → 0 0 → 0 0 → 0 1 → 1
91501 before → after 78 → 77 0 → 0 0 → 0 2 → 2 0 → 0

Suites

  • @solidjs/signals: 263 files, 4937 passed, 7 expected fail.
  • solid-js: 819 passed.
  • @solidjs/web: 1139 passed, 1 expected fail (the same as next).
  • rules-index --check: clean.

Public API changes

None.

Size

Size-Exception: the lane-membership ruling (F8 + adopted staging, +13 B minified) puts two scenarios over their caps by brotli layout — app: hydrating + every store primitive family 28.84 -> 28.87 KB and app: CSR, observe tier + attribution engine enabled 28.62 -> 28.66 KB (CI-measured + 10 B). Accepted by the maintainer (2026-10-05).

Measured locally against origin/next (b07fed5), as brotli / minified bytes. Local brotli varies by ±30 to 60 B between builds. The CI check job reproduced these numbers exactly, including both overages and the 9846 B br hello world.

Scenario next this PR Δ Cap
app: render + one signal (the simple-app floor) 9848 / 27653 9846 / 27657 −2 / +4 9.86 KB
signals: core floor 7338 / 20099 7349 / 20103 +11 / +4 7.35 KB
signals: + isPending/latest 9476 / 26772 9486 / 26785 +10 / +13 9.49 KB
app: hydrating + every store primitive family 28829 / 91443 28857 / 91456 +28 / +13 28.84 → 28.87 KB (was over by 17 B br)
app: CSR, observe tier + attribution engine enabled 28611 / 86385 28642 / 86389 +31 / +4 28.62 → 28.66 KB (was over by 22 B br)
page: live server components 48495 / 157404 48481 / 157417 −14 / +13 48.51 KB

Per fix: F8 is +9 B minified and adopted staging is +4 B minified, both within the ≤ 20 B budget. CI reported the same two brotli overages (17 B and 22 B). The maintainer approved raising both caps to CI-measured + 10 B (dated notes in scripts/size/scenarios.js).

Re-pins

None in the committed fixes; no existing pin changed expectation. The F6 Tier-A flips listed above are the reason F6 stopped, and were not applied.

ryansolid and others added 4 commits October 5, 2026 12:02
…aging)

The 2026-10-05 paragraph under the L2 section, ruled by the maintainer
(reading A for all three): a render effect off a lane that reads a lane
node directly is a stale reader of the lane whether or not the lane is
held (F8); a leaf reading through a boundary showing content is the
lane's frame reader through it (F6, A33); an on-screen reader whose
removal an adopted write stages follows the lane's work (adopted
staging). Derivations between the lane node and the leaf stay born held
under A29 (#3761). RULES-INDEX regenerated (line shifts only).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…r of the lane, held or not (fuzzer F8)

A mainline mount over a lane derivation whose re-ask nobody had observed
(the seam left the lane unheld) published its mount control while the
child entered the lane and waited on the flight: control and content
disagreed (fuzzer S2 "mount control"). `laneRead`'s stale-reader arm was
gated on `l._held`; it now also takes a render effect off the lane
reading a member whose own flight is up. The child shows the screen (the
lane's revealed derivation) beside its control, and re-derives at the
reveal.

Rule: A15 lanes corollary (#3460) and the lane-membership ruling
(2026-10-05, reading A): "a stale reader of the lane whether or not the
lane is held at that moment". Through a memo the mount stays born held
(A29, #3761) — pinned beside it.

Removing the gate outright livelocks the verdict lanes (F7b, F12: an
unheld, unblocked verdict lane re-queues its stale readers at every
reveal); the arm is widened only to a member with a flight up.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…lows the lane's work (fuzzer adopted staging)

An action writes `source = 1`; the same tick withdraws the mount around a
render effect `E` that reads `m = [latest(source)]` (adopted, O1). `E` is
a zombie of the action's frame, still on screen. The lane recomputes `m`
and dirties `E`, but `E` was not a lane member, so `laneDirty` left it
unmarked; it became a zombie later in the same flush, `_update` deferred
it, and the park cancelled its pass. `E` kept `[0]` while a sibling
reader of `m` showed `[1]` — the direct `latest()` twin already re-ran by
#3444's verdict-reader exception.

`laneDirty` now also marks a render effect on no lane. The mark is inert
for its pass (`recompute` seats a lane only from the node's own `_x`), and
makes its zombie pass the lane's live write, which `_update` runs now —
the existing rule for the lane's own dirty members (#3463). Memos stay
unmarked: a lane frame's child memo reading the guess keeps the #3662
cancellation (#3698 pins).

Rule: the lane-membership ruling (2026-10-05, reading A): an on-screen
render effect whose removal an adopted write has staged follows the
lane's re-derivation of a memo it reads; A15 #3463 (a zombie is live for
every hold until the commit that disposes it); A28 (1). New pin (none
existed): `fuzz-findings-l2.test.ts` "adopted staging".

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… A18 (c)

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 34157ea

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch
todos-server-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37374274558

Warning

No base build found for commit 01e80a6 on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 75.991%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.97 hits per line

💛 - Coveralls

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.35 KB +11 B (+0.1%) 7.35 KB ✅
signals: + createStore 14.56 KB +14 B (+0.1%) 14.56 KB ✅
signals: + isPending/latest 9.49 KB +10 B (+0.1%) 9.49 KB ✅
app: render + one signal (the simple-app floor) 9.85 KB −2 B (−0.0%) 9.86 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.67 KB −24 B (−0.1%) 17.71 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.86 KB +28 B (+0.1%) 28.87 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.83 KB −17 B (−0.1%) 12.86 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.39 KB −58 B (−0.4%) 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.64 KB +31 B (+0.1%) 28.66 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.80 KB +11 B (+0.0%) 44.84 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 48.48 KB −14 B (−0.0%) 48.51 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

… scenarios (Size-Exception)

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 0c550fd into next Oct 5, 2026
5 of 6 checks passed
ryansolid added a commit that referenced this pull request Oct 5, 2026
The check job measured the merge commit onto next @ a8c98bd, where
#3806 had added 4 B minified to the signals scheduler since the local
measurement at b07fed5; the compiled floor landed 3 B over. The caps
follow the repo's rule — CI head + 10 B, rounded up to 0.01 KB: floor
10,033 -> 10.05 KB, CSR 25,070 -> 25.08 KB, hydrating 30,899 -> 30.91 KB
(unchanged). The compiled app's own bytes are identical on CI and
locally (app=229 / 2,575 / 3,058); the delta is the runtime's.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Oct 6, 2026
…do app CSR + hydrating) (#3810)

* chore(size): bundle.mjs compiles JSX scenarios with the measured checkout's @solidjs/compiler

A scenario with `compile` is JSX under scripts/size/fixtures/, compiled at
measure time by the native compiler of the checkout being measured
(packagesRoot/compiler/index.js — the compare job's base builds its own, so
a compiler-only change moves the compiled scenarios on the comparison) in
client DOM mode with the production posture the Vite plugin uses
(`generate: "dom"`, `dev: false`, `hydratable` from the scenario). Every
`.jsx` module the entry reaches is compiled and handed to Rolldown as plain
JS; the compiler sees only each file's basename, so the output is the same
on every host. Modules under fixtures/ report as the `app` package, so the
compiled app's own bytes are separable from the runtime's. No scenario uses
it yet; existing scenarios are byte-identical.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* chore(size): compiled-template scenarios — a compiled floor and a JSX todo app, CSR and hydrating

Three scenarios the hand-written `app:` fixtures could not stand in for
(solid-web-size-audit §1.3, §2.4): none of them compiles a template, so the
DOM attribute runtime and the hydratable walk helpers were never on the
gate. fixtures/compiled/ holds a one-button floor (one template, one text
hole, one delegated click) and a todo app with an element spread,
merge/omit, a component spread (the compiler's mergeProps), delegated and
direct events, class/style objects, a keyed <For>, <Show>, <Loading> around
a lazy() child and a store, entered through render() and through hydrate().

Measured on next @ b07fed5 (identical on Node 24 and Node 26):
  compiled floor      28,184 min / 10,019 br   cap 10.03 KB
  compiled CSR        78,803 min / 25,128 br   cap 25.14 KB  (lazy stats.js 182 br)
  compiled hydrating  99,064 min / 30,898 br   cap 30.91 KB  (lazy stats.js 195 br)
Caps at measured + 10 B rounded up to 0.01 KB, inline (not frozen floors
yet). The props machinery — spread + mergeProps + merge/omit — retains
14,751 B min / 3,918 B br of the CSR scenario; the same app on signals
alone (no store) measures 54,467 / 18,097 against the audit's 51,898 /
17,498 for its Babel-compiled fixture.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* chore(size): size.mjs and attribute.mjs keep the first positional filter

Without `--json` (or `--min`), the flag's index is -1 and
`i !== jsonAt + 1` dropped the first positional argument, so
`node size.mjs compiled` ran every scenario and `node attribute.mjs name`
attributed all of them. Only exclude the value argument when the flag is
present. CI always passes `--json`, so the gate was unaffected.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* chore(size): compiled-scenario caps at CI-measured + 10 B

The check job measured the merge commit onto next @ a8c98bd, where
#3806 had added 4 B minified to the signals scheduler since the local
measurement at b07fed5; the compiled floor landed 3 B over. The caps
follow the repo's rule — CI head + 10 B, rounded up to 0.01 KB: floor
10,033 -> 10.05 KB, CSR 25,070 -> 25.08 KB, hydrating 30,899 -> 30.91 KB
(unchanged). The compiled app's own bytes are identical on CI and
locally (app=229 / 2,575 / 3,058); the delta is the runtime's.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

---------

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants