fix(signals): five rule-decided fixes from the semantic fuzzer (F3, F4, F9, F10/F11, F13) - #3801
Conversation
… final write Brings the F13 pin from fuzz/semantic-fuzzer-l2 (f3d6342) onto next, beside #3798's flipped F7a/F7b/F12 pins: a `latest(source)` render effect whose mount is withdrawn in the action's tick and restored in the next is born held with its creation value; the body-end write re-runs it in the verdict lane, and the landing then applies the stale born-held staging over the lane's run. Pinned as `it.fails` (A29 creation-time form, A28). No engine changes. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…obe's window (fuzzer F10, F11) `verdictValue` links and pulls like a plain read, but it pulled with the window's dispatch still installed: `pullComputed(m)` → `recompute(m)` → `m`'s own reads went through `verdictValue` under the probe's posture. For a source staged this flush and not held, the unheld-staged arm answers the probe with the committed value — right for the probe, wrong for `m`, whose result is cached for every reader. So `m` cached the committed input and never got the flushed one (F10: a gated `isPending` reader revealed beside a write to the probed memo's source left the memo's plain reader on the old value for good), and a pulled memo over an uninitialized staged input read `undefined` instead of suspending (F11). A31: "a memo computes under its own lane posture, never its puller's"; A28: a write is visible at flush to every channel; A7/A19 exc. 1: an uninitialized input suspends. The pull now drops the dispatch for the nested pass and reinstalls it after, so the probe's own read of `m` answers the verdict for what `m` produced. `latest`/`isPending` restore the dispatch they found instead of re-deriving it from their flags, so a window opened inside the pulled pass closes back to none rather than to the puller's (pinned: "F10 (nested window)"). `setWindows` goes away. F10 and F11 flip from `it.fails` to `it`; the nested-window case is a new `it` (fails on next). No other pin moved. Size: +1 B minified on the isPending/latest scenario (+5 B brotli locally). The brief's optional hardening of the unheld-staged arm for STATUS_UNINITIALIZED was not added: with the pulled pass outside the window, no reachable path served `undefined` from it in probing, so there was no failing case to pin. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ently (fuzzer F9) A verdict reader holds no pending source of its own: when the source goes pending, `propagateStatus`'s verdict arm re-derives it (CONFIG_VERDICT), and it answers `isPending` true without inheriting the status. A flight that then lands equal to the committed value notifies nobody — `setSignal` skips `insertSubs` on an equal value — and the settle walk returned at `removePendingSource` for a node without the source, so the probe read `true` forever. Shape: `setSrc(1); setSrc(0)` re-asks an async memo for the committed input; a probe-only `isPending(m)` reader stays true after the re-ask lands. A19: "a node is pending while any cause holds it and final the moment none does". The settle walk's early return now re-derives a verdict reader it reaches — the source settling is the reader's verdict changing, the third transition beside `propagateStatus`'s pending and error arms. Folded into the walk's existing early-return condition, which keeps the minifier's single `if` (a separate branch split it and cost 25 B). F9 flips from `it.fails` to `it`. No other pin moved. Size: +16 B minified (core floor and every scenario carrying the settle walk). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…lue (fuzzer F13)
A `latest(source)` render effect whose mount is withdrawn in an action's
tick and restored in the next is born held: its creation value is staged
(`_pendingValue` 0) and its first run is the commit's (A29). The action's
final write re-runs it as the verdict lane's work, and `recompute`'s lane
arm wrote an effect's value into its private `_value` (1) — beside the
born-held staging. The lane's seam then committed the node:
`commitPendingNode` applied the stale staging (0) over the lane's run and
queued the effect's run with it, so the committed truth never showed.
A29 creation-time form ("staged into it, committed with it");
`recompute`'s own note: "an effect still carrying an uncommitted staged
value re-stages: the commit applies the latest pass, not the born-held
one". The lane arm now does the same for an effect carrying a staging; an
effect with none keeps writing its private slot for the lane's run.
F13 flips from `it.fails` to `it`. No other pin moved. Size: +13 B
minified (core floor).
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… (fuzzer F3) A reader mounted while a flight is up is born held (A29 ruling A); unmounting it in the same hold stages its removal, making it a zombie that observes another flight, and that observation blocks an unrelated write (`src=0`) in a newer transaction (A15 #3463: a zombie is live for every hold "until the commit that disposes it"). The seam judges transactions newest first: the `src=0` transaction is judged blocked, then the older hold lands (its mount control nets to committed) and its commits dispose the zombie — but nothing judges `src=0` again. The `schedule()` in `disposeChildren` cannot cover this: `land` has already nulled the zombie's `_x._transaction`, and even ungated, a `schedule()` inside `settle` is overwritten by `flush`'s own `scheduled` recompute. `settle` now restarts its landing loop after every landing, so the transactions it parked are judged against the world the landing committed. The zombie is disposed, holds nothing, and `src=0` lands at the same seam. Deviation from the hand-off brief's triage: the brief named the `disposeChildren` predicate (zombie's transaction nulled before the disposing commit) as the cause and offered "schedule() for any dying pending node" or "iterate settle to a fixpoint". The predicate is false as triaged, but dropping it does not fix F3 (the scheduled seam is lost inside `settle`); the fixpoint is the fix. +11 B minified (core floor). F3 flips from `it.fails` to `it`. No other pin moved. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…hts (fuzzer F4) A tuple render reader observes `m0`'s flight for `a=1` (the hold on `a=1`). A later `b=1` re-runs it and the pass throws NotReady at `m1` before reaching `m0`. `blockedBy` counts only the reader's reads of its last pass (`s._gen === r._depGen` — O3, #3494: a reader that stopped reading releases the flight), so the unreached `m0` link counted as dropped, the hold on `a=1` landed, and `A=1` showed beside a tuple still derived from `a=0` while `m0`'s flight was in the air. A15: writes whose async work is observed by a shared reader settle as one unit; A30: an errored pass (a throw, NotReady included) keeps its full list. An errored pass did not stop reading — it never got there. `blockedBy` (scheduler.ts) now counts the links past `_depsTail` as live when the reader's pass errored (`r._x._error != null`). The unchanged- pass tails kept for A30 (#3469) are untouched: their pass completed. +16 B minified (core floor). F4 flips from `it.fails` to `it`. No other pin moved. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: a1f49e7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Coverage Report for CI Build 37357446407Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will improve performance by 11.28%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | projection derive: write one NESTED field (reference) |
2.3 ms | 2 ms | +14.58% |
| ⚡ | memo + sync render effect only (reference) |
32 ms | 29.6 ms | +8.08% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fix/fuzz-batch-a (a1f49e7) with next (c8aac88)
Accepted by the maintainer (2026-10-05). Each cap is set at the CI-measured size + 10 B, rounded up to 0.01 KB, with a dated ledger note per raise. Fuzz Batch A (F3/F4/F9/F10-11/F13) costs +56 B minified on the core floor; brotli layout puts nine scenarios over: - core floor 7.33 -> 7.35 KB (floor-caps.json) - + createStore 14.53 -> 14.56 KB - + isPending/latest 9.47 -> 9.49 KB - simple-app floor 9.83 -> 9.86 KB (floor-caps.json) - hydrating 17.66 -> 17.71 KB (floor-caps.json) - hydrating + stores 28.80 -> 28.84 KB - CSR 12.82 -> 12.86 KB - CSR observe tier 14.39 -> 14.46 KB - live server components 48.47 -> 48.51 KB (floor-caps.json) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Five rule-decided L2 fixes from GabbeV's semantic fuzzer (
fuzz/semantic-fuzzer-l2), one commit per finding, following #3798's pattern: each pin intests/fuzz-findings-l2.test.tsflips fromit.failstoitunder a behaviour name, the deciding rule is cited in source, and each commit has its own changeset.F5 is not in this PR. It was planned as the sixth fix, but both candidate mechanisms introduce a runtime crash under the fuzzer (details below). Its pin stays
it.fails. The title says five fixes instead of the planned six for that reason.Findings
F10 / F11: a memo pulled by an
isPendingprobe computes outside the probe's window. Commit e9e0e15.isPendingreader is revealed beside a write to the probed memo's source. The memo's plain reader stays on the old value for good.undefinedinstead of suspending.verdictValuepulledmwith the window's dispatch still installed.m's own reads went throughverdictValueunder the probe's posture. The unheld-staged arm answered them with the committed value, andmcached it for every reader.core/verdict.tsverdictValue: the nested pull runs with the dispatch dropped, and the dispatch is reinstalled after it.latest/isPending: they restore the dispatch they found, so a window opened inside the pulled pass closes back to none. That case is pinned as "F10 (nested window)", a newitthat fails onnext.setWindowsis removed. It's internal.STATUS_UNINITIALIZEDis not added. With the pull outside the window, no reachable path servedundefined, so there was no failing case to pin.F9: a verdict reader re-derives when its source settles silently. Commit a337a2a.
setSrc(1); setSrc(0)re-asks an async memo for the committed input. A probe-onlyisPending(m)reader staystrueafter the re-ask lands.removePendingSourcefor the verdict reader, which holds no pending source of its own.core/async.tssettlePendingSource. The settle closure's early return re-derives aCONFIG_VERDICTnode it reaches. It is folded into the existing early-return condition, because a separate branch cost 25 B.F13: a born-held effect re-run as lane work re-stages its value. Commit 74452b1.
latest(source)render effect whose mount is withdrawn and restored across an action's ticks loses the action's final write.recompute's lane arm wrote the effect's value into its private_valuebeside its born-held staging. The lane seam'scommitPendingNodethen applied the stale staging.core/core.tsrecompute, lane arm. An effect carrying a staging re-stages, the same as the mainline arm already does.F3: a landing re-judges the transactions parked at its seam. Commit 5478d5a.
src=0) in a newer transaction.src=0still never publishes.src=0is judged blocked before the landing that disposes the zombie, and nothing judges it again.core/scheduler.tsGlobalQueue.settle. The landing loop restarts after every landing.disposeChildrenpredicate. As triaged, it is false here, becauselandalready nulled the zombie's_x._transaction.flush()recomputesscheduledright aftersettle(), so aschedule()from inside a landing is lost.F4: an errored pass's unreached reads still hold their flights. Commit 0c93980.
m0's flight fora=1.b=1re-runs it, and the pass throws NotReady atm1before reachingm0.a=1lands, soA=1shows beside a tuple still derived froma=0.blockedBycounts only links of the reader's last pass (s._gen === r._depGen, the O3 "stopped reading" rule). An errored pass never reachedm0, so its link counted as dropped.core/scheduler.tsblockedBy. Links past_depsTailcount as live when the reader's pass errored (r._x._error != null). Theblockeddocstring now distinguishes this case from the unchanged-pass tails kept for Same-value branch switch leaves a synchronous memo inconsistent with its inputs #3469.F5: stopped, not in this PR
Mechanism, confirmed as triaged:
d0's lane pass goes pending and lists the render reader on the lane throughlaneStage, withoutREACTIVE_LANE_DIRTY.2,1.Two candidate fixes were tried:
lanes.tslaneStage: a lane pass with no answer marks its nodeREACTIVE_LANE_DIRTY.else if (errored) el._flags |= REACTIVE_LANE_DIRTY) is narrower. It also passes fix(signals): a lane's pending leaf re-runs as the lane's work (#3766) #3794's 2.0.0-rc.13 async isPending derivation makes rendered memo values disagree #3766 tests on this branch.Both forms of the second fix crash under the fuzzer: 39 cases across the readiness, derived-readiness and optimistic-readiness cohorts, on both seeds. The error is
TypeError: Cannot read properties of null (reading '_into'), raised fromrecompute→txOf. The trace:listre-points_transactionat the lane, andCONFIG_HELDstays set ("held by a blocked lane").laneStage's leave path nulls the lane_transactionbut keepsCONFIG_HELD.txOf(null).Before the mark, a leaf never reached that leave path. #3794's own head replays 38 of the 39 cases with the same crash; its base replays 1 with an unrelated error.
Two narrowings were tried, and neither works:
CONFIG_HELDon the leave path removes the crash, but 9 of the cases then fail S2 instead.A correct fix has to change how a lane takes over a held leaf. That is beyond F5's triaged mechanism and its 20 B budget, so F5 stays pinned and #3766 stays open here.
Fuzzer before/after
Settings:
--cases 1000 --shrink. "Before" isorigin/nextat b0bad02; "after" is this branch's head. Each cell shows failing cases before → after.No new signatures appear. Compared case by case, every remaining failure is one the baseline already had, with the same signature, except one.
That exception is seed 91501,
latestcase 827, "S1: Torn tuple delivered to an effect", which newly fails:Loadingboundary. Withboundary: "none"it replays clean.It is not a new shape, so it is not pinned separately. Accepted by the maintainer (2026-10-05), to be verified by Batch C's F6 fix. F9's new re-derivation reaches the F6 tear in one more schedule. A hand translation of the case tears on
nexttoo, before F9, but it isn't a faithful F9-specific reduction.The remaining failures fall in the open findings and their families: S2 mount control and S1 derivation/input (F1, F2, F8), S1 torn tuple under a boundary (F6), and the O1 optimistic publication case.
Tests
packages/signals, with the fuzzer directory excluded: 4934 passed, 8 expected fail, 2 skipped. Onnextit was 4924 passed, 12 expected fail.it.fails: F1, F2, F5, F6, F8.packages/solid: 819 passed.packages/web: 1139 passed, 1 expected fail.node scripts/rules-index.mjs --check: current.Public API changes
None.
setWindowsincore/verdict.tswas internal and is removed.Re-pins
None. No existing pin's expectation changed in any commit.
Size
Each fix is within 20 B minified, measured on the core floor unless noted:
The batch totals +56 B minified on the core floor and +57 B on isPending/latest, within the 100 B batch budget.
Local size harness (
scripts/size, brotli), next b0bad02 vs this branch:CI's size job (run 37352614179) matched the table above exactly. A clean rebuild after merging
origin/next(c8aac88, compiler-only changes) measures the same bytes.Size exception granted by the maintainer (2026-10-05) for the nine over-cap scenarios. Each cap is set at the CI-measured size + 10 B, rounded up to 0.01 KB, with a dated ledger note per raise in
scripts/size/scenarios.js. The four floor caps live infloor-caps.json.Size-Exception: fuzz Batch A F3/F4/F9/F10-11/F13 (+56 B minified batch on the core floor), accepted by the maintainer 2026-10-05; nine caps raised to CI-measured + 10 B.
Not in scope and untouched: F1, F2 (Batch B), F6, F8, the adopted-staging case (Batch C), and #3796.
I checked #3796 with a signals-level reduction of its repro: a derived store with a local write, wrapped in an optimistic store and updated inside an action. During the pending action it shows
Old / saving: falsewith the local write andNew / saving: truewithout it. That is identical onnextand on this branch, so none of these fixes changes its behaviour.