Skip to content

fix(signals): five hold-consistency fixes — re-park sweep, first observer, lane outside view, zombie readers, unchanged-pass trims - #3479

Merged
ryansolid merged 3 commits into
nextfrom
audit/gabbev-batch2
Sep 16, 2026
Merged

ryansolid merged 3 commits into
nextfrom
audit/gabbev-batch2

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 15, 2026

Copy link
Copy Markdown
Member

Batch 2 of @GabbeV's hold-consistency reports, audited against next after #3473. Each reproduced at the core level and pinned; every issue pin fails on the base commit (verified), the twin pins (existing behavior) pass on both.

Closes #3456, closes #3458, closes #3460, closes #3463, closes #3469.

Ruling applied (#3460)

Maintainer: "a held lane is basically a micro transition from the outside… we wouldn't hold a sync write on a transition. Lanes are the same." A render effect off a held lane is a stale reader of it: it sees the committed value (which is what is on screen — the lane defers its own readers' runs), publishes now, entangles nothing, and re-runs at the release. latest() is not special — same rule for a createOptimistic source. Inside, a reader on the lane sees the lane's values and the parent transaction's landings as before.

Fixes

Issue Rule Mechanism
#3456 conditional stays hidden after its async branch is cancelled pending-source model recompute's catch settles every source the earlier pass carried that the re-parked set no longer contains (the re-park twin of the unchanged-value recovery sweep)
#3458 revealing a pending memo shows its old value beside updated inputs A15 reveal corollary, first-observer arm heldFromStale: when the transaction has no entry for the flight, the reader (its first observer) notifies the pending status up its queue chain under the transaction — INV-3, the one registration site
#3460 existing and newly revealed latest readers disagree A15 lanes corollary, A17 readsHeldCommitted (lanes.ts) from read()'s override arm (CONFIG_HAS_LANE-gated hook) and latestRead; the release re-run rides the lane's own render queue
#3463 pending removal releases latest while its reader is visible A15 reporterBlocksSource(reporter, source, verdict): a zombie is live until the commit that disposes it; moot only for the verdict of the transaction staging its removal. sourceObserved keeps a zombie the verdict passed over
#3469 same-value branch switch leaves a memo inconsistent A30 heldTrims: an unchanged pass's dep trim waits on the flush's verdict (drained at commit, dropped on park); creation / OPT-dirty / tracked-effect passes trim at once

Semantics check

  • Same-value branch switch leaves a synchronous memo inconsistent with its inputs #3469's memo arm resolves to the held outcome (A: 2 reveals with B: 1) — the memo is served the staged b and enters the hold (A29). This is the pre-existing sum = a() + b() behavior on next, not a new shift; the report accepts either outcome. The render-effect arm follows its inputs at once (Selected: 2 on the write, 1 at the commit).
  • The body-end supersession pin (A18) is unaffected: a demoted lane is not held, so the override is the visible value.
  • No existing test expectation changed.

Docs

A15 (lanes corollary extended; first observer; zombie readers), A17 (every render effect off the lane sees the committed view), A30 (#3469 paragraph); INTERNALS §2/§3 bullets; RULES-INDEX regenerated.

Size

Core floor 24,478 → 24,873 (+395; ceiling bumped to 25,000 with the seams listed in treeshake.test.ts). All five are core-retained; the #3460 rule itself lives in lanes.ts and sheds with the engine. One golf pass applied (verdict passed through as the transaction, no ternaries).

Tests

signals 190/190 files, solid 28/28, web 93/93 against the rebuilt dist/.

New pins: tests/pending-source-repark.test.ts, tests/first-observer-stale-reader.test.ts, tests/lane-outside-view.test.ts, tests/held-frame-dependencies.test.ts.

Review follow-up: lanes stage (third commit)

@GabbeV's differential fuzzing on the first commit found 1:0 — an optimistic value that never finished preparing revealed beside its old derivation — and, once that was fixed, a torn-tuple class in the latest cohort: an outsider mounted mid-hold saw the source's committed 0 beside a derivation's speculative 1. The #3460 rule needs the committed view to be a whole frame, and the lane mechanism couldn't serve one because lane members direct-committed their speculative results into _value (INV-11's "lane direct commit").

Ruling: an optimistic derivation is an override. A lane pass on a memo now publishes into the override slot as a derived override (laneOverride, CONFIG_DERIVED_OVERRIDE); _value stays committed. The lane's own readers and direct reads see the optimistic frame, a reader off the lane sees the committed one, neither torn. The revert promotes a derived override the truth confirmed (no re-ask waterfall after the reveal) and drops one it superseded. A derived override is a plain member everywhere a written override means intent (merging, pending propagation, body-end supersession, the authoritative-blockage census, the hold census).

Two corollaries the fuzzer forced: off the lane is provenance, not membership (a pass under the lane's own transaction — its async's landing re-entering it — is the lane's work), and every pass over a live lane member carrying a derived override is the lane's pass whatever channel dirtied it (a boundary reset re-ran a lane-born memo plainly; A18's sync twin took its re-derived lane view for a differing truth and demoted the lane — latest-1 #2481, the boundary published undefined).

Fuzzer (8 cohorts × 2 seeds × 5000, this tree vs next): latest S1 published-derivation disagreements 57→5 / 52→6, torn tuples to an effect 26→0; branches S1 13→2 / 16→2, required-reader L1 6→0 / 10→2; everything else flat. Six new O1 findings in latest are the fuzzer's model disagreeing with the #3460 ruling (a ready optimistic source not publishing to an outsider while the action is open) — that is the rule, not a regression.

Size: isPending/latest 11.90→12.05 KB, store app 29.60→29.75, hydrating 19.60→19.70 (brotli scattered a ~40 B branch ±50 B across scenarios). A17 and INV-11 amended; two new pins in lane-outside-view.test.ts, plus the #2481 one.

@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c66cf6a

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

This PR includes changesets to release 11 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

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 Sep 15, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35069760192

Coverage remained the same at 71.46%

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: 1018
Covered Lines: 772
Line Coverage: 75.83%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.94 hits per line

💛 - Coveralls

@GabbeV

GabbeV commented Sep 15, 2026

Copy link
Copy Markdown

AI-assisted differential fuzzing found a regression on 3fb38bfe compared with next at 1de80f40. Independently reproduced outside the fuzzer in development and production, including compiled JSX in jsdom.

An optimistic value that never finished preparing becomes visible when the action ends, alongside its old derived value.

import { render } from "@solidjs/web";
import { action, createMemo, createOptimistic, createSignal } from "solid-js";

const delay = <T = void,>(ms: number, value?: T) =>
  new Promise<T>(r => setTimeout(r, ms, value));

export default function App() {
  const [source, setSource] = createSignal(0);
  const [value, setValue] = createOptimistic(source);
  const fast = createMemo(async () => value());
  const details = createMemo(() => delay(1500, fast()));

  const save = action(function* () {
    setValue(1);
    yield delay(500);
    setSource(0);
  });

  return <>
    <button onClick={() => save()}>Run</button>
    <p>{`${value()}:${details()}`}</p>
  </>;
}

render(() => <App />, document.getElementById("root")!);

Wait for 0:0, then click:

  • next: stays at 0:0.
  • This PR: shows 1:0 when the action finishes, then returns to 0:0 when the corrective details resolve.

The optimistic 1:1 never committed. Its details are still pending when the action ends, so revealing only the optimistic input produces a mixed frame. This persists across tasks; it isn't just an intermediate state within a flush.

In a diagnostic build, making readsHeldCommitted() return false removes this regression. It also removes 75 of the 77 differential findings from the 95,000-case campaign, which suggests this path is a useful starting point. That disables both the committed-view selection and release re-enqueueing, so it doesn't yet distinguish which part needs adjusting.

This isn't a suggestion to remove the new outside-lane rule. The intended stale-reader behavior makes sense; the problematic consequence here is the extra reveal at action completion exposing an optimistic frame that never became ready.

@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 173 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
store reconcile tree shuffle: 1111 keyed nodes 24.4 ms 31.7 ms -23.23%
projection derive: write one NESTED field (reference) 2.8 ms 2.1 ms +37.58%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing audit/gabbev-batch2 (c66cf6a) with next (28c65be)

Open in CodSpeed

ryansolid added a commit that referenced this pull request Sep 15, 2026
…et size caps (#3479)

`read()`'s override arm now makes one engine call, `GlobalQueue._overrideRead(el, c)`,
gated on `CONFIG_HAS_LANE | CONFIG_OVERRIDE_SUPERSEDED` (either implies the engine):
the lane outside-view rule (#3460, `readsHeldCommitted`) and the A18 supersession
selection (`supersededRead`, #3331) are its two arms, in optimistic.ts. Replaces the
separate `_readsHeldCommitted` check and `_supersededRead` slot. -37 B minified in the
core floor (24,873 -> 24,836); brotli-neutral.

Golf measured against the size-limit harness: terser source for sourceObserved /
heldFromStale / the zombie walk compressed WORSE (core floor 9,253 -> 9,301 B brotli)
and was dropped. The remaining +100–135 B brotli per entry is the five mechanisms;
caps ratcheted with notes: core 9.15 -> 9.35, createStore 16.20 -> 16.45,
isPending/latest 11.70 -> 11.90, app floor 11.90 -> 12.05, hydrating 19.40 -> 19.60,
hydrating+stores 29.35 -> 29.60, CSR 14.80 -> 15.00, observe 16.40 -> 16.55,
observe+attribution 28.00 -> 28.20 KB.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 16, 2026
…et size caps (#3479)

`read()`'s override arm now makes one engine call, `GlobalQueue._overrideRead(el, c)`,
gated on `CONFIG_HAS_LANE | CONFIG_OVERRIDE_SUPERSEDED` (either implies the engine):
the lane outside-view rule (#3460, `readsHeldCommitted`) and the A18 supersession
selection (`supersededRead`, #3331) are its two arms, in optimistic.ts. Replaces the
separate `_readsHeldCommitted` check and `_supersededRead` slot. -37 B minified in the
core floor (24,873 -> 24,836); brotli-neutral.

Golf measured against the size-limit harness: terser source for sourceObserved /
heldFromStale / the zombie walk compressed WORSE (core floor 9,253 -> 9,301 B brotli)
and was dropped. The remaining +100–135 B brotli per entry is the five mechanisms;
caps ratcheted with notes: core 9.15 -> 9.35, createStore 16.20 -> 16.45,
isPending/latest 11.70 -> 11.90, app floor 11.90 -> 12.05, hydrating 19.40 -> 19.60,
hydrating+stores 29.35 -> 29.60, CSR 14.80 -> 15.00, observe 16.40 -> 16.55,
observe+attribution 28.00 -> 28.20 KB.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 16, 2026
…3479 review)

A lane pass on a memo publishes its speculative result into the override
slot (`laneOverride`, CONFIG_DERIVED_OVERRIDE) instead of direct-committing
`_value`, so the committed view a reader off the lane is served (#3460) is a
whole frame: the held source and its derivations together, never a committed
shadow beside a speculative memo (gabbev's fuzzer, latest cohort: 57 -> 5
published-derivation disagreements, torn tuples to an effect 26 -> 0).

- Off the lane is provenance, not membership: a pass under the lane's own
  transaction (its async's landing re-entering it) is the lane's work — the
  `1:0` frame of an optimistic value that never finished preparing.
- The revert PROMOTES a derived override the truth confirmed instead of
  re-deriving (no re-ask waterfall after the reveal); a superseded one drops.
- A derived override is a plain member: merges lanes through itself, does
  not stop pending propagation, suspends its lane readers on its re-ask, is
  skipped by body-end supersession and the authoritative-blockage census,
  carries no `_overrideTime` or provenance stamp, is no acknowledgement to
  the hold census.
- Every pass over a live lane member carrying a derived override is the
  lane's pass, whatever channel dirtied it (a boundary reset re-running a
  lane-born memo read its re-derived lane view as a differing truth and
  demoted the lane; fuzzer latest-1 #2481). A fresh lane publish clears a
  supersession in force.

Pins in tests/lane-outside-view.test.ts; A17 and INV-11 amended; size caps
ratcheted (isPending/latest 11.90 -> 12.05, store app 29.60 -> 29.75,
hydrating 19.60 -> 19.70 KB).

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

Copy link
Copy Markdown
Member Author

Fixed in 3e80a25 — thank you for the fuzzer, it earned its keep twice here.

Your 1:0 frame: the landing of fast re-entered the lane's transaction with no ambient lane, and the #3460 outsider rule read that pass as a stale reader — it published the committed view and queued a replay that later revealed 1 beside the stale 0. Off the lane is now provenance, not membership: a pass under the lane's own transaction is the lane's work.

With that fixed, the latest cohort surfaced the deeper issue: an outsider mounted mid-hold saw latest(count)'s committed 0 beside a memo's speculative 1, because lane members direct-committed their results into _value. The committed view #3460 promises has to be a whole frame, so lanes now stage — a lane pass on a memo publishes a derived override, _value stays committed, and the revert promotes it when the truth confirmed it. Your cohort's S1 count went 57 → 5 on seed 1 and 52 → 6 on seed 2, torn tuples to an effect 26 → 0. The last one it found (latest-1 #2481, a boundary reset re-running a lane-born memo, publishing undefined) is fixed and pinned too.

Six new O1 findings in latest are the model disagreeing with the #3460 ruling itself — a ready optimistic source does not publish to a reader off the lane while the action is open. If you think the model has the better of that argument, say so; it is the ruling I'd want challenged, not the mechanism.

Claude via Cursor

ryansolid added a commit that referenced this pull request Sep 16, 2026
…3479 review)

A lane pass on a memo publishes its speculative result into the override
slot (`laneOverride`, CONFIG_DERIVED_OVERRIDE) instead of direct-committing
`_value`, so the committed view a reader off the lane is served (#3460) is a
whole frame: the held source and its derivations together, never a committed
shadow beside a speculative memo (gabbev's fuzzer, latest cohort: 57 -> 5
published-derivation disagreements, torn tuples to an effect 26 -> 0).

- Off the lane is provenance, not membership: a pass under the lane's own
  transaction (its async's landing re-entering it) is the lane's work — the
  `1:0` frame of an optimistic value that never finished preparing.
- The revert PROMOTES a derived override the truth confirmed instead of
  re-deriving (no re-ask waterfall after the reveal); a superseded one drops.
- A derived override is a plain member: merges lanes through itself, does
  not stop pending propagation, suspends its lane readers on its re-ask, is
  skipped by body-end supersession and the authoritative-blockage census,
  carries no `_overrideTime` or provenance stamp, is no acknowledgement to
  the hold census.
- Every pass over a live lane member carrying a derived override is the
  lane's pass, whatever channel dirtied it (a boundary reset re-running a
  lane-born memo read its re-derived lane view as a differing truth and
  demoted the lane; fuzzer latest-1 #2481). A fresh lane publish clears a
  supersession in force.

Pins in tests/lane-outside-view.test.ts; A17 and INV-11 amended; size caps
ratcheted (isPending/latest 11.90 -> 12.05, store app 29.60 -> 29.75,
hydrating 19.60 -> 19.70 KB).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 3 commits September 16, 2026 00:37
…rver, lane outside view, zombie readers, unchanged-pass trims (#3456 #3458 #3460 #3463 #3469)

Batch 2 of GabbeV's hold-consistency reports, audited against `next` after
base commit.

pass carried: `recompute`'s catch settles, against the node, every previously
carried source absent from the new set (the re-park twin of the unchanged-value
recovery sweep). A conditional whose async branch was cancelled no longer waits
forever on a flight it has no path to.

has an entry (#3374); when it has none the reader is the flight's first
observer, and the pending status is notified up the reader's queue chain under
the transaction (INV-3, the one registration site). The transaction now waits
on the flight it revealed a reader of.

transition from the outside… we wouldn't hold a sync write on a transition.
Lanes are the same"). `readsHeldCommitted` (lanes.ts): a render effect OFF a
held lane reading an override or a `latest()` shadow is served the committed
value, publishes now, entangles nothing, and is queued on the lane's render
queue for a re-run at the release. Hooked from `read()`'s override arm
(`GlobalQueue._readsHeldCommitted`, `CONFIG_HAS_LANE`-gated) and `latestRead`,
replacing the another-lane-only branch. `latest()` is not a special case.

reporter is live for every hold until the commit that disposes it; walk its
owner chain to the transaction staging its removal, and count it moot only when
that transaction is the one being judged (or is done). `sourceObserved` keeps a
zombie the verdict passed over rather than pruning it.

know at its tail whether the flush will park. `heldTrims` defers the trim to
the flush's verdict: drained by `commitPendingNodes`, cleared on a park.
Creation, OPT-dirty and tracked-effect passes trim at once as before.

Docs: A15 (lanes corollary, first observer, zombie readers), A17 (every render
effect off the lane sees the committed view), A30 (#3469 paragraph); INTERNALS
§2/§3 bullets; RULES-INDEX regenerated. Core floor: 24,478 → 24,873 (+395,
conscious bump to 25,000, all five seams core-retained).

Pinned: tests/pending-source-repark, tests/first-observer-stale-reader,
tests/lane-outside-view, tests/held-frame-dependencies.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…et size caps (#3479)

`read()`'s override arm now makes one engine call, `GlobalQueue._overrideRead(el, c)`,
gated on `CONFIG_HAS_LANE | CONFIG_OVERRIDE_SUPERSEDED` (either implies the engine):
the lane outside-view rule (#3460, `readsHeldCommitted`) and the A18 supersession
selection (`supersededRead`, #3331) are its two arms, in optimistic.ts. Replaces the
separate `_readsHeldCommitted` check and `_supersededRead` slot. -37 B minified in the
core floor (24,873 -> 24,836); brotli-neutral.

Golf measured against the size-limit harness: terser source for sourceObserved /
heldFromStale / the zombie walk compressed WORSE (core floor 9,253 -> 9,301 B brotli)
and was dropped. The remaining +100–135 B brotli per entry is the five mechanisms;
caps ratcheted with notes: core 9.15 -> 9.35, createStore 16.20 -> 16.45,
isPending/latest 11.70 -> 11.90, app floor 11.90 -> 12.05, hydrating 19.40 -> 19.60,
hydrating+stores 29.35 -> 29.60, CSR 14.80 -> 15.00, observe 16.40 -> 16.55,
observe+attribution 28.00 -> 28.20 KB.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…3479 review)

A lane pass on a memo publishes its speculative result into the override
slot (`laneOverride`, CONFIG_DERIVED_OVERRIDE) instead of direct-committing
`_value`, so the committed view a reader off the lane is served (#3460) is a
whole frame: the held source and its derivations together, never a committed
shadow beside a speculative memo (gabbev's fuzzer, latest cohort: 57 -> 5
published-derivation disagreements, torn tuples to an effect 26 -> 0).

- Off the lane is provenance, not membership: a pass under the lane's own
  transaction (its async's landing re-entering it) is the lane's work — the
  `1:0` frame of an optimistic value that never finished preparing.
- The revert PROMOTES a derived override the truth confirmed instead of
  re-deriving (no re-ask waterfall after the reveal); a superseded one drops.
- A derived override is a plain member: merges lanes through itself, does
  not stop pending propagation, suspends its lane readers on its re-ask, is
  skipped by body-end supersession and the authoritative-blockage census,
  carries no `_overrideTime` or provenance stamp, is no acknowledgement to
  the hold census.
- Every pass over a live lane member carrying a derived override is the
  lane's pass, whatever channel dirtied it (a boundary reset re-running a
  lane-born memo read its re-derived lane view as a differing truth and
  demoted the lane; fuzzer latest-1 #2481). A fresh lane publish clears a
  supersession in force.

Pins in tests/lane-outside-view.test.ts; A17 and INV-11 amended; size caps
ratcheted (isPending/latest 11.90 -> 12.05, store app 29.60 -> 29.75,
hydrating 19.60 -> 19.70 KB).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@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.

3 participants