Skip to content

fix(signals): a staged read of a node born staged is not re-queued at the lane seam - #3882

Merged
ryansolid merged 1 commit into
nextfrom
fix/verdict-lane-flush-loop
Oct 7, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/verdict-lane-flush-loop

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes the RC fuzzer gate's 29 Potential Infinite Loop Detected errors (mount-under-hold, seeds 3289 / 91501), introduced by #3869.

Shape

An action holds x. <Show when={latest(x) > 0}> (gated through a memo) opens and mounts a new <Loading> whose content reads x directly, with no load of its own. The first flush after the action's write never settles.

Root cause

packages/signals/src/core/core.ts, stagedRead (called from serve), working with the lane seam in packages/signals/src/core/lanes.ts (GlobalQueue._laneSeams, the verdict-lane re-run that #3869 added: !blocked(l._parent)).

  • The boundary's output node is born into the parked action transaction. It is held, STATUS_UNINITIALIZED, and has no committed value.
  • The render effect that inserts the Show's children is verdict-lane work. It reads that node's staging. Since fix(signals): seat a first pass in its creator's lane, never a verdict lane (#3835, #3851) #3869, stagedRead lists every lane pass in stagedReaders, verdict lanes included.
  • At the seam the transaction is still blocked, so the effect is re-queued to "re-derive on the committed world".
  • No committed world exists for a node born staged. The re-run joins, reads the same staging, and lists itself again. The seam re-queues it every round until the 1e5 guard throws.

Fix

serve passes the node it read to stagedRead. A read of an uninitialized (born staged) node still sets REACTIVE_STAGED_READ, which the born-held clause and route depend on, but it is no longer listed for re-derivation. One condition at the existing decision site.

Frames

The flush settles and the commit reveals content 1. While held, the content shows content 1 beside the committed x = 0. That is exactly the pre-#3869 frame (the MH1 failure these cases already had at fa371c3e7). A29's "fresh Loading shows its fallback while held" frame is still open: fixing it would mean the boundary's output not being born held, which is a larger change. It is pinned as test.fails so it flips when fixed.

Tests

  • packages/signals/tests/verdict-mount-loading-flush-loop.test.ts covers case 119 and case 323 (the same shape plus a reader of x already on screen). The no-throw and commit-frame tests throw on next and pass here; the A29 fallback frame is test.fails for both.
  • Full suites: signals 280 files (5070 passed / 30 expected fail), solid 833 passed, web all projects green.

Fuzz evidence (rev 23, 030e77f2b, source-only copy, seeds 3289 / 91501)

cohort gate on bc51ada93 this PR
verdict: infinite-loop error 13 / 16 0 / 0
verdict: MH1 fail 102 / 115 115 / 131
lane 0 / 0 0 / 0
fresh 0 / 0 0 / 0
rearm-mount MH1 52 / 51 52 / 51
rearm-committed MH1 92 / 85 92 / 85
latest 80 / 76 80 / 76

Per-case diff across every cohort, both seeds: exactly 29 cases changed, all from error (infinite loop) to fail MH1, the status they had before #3869. Nothing else moved.

Size

+15 B minified wherever signals is bundled (under the 20 B allowance; gate.mjs passes). Brotli moves by layout noise.

scenario minified Δ brotli Δ
signals: core floor +15 +3
app: render + one signal (hello world / simple-app floor) +15 +3
app: compiled floor +15 +3
app: hydrating + every store primitive family +15 +15 (WARN over cap, within allowance)
app: compiled hydrating +15 +50 (WARN over cap, within allowance)
page: live server components +15 +32 (WARN over cap, within allowance)
page: compiled base server components +15 +26 (WARN over cap, within allowance)
page: live + router +15 +49 (WARN over cap, within allowance)
server scenarios, frames client 0 0

Public API changes

None.

… the lane seam

Since #3869, verdict-lane work that read a staging of the flush is
re-derived on the committed world while the frame stays parked
(`stagedReaders`, the lane seam). A node born into the parked frame has
no committed value: the re-run reads the same staging, registers again,
and the seam re-queues it every round until flush throws "Potential
Infinite Loop Detected" (semantic fuzzer, mount-under-hold: a
`latest()`-gated Show mounting a fresh Loading under an action hold,
29 cases across seeds 3289 and 91501).

`stagedRead` keeps REACTIVE_STAGED_READ for such a read but no longer
lists the pass for re-derivation. The frames return to pre-#3869's (the
content shows the held value while held; A29's fallback frame is still
open, pinned as test.fails).

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

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 641078d

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

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB +3 B (+0.0%) +15 B +15 B 7.45 KB ✅
signals: + createStore 14.67 KB +4 B (+0.0%) +15 B +15 B 14.68 KB ✅
signals: + isPending/latest 9.63 KB 0 B +15 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB +3 B (+0.0%) +15 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB +2 B (+0.0%) +15 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.19 KB +15 B (+0.1%) +15 B +15 B 29.19 KB ⚠️ over by 4 B, 5 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB −24 B (−0.2%) +15 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB +10 B (+0.1%) +15 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB −22 B (−0.1%) +15 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB +3 B (+0.0%) +15 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.18 KB −41 B (−0.2%) +15 B +15 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB +50 B (+0.2%) +15 B +15 B 31.12 KB ⚠️ over by 35 B, 5 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB +65 B (+0.2%) +15 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB +32 B (+0.1%) +15 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB +26 B (+0.1%) +15 B +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.17 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB +6 B (+0.0%) +15 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB −14 B (−0.0%) +15 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB +49 B (+0.1%) +15 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: hydrating + every store primitive family: over brotli cap by 4 B; minified 92,278 B vs 92,263 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 35 B; minified 99,632 B vs 99,617 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified 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). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37691137925

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.52 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing fix/verdict-lane-flush-loop (641078d) with next (bc51ada)

Open in CodSpeed

@ryansolid
ryansolid merged commit 3086f1b into next Oct 7, 2026
8 checks passed
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