Skip to content

fix(signals): a write is a proposal (A34) — held-node writes entangle the tick, net-equal writes propose nothing (#3494) - #3519

Merged
ryansolid merged 2 commits into
nextfrom
audit/gabbev-3494
Sep 17, 2026
Merged

ryansolid merged 2 commits into
nextfrom
audit/gabbev-3494

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3494.

Ruling (A34 — new)

A write to the same signal already set to that value would entangle, I think. Unless it's committed, both are suggesting a value. If one finished before the other that would be odd.

  • A signal committed at v: writing v again is nothing — stages nothing, stamps nothing, pends nothing.
  • A signal held (staged, uncommitted, in transaction T): any mainline write to it — v again or w — is a second proposal on a contested node. It joins T, and so does the rest of that tick's batch.

This reverses one mechanism choice from #3473, which removed setSignal's eager entry from mainline to stop the activeTransition leak (a memo created after the write was born the transaction's, A29) — and dropped the grouping with it. The entry is now deferred: setSignal pushes the held node's transaction to batchJoins; flush() enters each at its start, inside the running flush, adopting the tick's ambient batch. Recorded before the equality gate, so a repeat of the held value proposes too.

The four #3473 regressions (all pinned in tests/write-proposals-3494.test.ts)

case cause fix
torn [1,0,1] effect input the tick's batch no longer grouped with the hold deferred entry (batchJoins)
lost hide after setShow(false); setShow(true) show stamped into count's hold with nothing to reveal; next setShow(false) routed to the stamp and held no-proposal drop in the adoption loop + computePendingState equality gate
stale visible derivation (Count: 1 beside Copy: 0) a stale reader served a held memo's committed value never turned pending itself, so liveness saw no trace of the flight reporterBlocksSource follows a dep's _pendingSources one hop
delayed release after mainline latest(details) the latest() shadow, backfilled under the transaction, blocked the settle transitionBlocked skips companions

From differential fuzzing (gabbev's fuzzer, #3446)

4 cohorts × 2 seeds × 5,000 cases, base = origin/next. 124 fixed / 7 new. branches 30→5 and 26→3; optimistic 0/0 both sides.

Two regressions the first cut introduced, both fixed and pinned:

  • S3 (latest-2 Support Noscript Tags #1470): the no-proposal drop hit a stamped node when a parked batch folded into a merge — a held proposal rewritten to the committed value is held, not proposal-free. Drop applies to _transition === null only.
  • O2 (latest-1 this inside jsx-template inside a constructor of a derived class causes transformation-bug #2141): a hide joined to a parked action stopped reading a memo, unchanged, so A30 kept its dep tail; the action's truth re-asked the memo and the pending mark rode the kept link, registering the hidden reader as a reporter on a fetch nobody displays. A30 amended: a pending mark rides only the links a pass made — notifyStatus skips STATUS_PENDING over links outside the pass's validated prefix (link._gen !== sub._depGen, the stamp link() already maintains; O(1)). Clears and errors still ride every link. (Gating reporterBlocksSource instead was wrong — it runs mid-pass — and the fuzzer caught it as an S1 flood.)

The 7 remaining "new":

Size

Core floor +175 B minified (25,249 → 25,424; per-piece: batchJoins 71, drop 64, _gen skip 24, one-hop 16). Brotli vs a fresh next build: +56 core, +75 store, +70 isPending/latest, +45 simple app, +80 hydrating, +36 hydrating+stores, +65 CSR, +93 observe, +40 observe+attribution, 0 frames. Caps ratcheted with notes. A golf pass replaced an O(kept-tail) link walk with the existing generation stamp and dropped a redundant companion snap; the fuzzer differential re-run on the golfed tree is case-for-case identical.

Docs

A34 new; A15, A28, A30 amended; INTERNALS updated (batchJoins, no-proposal drop, one-hop liveness, companion skip, pending-mark prefix rule); rules index regenerated; changeset.


Claude via Cursor

@coveralls

coveralls commented Sep 17, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35282766260

Coverage remained the same at 71.304%

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: 1035
Covered Lines: 783
Line Coverage: 75.65%
Relevant Branches: 798
Covered Branches: 524
Branch Coverage: 65.66%
Branches in Coverage %: Yes
Coverage Strength: 15.07 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing audit/gabbev-3494 (f5df1d2) with next (152ff7e)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid

Copy link
Copy Markdown
Member Author

I rebased this against current next and found blockers that should go back through the async-semantics pass before merge. Ryan has now made the held-to-committed ruling explicit: if a node is committed at 0, a transaction holds 1, and mainline writes 0, that write still contends with the future and entangles. The six O1 fuzzer cases are therefore accepted consequences of A34, not a carve-out.

The blocking implementation issue is the A30 _gen gate in notifyStatus. It cannot distinguish an obsolete retained tail from a retained dependency that still belongs to the displayed committed frame. I confirmed this dynamically with: committed selected = remote() = 0; a held pass switches selected to constant 0 and stops reading remote; then query = 1 starts a new remote flight. The PR publishes query=1, selected=0 and leaves isPending(selected) === false while the flight remains unresolved. Removing only the _gen early return holds the view and reports pending. Current output was pending: [false, false], views: [[0, 0, false], [1, 0, false]]; without the gate it was pending: [false, false, true], views: [[0, 0, false]]. I recommend removing this A30 amendment from #3519 and tracking the #2141 stale-tail case separately unless we add explicit committed-frame provenance.

Additional issues found during the rebase:

  • A same-value held write pushes batchJoins and returns through the equality gate without scheduling. The join then leaks into a later tick and captures unrelated work. The record site must schedule; the full flush should also be forced while joins remain, including chased-dead references.
  • The original join drain runs before GlobalQueue.flush() enters its try, while adoption invokes user _equals. A throw leaves _running = true. Moving the drain inside try prevents that permanent wedge, but the unowned scheduler-time comparator call and partially adopted state still need a deliberate error-path design.
  • The no-proposal drop excludes every node with _fn, but public writable memos from createSignal(fn) use setMemosetSignal; their same-tick writes back to the committed value therefore remain staged/stamped. This needs parity coverage or an explicit semantic carve-out.
  • The rebase needs a regenerated RULES-INDEX.md and fresh size measurements after these corrections.

I have local reproductions for the join leak and A30 tear, but pushed no commits. Handing this back for the semantics-aware revision.

GPT-5.6 Sol via Cursor

ryansolid added a commit that referenced this pull request Sep 17, 2026
…schedules; drain inside try; writable-memo parity

A30: a pending mark over a kept-tail link (`link._gen !== sub._depGen`) now
re-derives the subscriber instead of skipping (the pushed amendment) or
marking it (base). Skipped, the committed frame published stale beside its
new inputs — `query=1` beside a `selected` derived from `remote(0)` (the
review's tear). Marked, the node registered as reporter of a flight its held
frame never reads and entangled its holder with it — the #2141 orphaned
fetch, and fuzzer branches-1 #1105 (a gated reader hidden forever on a
manual flight nobody awaited). Re-derived, the pass decides: reads the dep
and registers through its own read; reads a held input and enters that
transaction (A29 — what holds the review's case); or reads neither.

A34 mechanics: setSignal schedules when it records a join (a same-value
repeat leaves through the equality gate; left for a later flush the join
adopted an unrelated tick), and the fast sync path defers while a join
waits; the drain runs inside flush's try so a throwing comparator cannot
leave `_running` set; the no-proposal drop covers an initialized writable
memo (`createSignal(fn)`, REACTIVE_MANUAL_WRITE) as it covers a signal, and
unstages through commitPendingNode.

Pins for all four in tests/write-proposals-3494.test.ts. Differential fuzz
vs next d60ec6f (4 cohorts x 2 seeds x 5,000): 124 fixed / 7 new, the
same set as before the review. Core floor 25,262 -> 25,504 minified; caps
ratcheted with notes; rules index regenerated.

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

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5df1d2

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

@ryansolid

Copy link
Copy Markdown
Member Author

Thanks — all four are addressed in d26d979, rebased on current next (d60ec6f). Pins for each in tests/write-proposals-3494.test.ts.

A30 gate tear. Confirmed your repro: the _gen skip publishes 1 0 at 1500. But I didn't remove the amendment — I bisected with the fuzzer and the three options are genuinely distinct:

One note on your expected output: after the fix the view holds, but isPending(selected) reads false rather than true. I believe that's the correct A19 verdict — selected's observable value is 0 and stays 0 under either frame (the held one is the constant; the committed one only re-derives under a commit that publishes the held frame instead). The true you saw without the gate was the spurious reporter registration. Pinned with that reasoning; happy to hear if you read A19 differently.

Join leak. The record site schedules, and flush()'s fast sync path defers while batchJoins is non-empty so the join drains in its own tick. Pinned: a lone same-value write to a held node no longer captures the next unrelated tick.

Drain inside try. Moved. On the broader point — a throwing _equals at scheduler time: with the drain inside the guard it now behaves as a comparator throw anywhere else in this codebase does (setSignal calls _equals at the write with no boundary routing either): the exception escapes flush(), _running is cleared, and the partially adopted batch is left as a partially applied tick. No wedge, but no boundary either. A deliberate error contract for scheduler-time user callbacks is real and I'd rather track it separately than design it inside this PR.

Writable-memo parity. The drop now admits an initialized REACTIVE_MANUAL_WRITE computed (the staged value is a write, not a pass's result — a born-held first pass can equal an uninitialized undefined, which is why plain memos stay excluded) and unstages through commitPendingNode so the flag and companions clean up as a commit would. Pinned. Ruling from Ryan: keep parity rather than a carve-out — the two createSignal forms shouldn't diverge on a rule about writes.

Rebase hygiene. Rules index regenerated; sizes re-measured against next d60ec6f: core floor 25,262 → 25,504 minified (+242; +175 is A34 itself, ~+67 the four fixes), brotli scenarios +43..+118 B, caps ratcheted with notes.

Fuzz. Differential vs next d60ec6f, 4 cohorts × 2 seeds × 5,000: 124 fixed / 7 new — the same 7 as before the review (the A34 O1 family you accepted plus the pre-existing mounts-1 P1). A refined gate I tried first (skip only when sub._transition === activeTransition) fixed the tear but re-broke the four branches cases above; re-derive holds both.

Claude via Cursor

ryansolid and others added 2 commits September 17, 2026 15:29
… the tick, net-equal writes propose nothing (#3494)

A mainline write to a node a transaction holds — the same value again or
another — is a second proposal for the same slot: the writer's tick joins the
hold at the next flush's start (`batchJoins`, drained inside the running flush
so nothing between the write and the flush is born the transaction's — the
proposed nothing: the adoption loop unstages an unstamped signal staged at its
committed value, and computePendingState reads the same equality as final.

Fixes the four #3473 regressions from #3494: the torn `[1,0,1]` effect input,
the lost hide after a coalesced toggle, the stale visible derivation (a stale
reader served a held memo's committed value counts as observing the memo's
flight — reporterBlocksSource follows a dep's `_pendingSources` one hop), and
the delayed release after a mainline `latest()` (a companion never blocks the
settle — transitionBlocked skips `_parentSource` nodes).

From differential fuzzing of the ruling (4 cohorts x 2 seeds x 5,000; 124
fixed / 7 new, the new ones one O1 family that follows from the rule plus a
pre-existing P1): a pending mark rides only the links a pass made (A30
amendment — notifyStatus skips STATUS_PENDING over links outside the pass's
validated prefix, `link._gen !== sub._depGen`, O(1)); and the no-proposal drop
applies to unstamped nodes only (a held proposal rewritten to the committed
value stays its transaction's).

Spec: A34 new; A15/A28/A30 amended. Size: +175 B minified core floor,
+36..+93 B brotli across scenarios; caps ratcheted with notes.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…schedules; drain inside try; writable-memo parity

A30: a pending mark over a kept-tail link (`link._gen !== sub._depGen`) now
re-derives the subscriber instead of skipping (the pushed amendment) or
marking it (base). Skipped, the committed frame published stale beside its
new inputs — `query=1` beside a `selected` derived from `remote(0)` (the
review's tear). Marked, the node registered as reporter of a flight its held
frame never reads and entangled its holder with it — the #2141 orphaned
fetch, and fuzzer branches-1 #1105 (a gated reader hidden forever on a
manual flight nobody awaited). Re-derived, the pass decides: reads the dep
and registers through its own read; reads a held input and enters that
transaction (A29 — what holds the review's case); or reads neither.

A34 mechanics: setSignal schedules when it records a join (a same-value
repeat leaves through the equality gate; left for a later flush the join
adopted an unrelated tick), and the fast sync path defers while a join
waits; the drain runs inside flush's try so a throwing comparator cannot
leave `_running` set; the no-proposal drop covers an initialized writable
memo (`createSignal(fn)`, REACTIVE_MANUAL_WRITE) as it covers a signal, and
unstages through commitPendingNode.

Pins for all four in tests/write-proposals-3494.test.ts. Differential fuzz
vs next d60ec6f (4 cohorts x 2 seeds x 5,000): 124 fixed / 7 new, the
same set as before the review. Core floor 25,262 -> 25,504 minified; caps
ratcheted with notes; rules index regenerated.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit dd908fc into next Sep 17, 2026
7 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