Skip to content

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

Open
ryansolid wants to merge 1 commit into
nextfrom
audit/gabbev-3494
Open

ryansolid wants to merge 1 commit 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

… 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
#3473 leak) and reveals with it. A tick whose writes net to the committed value
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>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35204286025

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.93 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 (fba237a) with next (1e7ebe9)

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

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