From 64f926675730d871e74759ec908d85c31ccd2986 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 03:45:58 -0700 Subject: [PATCH 1/4] fix(signals): reporter liveness reads this pass's deps; a dropped dep retires the reporter and wakes every parked transaction (fuzzer #3446 P1, remaining forms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to how a parked transaction learns that a reporter stopped observing the flight it waits on (A15 / #3426; spec O3): 1. `reporterBlocksSource`'s deps scan is bounded at `_depsTail` — this pass's reads. A staged pass keeps its previous deps linked past the tail until the commit trims them (A30), so a reporter whose pass had stopped reading the source in the same flush as the write still looked live through the kept dep, and the hold it kept was the commit that would have trimmed the dep that kept it. Rule 2's predicate reading Rule 3's deferral (fuzzer case 21). 2. recompute's tail retires a reporter when its pass DROPPED a dep, not only when it recovered from pending: a reporter registered by the stale-reader carve-out (heldFromStale, an initialized source refetching) displays the committed value and is never pending (fuzzer case 79). 3. The retirement wakes every parked transaction (wakeParked), not the reporter's stamp — the transaction waiting on it registered it without stamping it (a later write's hold over a flight an earlier step observed). One idle pass per parked transaction; done ones return. Fuzzer, same campaign (seed 3289, 1000 cases): 994 pass / 0 fail / 6 policy, from 984 / 4 / 12. Same-flush pin flipped from it.fails. Posture matrix: the runner records INVARIANT_VIOLATIONs per cell instead of dying (new `invariant` column). With the parked-transaction leak gone, quiescence checks run again and surface a pre-existing INV-4 — a projection leaf's latest() shadow is stale on the flush right after its root is disposed mid-refetch (spec O5; pinned it.fails as S3 in posture-store-parity.test.ts; standalone repro on `next`). The two matrix cells that trigger it are excluded until it is fixed; every other cell's served value is unchanged. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/action-await-yield-docs.md | 5 - .changeset/staged-reporter-deps-tail.md | 5 + packages/signals/docs/DESIGN-CONSOLIDATION.md | 122 ++++++++++++++++++ packages/signals/docs/RULES-INDEX.md | 28 ++-- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 8 +- packages/signals/src/core/action.ts | 17 +-- packages/signals/src/core/core.ts | 42 +++--- packages/signals/src/core/scheduler.ts | 16 ++- packages/signals/src/signals.ts | 8 -- .../posture-born-held-and-observation.test.ts | 17 ++- .../tests/posture-store-parity.test.ts | 53 ++++++++ .../tests/visibility-oracle-posture.test.ts | 73 +++++++++-- 12 files changed, 313 insertions(+), 81 deletions(-) delete mode 100644 .changeset/action-await-yield-docs.md create mode 100644 .changeset/staged-reporter-deps-tail.md create mode 100644 packages/signals/docs/DESIGN-CONSOLIDATION.md diff --git a/.changeset/action-await-yield-docs.md b/.changeset/action-await-yield-docs.md deleted file mode 100644 index 60a5e88dd..000000000 --- a/.changeset/action-await-yield-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@solidjs/signals": patch ---- - -Docs: inside an `action`, a bare `yield` is required after an `await` before anything that creates a reader — `until()`, `latest()`, a memo or effect, a mount — not only before writes. The `until()` docstring's own example had `await` straight into `yield until(...)`; the `until(...)` expression is evaluated in the post-`await` continuation, outside the transaction, and its predicate reader is born held there (#3482). Example corrected. diff --git a/.changeset/staged-reporter-deps-tail.md b/.changeset/staged-reporter-deps-tail.md new file mode 100644 index 000000000..e3db87b9e --- /dev/null +++ b/.changeset/staged-reporter-deps-tail.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A render effect that stops reading a pending memo no longer keeps the memo's source held, in every ordering. Reporter liveness now reads this pass's deps — `reporterBlocksSource`'s scan stops at `_depsTail` instead of walking the committed frame's deps that A30 keeps linked until the commit trims them (a staged pass that had stopped reading the memo still looked live through its kept dep, and the hold it kept was the commit that would have trimmed it). A reporter retires when its pass drops a dep — not only when it recovers from pending, which a reporter registered by the stale-reader carve-out never was — and the retirement wakes every parked transaction rather than the reporter's stamp, since the transaction waiting on it registered it without stamping it. Semantic fuzzer (#3446), same campaign: 994 pass / 0 fail / 6 policy, from 984 / 4 / 12. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md new file mode 100644 index 000000000..66ff186ce --- /dev/null +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -0,0 +1,122 @@ +# Consolidation — one implementation per rule + +**Status:** design, 2026-09-16. Read-only pass over `next` at `5fa224a4a` (#3479 in). Nothing here is implemented. Written for a decision, not as a plan of record. + +## 1. Why + +The last two months' async fixes are ~four rules, each fixed several times at different sites: + +| Rule (stated per outcome) | Sites that each decide it (enforced per site) | Fixes to the same rule | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| Which value does a reader off the hold see | `readNodeFast`, `read` fast block, `read` slow tail, `overrideRead`, `latestRead`, `gatedRead`, `laneReadsCommitted`, `readsHeldCommitted`, store `nodeValue` / `serveDataKey` / `pendingBackingVisible` / `heldFromReader` / `visibleOverride` / `optimisticView` | #3330 #3334 #3460 (two places) A29 (three sites) A28 (four sites) | +| Is this reporter live | `reporterBlocksSource` (one predicate, five stand-ins) + three independent _wake_ sites: `disposeChildren`, `recompute` tail, boundary reset | #3372 #3375 #3426 #3458 #3463 #3488 | +| Dependencies are the committed frame's (A30) | `commitPendingNode` trim, `runEffect` trim, `heldTrims` (unchanged pass), `trimStaleDeps` at pass end | #3410 #3438 #3461 #3469 | +| Decided at the pass, known at the verdict | `CONFIG_HELD_CHILDREN`+`_pendingFirstChild`/`_pendingDisposal`, `_modified`+`_queueStash`, `heldRevealed`, `_gatedSubs`, `heldTrims`, `_contested`, `_flushedStaged` | one bespoke mechanism per fix | + +Two instruments now exist that did not when those fixes were written: the posture matrix (621 enumerated cells: state × posture × reader → served value, entanglement) and the semantic fuzzer (#3446, 20 laws over generated graphs; baseline on this `next`: 984 pass / 4 fail / 12 policy). Both say "zero semantic change" is now a checkable claim rather than a hope, which is the precondition for any of what follows. + +Three open violations are the concrete targets; each is a consequence of the site count: + +- **O3, same-flush form** (fuzzer P1, cases 21/79; pinned `it.fails`): gate closes and source is written in one flush → the reporter's pass runs under the transaction and _stages_ its value, so A30 keeps its previous dep on the memo linked past `_depsTail` until commit; `reporterBlocksSource`'s deps scan walks the whole list, finds the kept dep, and calls the reporter live. The hold keeps the dep that keeps the hold. Rule 2's predicate reading Rule 3's deferral — verified by probe 2026-09-16 (pass ran once; not pending; `_pendingValue = "hidden"`; deps `[show, memo]`, tail after `show`). +- **O4 / S1** (pinned `it.fails`): after same-tick adoption, the signal's `unflushedValue` reads a stamped node with no stash as "flushed, held" and `latest`/`isPending` see a write no flush carried; the store's `flushedStaged` path does not. Two definitions of "unflushed". Rule 1. +- **O2** (recorded, not ruled): creation under a transaction / in boundary content escapes the hold while mainline creation is born held. One rule (A29) implemented at one of its sites. Rule 1 / Rule 4. A ruling question first — the consolidation makes whichever answer is chosen hold everywhere. + +## 2. Inventory (as of `5fa224a4a`) + +Condensed from a read-only walk; line numbers are approximate to ±5 and will drift. + +### Rule 1 — value selection + +Core, in evaluation order per site: + +- `readNodeFast` (`core.ts` ~1699–1737): bail gate → `READ_SLOW` on any special mode (`latestReadActive`, `pendingCheckActive`, `_fn`, `_firewall`, override, snapshot, `activeTransition`, lane, `unflushedStaged && pending`, strict); else link; then **T1**: `!c || pending === NOT_PENDING || CHILDREN_FORBIDDEN || (stale && heldFromStale)` → `_value`, else `enterStagedRead; _pendingValue`. +- `read` fast block (~1739–1783): same eligibility, same **T1** verbatim. +- `read` slow tail (~1966–1995): `noCommitted && !c` → throw; `unflushedValue` arm (A28) → committed / stash + `markLateLinker`; then **T1 extended**: `+ laneReadsCommitted`, `+ (CONFIG_HELD_TRUTH && !latest && !AUTHORITATIVE)`, `+ !noCommitted` guard on the stale arm. +- `read` override arm (~1912–1938): active override, not authoritative, `!unflushedOverride` → tracked with lane/superseded → `overrideRead`, else `unwrapOverride`. +- `read` pending arm (~1821–1878): stale carve-out (`!UNINITIALIZED && !INPUTS_PUBLISHED && !laneLive && heldFromStale`) → committed; else throw / `laneSuspends`. +- `heldFromStale` (~1544–1555): foreign transaction → true, with side registration into `_gatedSubs` / `_asyncReporters`. +- `enterStagedRead` (~1578–1611): A29 entry; companion/verdict exemptions; born-held record for mainline creation. +- `unflushedValue` / `unflushedOverride` (~1640–1663): A28. +- `overrideRead` (`optimistic.ts` ~429–445): `stale && readsHeldCommitted` → `_value`; not superseded → override; stale foreign owner → override; else enter + pending/committed. +- `gatedRead` (~556–567), `laneReadsCommitted` (~573–614), `readsHeldCommitted` (`lanes.ts` ~143–154): lane-side "prefer committed" with `_gatedSubs` registration. +- `latestRead` (`verdict.ts` ~479–551), `flushedStaged` (~170–176), `computePendingState` (~259–314): verdict channels; re-derive visible override, unflushed, stale-foreign, shadow pending. + +Store twins (`store/next/store.ts`, `optimistic.ts`): `heldFoldTransition` / `foreignHold` / `heldFromReader` (≡ `heldFromStale` for backings), `readSource` + `pendingBackingVisible` (≡ T1 extended for backings, plus draft / write-override / opt-family arms), `heldTruthMasked` (≡ HELD_TRUTH arm), `visibleOverride` (≡ override arm's `unflushedOverride` gate), `nodeValue` (untracked view: override → pending → backing), `serveDataKey` (per-key: length / opt / draft overlay, then `readNodeFast`/`readNode` tracked or `nodeValue` untracked), `optimisticView` (deep compose of flushed overrides). + +**Duplicated conditions (each is a place a rule change must be threaded by hand):** T1 ×2 verbatim, T1-extended ×1 + store backing twin; stale-foreign → committed ×5; CHILDREN*FORBIDDEN → committed ×3; A28 unflushed ×6 call sites over two helpers (signal) plus `flushedStaged` (verdict) — and the store gets a \_different* answer for adopted nodes (S1); override-vs-truth ×3; HELD_TRUTH mask ×2; `enterStagedRead` on staged serve ×4. + +### Rule 2 — reporter liveness + +- Predicate: `reporterBlocksSource` (`scheduler.ts` ~1499–1542): DISPOSED → dead; ZOMBIE → walk to non-zombie parent, judge by its transaction vs verdict; boundary walk (`_collectionType & PENDING && !_initialized`) → dead (A33); `_pendingSources.has(source)` → live; deps scan through `_parentSource`/`_firewall` → live; `pending && _error.source === source` → live. Callers: `sourceObserved` → `transitionComplete`, `waitingTransition`, `enterWaiting`, `_endOptimism`, `_transitionBlocked`. +- Registration: `notify` (~897–931, INV-3), `heldFromStale`, store optimistic path. +- **Events that retire a reporter, each pushing `wokenTransitions` independently:** `disposeChildren` (`owner.ts` ~86, #3372), `recompute` tail (`core.ts` ~727, #3488), boundary reset → `wakeParked` (`boundaries.ts` ~319). Consumed in flush's `finally` on an otherwise idle pass (~892). +- **Verdict placement:** `transitionComplete` at ~774, after `runHeap(dirtyQueue)` and **before** effects; on incomplete: `stashQueues` (~805) parks the _entire_ render/user queues, `finalizePureQueue(null, true)`, return. This ordering is O3's same-flush form. + +### Rule 3/4 — deferred decisions (one structure each today) + +| Decision recorded at the pass | Carrier | Applied at commit | Dropped at park | +| ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Staged value | node `_pendingValue`, `t._pendingNodes` | `commitPendingNode(s)` | kept (re-stamped) | +| Children of a held pass | `CONFIG_HELD_CHILDREN`, `_x._pendingFirstChild/_pendingDisposal`, zombie heap | `_dispose(zombie=true)` in `commitPendingNode` | zombie heap cancelled if batch===txn; immediate on re-run | +| Effect run owed | `_modified`, `_queueStash` | `restoreQueues` → `runEffect` (re-enqueues if `_valueTransition` open) | stashed whole-queue | +| Deps trim (A30) | `_depsTail`, module `heldTrims[]` | `commitPendingNodes` drains; `runEffect` trims; per-node in `commitPendingNode` | `heldTrims.length = 0` | +| Gated / stale readers to replay | `t._gatedSubs` | `finalizePureQueue` enqueue+clear | kept; merged on merge; adopted from ambient | +| Held-truth reveal | node `CONFIG_HELD_TRUTH`, module `heldRevealed[]` | post-`_resolveOptimistic` `insertSubs` | never (park never commits) | +| Cross-txn effect write | `t._contested` | `finalizePureQueue` enqueue | merged | +| Held rewrite's flushed value (A28) | `_x._flushedStaged`, module `unflushedRewrites[]` | cleared at flush start | n/a (tick-local) | + +Ambient `_batch` and a live `Transition` already share the same field set — the "cargo" concept exists; it has no lifecycle API. + +## 3. Shape + +Three moves, ordered by blast radius. Each is zero-semantic-change by construction _except_ where it closes a pinned violation, and each is gated on the matrix, both oracles, and the fuzzer baseline before and after. + +### 3a. Rule 2 — one retirement event, one verdict placement + +**`retireReporter(reporter)`** — a single entry point that the three wake sites call instead of pushing `wokenTransitions` themselves. Body is today's dedup'd push + `schedule()`, keyed on `reporter._transition`. Pure refactor; the three call sites lose their inline copies. + +**Same-flush O3.** A first reading of this case blamed effect parking (the run stashed, never proving itself dead). The probe says otherwise: the compute ran, staged `"hidden"`, and the effect is judged live only because `reporterBlocksSource`'s deps scan reads the dep A30 deliberately keeps linked past `_depsTail` for the commit to trim. Two rules, one wrong answer; the fix is in the predicate, not the scheduler: + +_Liveness reads the pass's deps, not the committed frame's._ For a reporter with a staged pass (`_pendingValue !== NOT_PENDING`), the deps scan stops at `_depsTail` — the dependencies this pass actually read. A kept tail is the committed frame's business (A30: a write to a dep the committed value still derives from must reach the node) and says nothing about whether the reporter still observes the flight. One predicate, one line, and it is the `readerLive()` consolidation's first concrete content: the predicate must know which frame it is asking about. + +Case 21 then resolves without any parking change: the pass no longer reads the memo → not live → the transaction completes at the verdict already in place, the same flush. Stash-by-world (`#3407` applied to `stashQueues`) is _not_ needed for this and should not be done on its account; it remains a separate question (§6). + +### 3b. Rule 1 — shared predicates, then one `serve` + +Not a single `serve()` first. The perf constraint is hard and measured (2026-09-15): `readNodeFast` past ~460 B of bytecode, or a call on its staged branch, costs 10–15% on propagation; `setSignal` past the inline budget costs 10–20% on the write loop. The fast path must stay a tiny inlinable guard that handles the trivial case and bails. Value selection therefore has exactly **two** implementations by design — the fast ternary (T1) and one slow `serve` — and the target is to make the third-through-eleventh disappear, not the second. + +Step 1 — **shared predicates**, no behavior change: `readerSeesCommitted(el, c)` (= T1-extended's disjunction, including HELD_TRUTH and lane arms), `visibleOverride(el)` (already exists store-side; core's override arm inlines the same test), `unflushed(el)` with **one** definition used by `unflushedValue`, `flushedStaged`, `pendingBackingVisible` and `nodeValue`, and `readerClass(ctx)` — the three ways a reader relates to a hold, today spread over three unrelated flags: **derives** (a tracked pass: joins, or is born held), **displays** (a render/user effect's apply: sees the committed frame now, replays at the reveal — `_gatedSubs`), **observes** (verdict pulls `_verdictPull`, companions `_parentSource`: mirrors of the flushed world, never join). Direct-commit readers (`CONFIG_DIRECT_COMMIT` — `resolve()`/`until()`) are **not** observers: they are derivers with a tunnel _inside their own transaction_ (the arm that lets a hold not deadlock on its own acknowledgment), and from mainline over a **foreign** hold they wait for the commit like any deriver — #3492 pins that a mainline `resolve()` must never resolve with an unrelated action's unrevealed frame, which is exactly what exempting them from born-held (#3490) leaked. `enterStagedRead`, `heldFromStale` and `recompute`'s commit arm each test a different subset of these flags today. This step closes **S1**: "unflushed" = staged outside a flush and not yet carried by one, whatever the stamp — one predicate, so the signal and the store cannot disagree. Mechanism: `queuePendingNode` outside a flush already sets `unflushedStaged`; a per-node bit set there and cleared by the carrying flush (`resyncUnflushedCompanions` walks the batch's pending nodes — it is already the flush-start hook) makes adoption irrelevant to the test. + +Step 2 — **`serve(el, reader)`** as the slow tail: `read`'s slow arms, `overrideRead`, `latestRead`'s value selection and the store's `nodeValue`/`readSource` value decision call it; the store keeps its structural arms (draft overlay, length, chained, opt family) and delegates the _value_ decision. `gatedRead`/`laneReadsCommitted`/`readsHeldCommitted` fold into `readerSeesCommitted` with their `_gatedSubs` registration as a side effect of the predicate, as `heldFromStale` already does. + +Bytes: expect roughly neutral to slightly positive. Three mechanism-preserving consolidations this month came back +13…+85 B; the pitch is one site per rule, not size. + +### 3c. Rule 3/4 — cargo lifecycle + +Give the shared batch/transaction field set the two functions it lacks: `applyCargo(t)` (today's `commitPendingNodes` + `_gatedSubs` replay + `heldRevealed` wake + `heldTrims` drain + zombie dispose, in the order `finalizePureQueue` runs them) and `dropCargo(t)` (today's park path: `heldTrims.length = 0`, zombie cancel, `stashQueues`). `heldTrims` and `heldRevealed` move from module arrays onto the transaction they belong to (a module array is only correct while one transaction commits at a time, which `finalizePureQueue` guarantees today — by accident of sequencing, not by construction). New deferrals then have exactly one place to go. + +This is the largest move and the one with the least direct violation behind it; it can wait for the first new "decided at the pass" fix to motivate it, or be done when 3a/3b have settled. + +## 4. Verification protocol (per PR) + +1. `tests/visibility-oracle.test.ts`, `-store.test.ts`: every cell unchanged. +2. `tests/visibility-oracle-posture.test.ts`: 621-cell report diffed against the pre-change report; the only permitted diffs are the cells a pinned violation says should flip. +3. Fuzzer (#3446) campaign, same seed: baseline 984 / 4 / 12; permitted change is the pinned violation's cases. +4. CodSpeed on the PR; write-loop benches (`update1to1`, `update1to1000`, `diamond`, `avoidable`) alternating pairs; `--print-bytecode` for `readNodeFast`, `read`, `setSignal`, `recompute` before/after. +5. Size: floor and the nine brotli scenarios; report the delta, do not sell it. + +## 5. Sequencing + +1. **3a** — `retireReporter` + the deps-scan bounded by `_depsTail` for staged passes. Closes O3's same-flush form (fuzzer 4 → 0 expected). Smallest blast radius; touches `reporterBlocksSource` and three wake sites. +2. **3b step 1** — shared predicates incl. one `unflushed`. Closes S1. Touches `core.ts` read arms, `verdict.ts`, store `store.ts`; no fast-path change. +3. **O2 ruling**, then whichever answer, applied once via `enterStagedRead` (born held everywhere: the `creatingPass` prototype, +83 B) or via `recompute`'s create arm (escapes everywhere: retire the mainline born-held form). +4. **3b step 2** — `serve`. +5. **3c** — cargo lifecycle, when motivated. + +## 6. Open questions for the maintainer + +- **Stash-by-world (not required for O3):** `stashQueues` parks the whole render/user queue when a transaction parks, including effects dirtied only by a mainline write in that round. #3407 read literally says those belong to mainline and should run. Not a violation anyone has pinned; flagged as a candidate rule to make explicit, not a change to make now. +- **O2:** born held everywhere, or escapes everywhere. Either is consistent; the current state (mainline held, transaction/boundary creation escapes) is the only inconsistent option. **Insight from #3482 (2026-09-16):** born-held bundles two decisions that should be separate — _ownership_ (the created value belongs to the transaction it derived from) and _application_ (skip the effect's first run, replay at commit). The ownership half was right even in #3482's misuse: the post-`await` `until()` _was_ the action's reader, and born-held correctly made it the action's. What deadlocked was a reader created in the wrong posture (post-`await`, mainline by mechanism) over its _own_ action's hold — and from mainline, waiting for the commit is the correct behavior for that reader class (#3492: a mainline `resolve()` over a foreign hold must not see the held frame; the direct-commit tunnel is only for a reader inside its own transaction). So born-held was right on both halves there; the misuse is what put the reader in a posture where "right" deadlocks, and the docs/lint are the fix. "Born held everywhere" remains the recommendation for O2's actual question — creation _under_ a transaction / in boundary content — ownership and application both following derivation, as mainline creation already does (the `creatingPass` prototype, +83 B). The `CONFIG_DIRECT_COMMIT` exemption proposed in #3482 is declined on the evidence, not on taste. A further argument for posture-independence: the posture is exactly what users get wrong (`await` vs `yield`), so a rule that changes with the posture turns a documentation slip into a semantic one. +- **`readsHeldCommitted` and the lane arms:** folding them into `readerSeesCommitted` assumes lanes are "a transaction with an override"; if lanes are meant to diverge from transactions later, keep them as a separate predicate that `serve` consults. +- **Post-`await` posture:** pinned by #3492 for the direct-commit readers (three postures: own step, own `await` continuation, foreign mainline) as a standalone file. Worth folding into the matrix as a posture (`ownActionAfterAwait`) so the other reader kinds get the same rows; not urgent. +- **Loosening:** once `serve` exists, each of its arms is a constraint with a measurable blast radius (flip it, rerun the matrix). Candidates surfaced so far: O2 (two born-held forms → one), the `CONFIG_HELD_TRUTH` mask (one arm, two sites), and the stale-foreign carve-out in the pending arm (`INPUTS_PUBLISHED`), which exists to serve one shape (#3305). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 09e6e809f..2a23efee7 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -26,7 +26,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | V | 5 | 2 | 5 | 0 | | B | 5 | 0 | 5 | 0 | | C | 4 | 0 | 3 | 1 | -| INV | 11 | 11 | 5 | 0 | +| INV | 11 | 11 | 6 | 0 | | RUL | 13 | 6 | 6 | 5 | | R (CS) | 59 | 18 | 16 | 31 | | R (OL) | 37 | 0 | 0 | 37 | @@ -64,8 +64,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×4 constants.ts×2 core.ts×9 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×11 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×25 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×3 constants.ts×1 core.ts×6 optimistic.ts×6 scheduler.ts×3 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 lane-outside-view.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.states.ts×9 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×24 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | | A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:332` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:339` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:338` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:345` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | | A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | | A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | | A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×3 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | @@ -73,8 +73,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×5 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×3 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×2 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | @@ -83,11 +83,11 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:402` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:412` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:418` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:425` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:437` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:408` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:418` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:424` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:431` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:443` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B @@ -104,8 +104,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | | C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:368` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:378` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:374` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:384` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | | C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants @@ -114,8 +114,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | ------ | ------- | ----------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:147` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | | INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:149` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | -| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:153` | boundaries.ts×1 core.ts×1 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | -| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:160` | invariants.ts×3 | — | - **INV-4 (medium)** After any of the three write paths completes for node `el` | +| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:153` | boundaries.ts×1 core.ts×2 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | +| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:160` | invariants.ts×3 | posture-store-parity.test.ts×3 visibility-oracle-posture.test.ts×1 | - **INV-4 (medium)** After any of the three write paths completes for node `el` | | INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:164` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | | INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:170` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | | INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:173` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 52ac30b53..bf3b33a5e 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -317,7 +317,8 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H **Current behavior:** a render effect that directly observed an uninitialized async memo registers as the flight's reporter (`_asyncReporters`); when it re-runs without reading the memo (a `show()` gate closes) the source's ordinary write stays held — forever when the flight never lands. A memo between the effect and the flight releases the hold (its re-run clears its status). `reporterBlocksSource` still answers true for the effect from its stale `_pendingSources` / NotReady `source`, and the parked transaction's verdict may not be re-evaluated by a flush with no async event. **Rule:** A15 / #3426 — the hold lasts while a LIVE reporter observes the flight; "re-ran and no longer derives from the source" is the fifth liveness case after disposed, zombie, behind-fallback and first-observer. **Mechanism (2026-09-16):** the predicate was already right — `reporterBlocksSource` judged the re-run effect dead; nothing RE-JUDGED the parked transaction. `recompute`'s tail now treats a pending reporter recovering without its flight landing as the completion event it is: it wakes the transaction it reported to (`wokenTransitions`, the third site after disposal #3372 and boundary reset #3375; `wakeParked` when the reporter carries no stamp), skipped under that transaction's own flush, which judges the landing itself. 21 → 4 of 1,000 fuzzer cases. -**Remaining form (open):** gate and write in ONE flush (fuzzer case 21; case 79 is the multi-step variant). The effect is notified pending by the write — registering as the reporter — and dirtied by the gate in the same flush; the verdict runs after the pure phase, before effects, so the effect still looks live, the transaction parks, and the effect's run (which would prove it dead) is stashed with the transaction. The hold keeps the run that would release it; disposal releases (#3372). Pinned `it.fails`. Belongs with the effect-phase parking question (#3407: which world an effect's run belongs to when a mainline write and a held flight dirty it in one flush). +**Same-flush form (fixed 2026-09-16):** gate and write in ONE flush (fuzzer case 21; case 79 the multi-step variant). A first reading blamed effect parking; a probe showed the effect's pass ran and _staged_ `"hidden"`, and that A30 kept its previous dep on the memo linked past `_depsTail` for the commit to trim — `reporterBlocksSource`'s deps scan read that kept dep and called the effect live. The hold kept the dep that kept the hold: Rule 2's predicate reading Rule 3's deferral. Fix: the scan is bounded at `_depsTail` — "still derives from the source" is a question about this pass's reads; a trimmed list ends there anyway, a pass that read nothing has a null tail. Pinned; fuzzer P1 4 → 0 with the two mechanism corrections below. +**Mechanism (2026-09-16, complete):** (1) `reporterBlocksSource`'s deps scan is bounded at `_depsTail` — this pass's reads, not the committed frame's kept tail. (2) `recompute`'s tail retires a reporter when its pass **dropped a dep** (deps past `_depsTail`, or a pass that read nothing) — not only when it recovered from pending: a reporter registered by the stale-reader carve-out (`heldFromStale`, an initialized source refetching) displays the committed value and is never pending (fuzzer case 79). (3) The retirement wakes **every** parked transaction (`wakeParked`), not the reporter's stamp: the transaction waiting on it registered it without stamping it (a later write's hold over a flight an earlier step observed). The fuzzer's same campaign: 994 pass / 0 fail / 6 policy (from 984 / 4 / 12 before #3488). ### O4. Adopted, unflushed — the signal's verdict channels see a write no flush has carried — violation, open @@ -325,6 +326,11 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H **Rule:** same-tick adoption is by design (O1); A28 (1)/(2) still govern visibility — nothing is visible before the flush that carries the write, on any channel. The store is right. **Mechanism (current):** adoption stamps the signal with the transaction (`initTransition`'s pending-node loop); `unflushedValue` reads a stamped node with no `_flushedStaged` stash as a flushed held node and serves `_pendingValue`. The store's selection (`nodeValue` / `serveDataKey`, `flushedStaged`) does not take that path. One rule, two implementations — the fix is making "unflushed" mean the same thing at both sites (a node staged outside a flush and adopted before any flush is unflushed whatever its stamp). +### O5. INV-4 — a projection leaf's `latest()` shadow is stale on the flush right after its root is disposed mid-refetch — violation, open + +**Status:** **violation, recorded** 2026-09-16 — surfaced by O3's fix: the posture matrix had been leaking parked transactions (dead reporters never re-judged), which kept `transitions.size > 0` and silenced every quiescence invariant for the rest of the run. With the leak gone, INV-4 fires. Pre-existing on `next` (standalone repro: projection store with a held refetch, `latest()` read of a leaf, `dispose()`, synchronous `flush()`); pinned `it.fails` in `tests/posture-store-parity.test.ts` (S3). Transient — the shadow is re-derived a microtask later — but under `__TEST__` a throw from the runtime's own scheduled flush leaves the scheduler mid-flush, so the matrix excludes the two triggering cells (`gatedAway` × the projection states) until fixed. +**Where to look:** the disposal snap (`disposeChildren` → `_snapCompanions`) covers the disposed computed's own companions; a projection's leaves hang off the firewall, not the child chain, and their companions are re-derived only by the scheduled pass that follows. + ## Superseded rules (kept verbatim) Cited by tests and by A24's reasoning; the statements below are as they stood when superseded. diff --git a/packages/signals/src/core/action.ts b/packages/signals/src/core/action.ts index 318c8a5cb..b43fcf668 100644 --- a/packages/signals/src/core/action.ts +++ b/packages/signals/src/core/action.ts @@ -64,22 +64,15 @@ function restoreTransition(seq: number, transition: Transition, fn: () => T): * `yield` is the transaction-safe suspension point: the action waits for a * yielded promise and re-enters the transaction before running the code after * it. A plain `await` does NOT — the runtime has no hook into an async - * generator's internal await continuations, so code between an `await` and - * the next `yield` runs OUTSIDE the transaction: writes to fresh signals - * commit immediately, and anything that creates a reader there — `until()`, - * `latest()`, a memo or effect, a mount — is created mainline, where a read of - * this action's held state makes it born held (A29): staged with the - * transaction and replayed at its commit. For `until()` that commit is the - * settle its own promise holds open (#3482). `await` is still the ergonomic - * choice for typed results; just put a bare `yield` before any write or - * reader creation that follows it — including the expression of the next - * `yield`, which is evaluated before the step re-enters: + * generator's internal await continuations, so writes to fresh signals + * between an `await` and the next `yield` escape the transaction and commit + * immediately. `await` is still the ergonomic choice for typed results; just + * put a bare `yield` before any writes that follow it: * * ```ts * const saved = await api.createTodo(text); // typed result - * yield; // re-enter the transaction before writing or reading + * yield; // re-enter the transaction before writing * setTodos(t => { ... }); - * yield until(() => todos.some(t => t.id === saved.id)); * ``` * * (For the same reason, don't call `flush()` inside an action body — it diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index ecc92d5ae..c8df7489d 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -101,7 +101,7 @@ import { heldTrims, runInTransition, schedule, - wokenTransitions, + wakeParked, zombieQueue } from "./scheduler.js"; import type { @@ -715,22 +715,30 @@ export function recompute(el: Computed, create: boolean = false): void { if (wasPendingSource && !(el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED))) settlePendingSource(el); } - // A pending REPORTER that recovered without its flight landing — this pass - // no longer reads the source (a gate closed) — stops counting for the - // transaction it reported to (A15 / #3426: the hold lasts while a live - // reporter observes the flight). Nothing else re-judges a parked - // transaction (see wokenTransitions; the disposal (#3372) and boundary - // (#3375) twins of this site), so the writes it held stayed staged for as - // long as the flight stayed up — forever, for one that never lands - // (fuzzer #3446 P1, spec O3). Not under the transaction's own flush: the - // landing that recovers a reader there is judged by that flush. - if (isEffect && wasPending && !(el._statusFlags & STATUS_PENDING)) { - const t = el._transition; - if (t !== null && t !== activeTransition && !t._done && !wokenTransitions.includes(t)) { - wokenTransitions.push(t); - schedule(); - } - } + // A REPORTER whose pass stopped reading a source it reported on (a gate + // closed) stops counting for the transaction waiting on it (A15 / #3426: + // the hold lasts while a live reporter observes the flight). Nothing else + // re-judges a parked transaction (see wokenTransitions; the disposal + // (#3372) and boundary (#3375) twins of this site), so the writes it held + // stayed staged for as long as the flight stayed up — forever, for one + // that never lands (fuzzer #3446 P1, spec O3). The event is "this pass + // dropped a dep" — deps past `_depsTail` (trimmed below, or kept by A30 + // for a staged pass), or a pass that read nothing — not "recovered from + // pending": a reporter registered by the stale-reader carve-out + // (heldFromStale, an INITIALIZED source refetching) displays the committed + // value and is never pending (fuzzer case 79). Every parked transaction, + // not the reporter's stamp: the transaction waiting on it registered it + // without stamping it. One idle pass per parked transaction; done ones + // return at re-entry. Effects only — reporters register from render-effect + // notification (INV-3). + // (`_depsTail` was reset at the top of the pass; TS keeps that narrowing.) + const tail = (el as Computed)._depsTail as Link | null; + if ( + isEffect && + ((wasPending && !(el._statusFlags & STATUS_PENDING)) || + (tail === null ? el._deps !== null : tail._nextDep !== null)) + ) + wakeParked(); // Dependencies are the committed frame's until it is replaced (A30, #3410; the // deps twin of the held children above): a pass that staged its value // leaves the previous pass's tail linked for `commitPendingNode` to trim, diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index ecea95190..856c862d4 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -1536,7 +1536,21 @@ function reporterBlocksSource( for (let q: IQueue | null = reporter._queue; q; q = q._parent) if (q._collectionType! & STATUS_PENDING && !q._initialized) return false; if (reporter._x?._pendingSources?.has(source)) return true; - for (let dep = reporter._deps; dep; dep = dep._nextDep) { + // "Still derives from the source" is a question about THIS pass's reads: + // the deps up to `_depsTail`. Past it lie the committed frame's — kept + // linked by A30 until the commit trims them (a staged pass, an errored + // one). Reading them here made a reporter whose pass had stopped reading + // the source (a gate closed in the same flush as the write) look live, and + // the hold it kept was the commit that would have trimmed the dep that + // kept it (spec O3, same-flush form; fuzzer #3446 P1 cases 21/79). A + // trimmed list ends at `_depsTail`, so the bound is free there; a pass + // that read nothing has a null tail and derives from nothing. + const tail = reporter._depsTail; + for ( + let dep = tail === null ? null : reporter._deps; + dep; + dep = dep === tail ? null : dep._nextDep + ) { let current = dep._dep as Signal | Computed | undefined; while (current) { if (current === source || (current as any)._firewall === source) return true; diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 8aa09ef00..513851e25 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -932,20 +932,12 @@ export interface UntilOptions { * * Must be called *outside* a tracking scope. * - * Inside an action, call it from a step: after an `await`, put a bare `yield` - * before `yield until(...)`. The runtime cannot hook an async generator's - * `await` continuation, so the `until(...)` expression — which CREATES the - * predicate's reader — would otherwise run outside the transaction; created - * there it is born held (A29) and replays only at the commit its own promise - * holds open (#3482). See {@link action}. - * * @example * ```ts * const send = action(async function* (text: string) { * const clientId = crypto.randomUUID(); * setMessages(m => { m.push({ clientId, text, pending: true }); }); // optimistic * await socket.send({ clientId, text }); // fire-and-forget transport - * yield; // re-enter the transaction after the await * // Hold until the live source echoes the write (authoritative view — * // the optimistic row above cannot satisfy this): * yield until(() => messages.some(m => m.clientId === clientId), { timeout: 10_000 }); diff --git a/packages/signals/tests/posture-born-held-and-observation.test.ts b/packages/signals/tests/posture-born-held-and-observation.test.ts index 67f1fd7cf..9f3712aa2 100644 --- a/packages/signals/tests/posture-born-held-and-observation.test.ts +++ b/packages/signals/tests/posture-born-held-and-observation.test.ts @@ -163,15 +163,14 @@ describe("P1 — a reader that stopped reading the flight does not keep its hold }); }); -describe("P1, same-flush form — gate and write in ONE flush (fuzzer #3446 case 21) — VIOLATION, pinned it.fails", () => { - // The effect is notified pending by the write (registering as the flight's - // reporter) and dirtied by the gate in the same flush. The verdict runs - // after the pure phase, BEFORE effects: the effect still looks live, the - // transaction parks, and the effect's run — the one that would prove it - // dead (it no longer reads the memo) — is stashed WITH the transaction. - // The hold keeps the run that would release it. Disposing the reader - // releases (#3372). Spec O3, remaining form. - it.fails("closing the gate and writing the source in one flush releases the write", async () => { +describe("P1, same-flush form — gate and write in ONE flush (fuzzer #3446 case 21; spec O3)", () => { + // The effect's pass runs under the transaction and STAGES "hidden", so A30 + // keeps its previous dep on the memo linked past `_depsTail` until the + // commit trims it. `reporterBlocksSource`'s deps scan read that kept dep and + // called the effect live — the hold kept the dep that kept the hold. The + // scan is now bounded at `_depsTail`: "still derives from the source" is a + // question about this pass's reads, not the committed frame's. + it("closing the gate and writing the source in one flush releases the write", async () => { const [s, setS] = createSignal(0); const [show, setShow] = createSignal(true); createRoot(() => { diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 6295b54d8..fc4cb88f6 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -13,6 +13,14 @@ * the content pass entered the hold, the creation direct-committed). * Mainline creation over the same value is born held (A29). * + * S3 — VIOLATION (INV-4), pinned it.fails: a projection leaf's latest() + * shadow is stale on the flush right after the store's root is disposed + * while a refetch is held. Transient (it recovers a microtask later), + * but a __TEST__ quiescence check in that window throws — and a throw + * from the runtime's own scheduled flush leaves the scheduler mid-flush. + * Surfaced when #3488/O3 stopped leaking the parked transactions that + * had masked every quiescence check in the posture matrix. Spec O5. + * * (A first cut also reported the projection's seed leaking as a value inside * boundary content, and `isPending` false / override invisible behind a * fallback. All three were a runner artifact — the boundary content re-ran @@ -33,6 +41,13 @@ import { latest } from "../src/index.js"; +const settle = async () => { + for (let i = 0; i < 3; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + const never = () => new Promise(() => {}); describe("S1 — adopted, unflushed: verdict channels inside the adopting action (A28 (1)/(2)) — signal vs store", () => { @@ -116,3 +131,41 @@ describe("S2 — creation in boundary content over a held value publishes it (OB expect(s.n).toBe(0); }); }); + +describe("S3 — INV-4: a projection leaf's latest() shadow after its root is disposed mid-refetch (spec O5) — VIOLATION, pinned it.fails", () => { + // Reproduces on `next`: the flush right after `dispose()` trips the + // quiescence invariant (the shadow holds the pre-refetch value, is not + // dirty, and the leaf's committed value differs). A microtask later the + // shadow is re-derived and the same check passes — so this is a window, + // not a permanent divergence; under __TEST__ the window is fatal. + it.fails( + "the flush right after disposing a projection with a held refetch passes the quiescence invariants", + async () => { + const [q, setQ] = createSignal(0); + const fetches: Array<() => void> = []; + let s!: { n: number }; + const dispose = createRoot(d => { + [s] = createStore<{ n: number }>( + () => { + const v = q(); + return new Promise(r => fetches.push(() => r({ n: v * 10 }))); + }, + { n: -1 } + ); + createRenderEffect( + () => s.n, + () => {} + ); + return d; + }); + flush(); + fetches.shift()!(); + await settle(); + setQ(1); // refetch, never lands + flush(); + expect(latest(() => s.n)).toBe(0); // creates the leaf's shadow + dispose(); + expect(() => flush()).not.toThrow(); // INV-4 here on next + } + ); +}); diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts index d9035618d..633619f84 100644 --- a/packages/signals/tests/visibility-oracle-posture.test.ts +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -138,7 +138,7 @@ function enter(posture: Posture, build: () => void): { y: (() => number) | null return d; }); disposers.push(dispose); - flush(); + gflush(); if (view !== "fallback") throw new Error("behindFallback posture: fallback not showing (" + String(view) + ")"); return { y: null }; @@ -161,6 +161,32 @@ function enter(posture: Posture, build: () => void): { y: (() => number) | null } const disposers: Array<() => void> = []; +/** Discovery mode must not die on a __TEST__ invariant: record the first + * INVARIANT_VIOLATION a step raises for the cell's `invariant` column and let + * the runtime recover. (A stale companion left by an earlier cell can trip + * the quiescence check at a later cell's first flush — attribution is by + * cell order, and the dedicated pins name the bug precisely.) */ +let cellInvariant: string | undefined; +function guard(fn: () => T): T | undefined { + try { + return fn(); + } catch (e) { + const m = String((e as Error)?.message ?? e); + if (!m.startsWith("[INVARIANT_VIOLATION]")) throw e; + cellInvariant ??= m.slice( + "[INVARIANT_VIOLATION] ".length, + "[INVARIANT_VIOLATION] ".length + 60 + ); + return undefined; + } +} +const gflush = () => guard(flush); +const gsettle = async () => { + await Promise.resolve(); + await Promise.resolve(); + gflush(); +}; + type Row = { state: string; posture: Posture; @@ -170,10 +196,12 @@ type Row = { afterSourceRelease: Cell; // untracked x() after ONLY the source's holds are released foreignStillHeld: boolean | null; // y() still 0 (the foreign action did not settle) afterGate?: string; // gatedAway: `x / source / isPending` after the gate closes, before any release + invariant?: string; // an INVARIANT_VIOLATION raised by this cell's own quiescence (attributed here, not to the next cell) }; const rows: Row[] = []; async function cell(state: State, posture: Posture, reader: Reader): Promise { + cellInvariant = undefined; const built = state.build(() => {}); const { x, dispose, source, perturb } = built instanceof Promise ? await built : built; const [show, setShow] = createSignal(true); @@ -249,10 +277,10 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise isPending(x)))}`; } if (posture === "disposedReader") { for (const d of disposers.splice(0)) d(); - flush(); + gflush(); } // Entanglement probe: release ONLY the source's holds. for (const r of holds.splice(0, sourceHolds)) r(); - await settle(); - await settle(); + await gsettle(); + await gsettle(); const afterSourceRelease = classify(x); const foreignStillHeld = y ? y() === 0 : null; dispose(); - await releaseAll(); + for (const r of holds.splice(0)) r(); + await gsettle(); + await gsettle(); for (const d of disposers.splice(0)) d(); - flush(); + gflush(); + // Force this cell's quiescence check now (it runs only on a flush with no + // parked transaction): an unrelated write, then a synchronous flush. + const [, poke] = createSignal(0); + poke(1); + gflush(); + const invariant = cellInvariant; + cellInvariant = undefined; return { state: state.name, posture, @@ -292,7 +329,8 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise { for (const posture of POSTURES) for (const reader of READERS) { if (posture === "gatedAway" && reader !== "memo" && reader !== "effect") continue; // a gate needs a tracked reader + // INV-4 (spec O5, pinned it.fails in posture-store-parity.test.ts): a + // projection leaf's latest() shadow is stale on the flush right after + // its root is disposed. Under __TEST__ the runtime's own scheduled + // flush throws and the scheduler is left mid-flush, poisoning every + // later cell. Excluded until fixed; the pin names the bug. + if (posture === "gatedAway" && state.name.startsWith("derived store (projection)")) + continue; it(`${state.name} × ${posture} × ${reader}`, async () => { rows.push(await cell(state, posture, reader)); expect(true).toBe(true); @@ -313,9 +358,9 @@ describe("visibility oracle — posture matrix (discovery)", () => { for (const state of STATES) { out.push(`## ${state.name}`, ""); out.push( - "| reader | posture | served | memo pass | x after source release | foreign still held | after gate (x / source / isPending) |" + "| reader | posture | served | memo pass | x after source release | foreign still held | after gate (x / source / isPending) | invariant |" ); - out.push("|---|---|---|---|---|---|---|"); + out.push("|---|---|---|---|---|---|---|---|"); for (const reader of READERS) { const base = rows.find( r => r.state === state.name && r.reader === reader && r.posture === "mainline" @@ -331,7 +376,7 @@ describe("visibility oracle — posture matrix (discovery)", () => { fmt(r.afterSourceRelease) !== fmt(base.afterSourceRelease); const servedDiff = base && posture !== "mainline" && fmt(r.served) !== fmt(base.served); out.push( - `| ${reader} | ${posture} | ${fmt(r.served)}${servedDiff ? " **≠**" : ""} | ${fmt(r.passValue)} | ${fmt(r.afterSourceRelease)}${entangled ? " **ENTANGLED**" : ""} | ${r.foreignStillHeld === null ? "—" : r.foreignStillHeld} | ${r.afterGate ?? "—"} |` + `| ${reader} | ${posture} | ${fmt(r.served)}${servedDiff ? " **≠**" : ""} | ${fmt(r.passValue)} | ${fmt(r.afterSourceRelease)}${entangled ? " **ENTANGLED**" : ""} | ${r.foreignStillHeld === null ? "—" : r.foreignStillHeld} | ${r.afterGate ?? "—"} | ${r.invariant ? "**" + r.invariant + "**" : "—"} |` ); } } From 544501feee89aac462c5f684c0e10e02d04fbf3e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 05:24:20 -0700 Subject: [PATCH 2/4] =?UTF-8?q?test(signals):=20INV-4=20after=20disposing?= =?UTF-8?q?=20a=20projection=20mid-refetch=20=E2=80=94=20pinned=20it.fails?= =?UTF-8?q?=20in=20its=20own=20file=20(spec=20O5);=20size=20caps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the O3 fix: with the parked-transaction leak gone, the posture matrix's quiescence checks run again and INV-4 fires on a pre-existing window — the synchronous flush right after disposing a projection store with a held refetch and a latest() companion on a leaf. Externally the leaf and its latest() agree; the stale pair is an internal companion owner, and the window closes a microtask later. Pinned in its own file because a live action in a sibling test masks the quiescence check the same way the leak did. Two brotli caps +50 B for the O3 fix (0 B minified in the floor). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- .../inv4-projection-dispose-shadow.test.ts | 62 +++++++++++++++++++ .../tests/posture-store-parity.test.ts | 55 +--------------- scripts/size/.size-limit.js | 9 ++- 5 files changed, 75 insertions(+), 55 deletions(-) create mode 100644 packages/signals/tests/inv4-projection-dispose-shadow.test.ts diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 2a23efee7..d293f5c46 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -115,7 +115,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:147` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | | INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:149` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | | INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:153` | boundaries.ts×1 core.ts×2 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | -| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:160` | invariants.ts×3 | posture-store-parity.test.ts×3 visibility-oracle-posture.test.ts×1 | - **INV-4 (medium)** After any of the three write paths completes for node `el` | +| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:160` | invariants.ts×3 | inv4-projection-dispose-shadow.test.ts×4 posture-store-parity.test.ts×1 visibility-oracle-posture.test.ts×1 | - **INV-4 (medium)** After any of the three write paths completes for node `el` | | INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:164` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | | INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:170` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | | INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:173` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index bf3b33a5e..816a8edb7 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -328,7 +328,7 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H ### O5. INV-4 — a projection leaf's `latest()` shadow is stale on the flush right after its root is disposed mid-refetch — violation, open -**Status:** **violation, recorded** 2026-09-16 — surfaced by O3's fix: the posture matrix had been leaking parked transactions (dead reporters never re-judged), which kept `transitions.size > 0` and silenced every quiescence invariant for the rest of the run. With the leak gone, INV-4 fires. Pre-existing on `next` (standalone repro: projection store with a held refetch, `latest()` read of a leaf, `dispose()`, synchronous `flush()`); pinned `it.fails` in `tests/posture-store-parity.test.ts` (S3). Transient — the shadow is re-derived a microtask later — but under `__TEST__` a throw from the runtime's own scheduled flush leaves the scheduler mid-flush, so the matrix excludes the two triggering cells (`gatedAway` × the projection states) until fixed. +**Status:** **violation, recorded** 2026-09-16 — surfaced by O3's fix: the posture matrix had been leaking parked transactions (dead reporters never re-judged), which kept `transitions.size > 0` and silenced every quiescence invariant for the rest of the run. With the leak gone, INV-4 fires. Pre-existing on `next` (standalone repro: projection store with a held refetch, `latest()` read of a leaf, `dispose()`, synchronous `flush()`); pinned `it.fails` in `tests/inv4-projection-dispose-shadow.test.ts` (its own file: a live action in a sibling test masks the check). Externally the leaf and its `latest()` agree throughout — the stale pair is an internal companion owner. Transient — the shadow is re-derived a microtask later — but under `__TEST__` a throw from the runtime's own scheduled flush leaves the scheduler mid-flush, so the matrix excludes the two triggering cells (`gatedAway` × the projection states) until fixed. **Where to look:** the disposal snap (`disposeChildren` → `_snapCompanions`) covers the disposed computed's own companions; a projection's leaves hang off the firewall, not the child chain, and their companions are re-derived only by the scheduled pass that follows. ## Superseded rules (kept verbatim) diff --git a/packages/signals/tests/inv4-projection-dispose-shadow.test.ts b/packages/signals/tests/inv4-projection-dispose-shadow.test.ts new file mode 100644 index 000000000..76db8dfd2 --- /dev/null +++ b/packages/signals/tests/inv4-projection-dispose-shadow.test.ts @@ -0,0 +1,62 @@ +/** + * INV-4 after disposing a projection mid-refetch — VIOLATION, pinned it.fails + * (spec O5; posture matrix S3). + * + * Reproduces on `next`: with a projection store's refetch held and a + * `latest()` companion created on a leaf, the synchronous flush right after + * `dispose()` trips the __TEST__ quiescence invariant INV-4 ("latest() shadow + * holds a stale committed value for a settled node"). Externally the leaf and + * its shadow agree throughout (`s.n` and `latest(() => s.n)` both read the + * committed 0), so the stale pair is an INTERNAL companion owner — the + * projection's firewall node is the candidate — and the window closes a + * microtask later. Under __TEST__ the runtime's own scheduled flush throws in + * that window and leaves the scheduler mid-flush, which is what poisoned the + * posture matrix once #3488 / O3 stopped leaking the parked transactions that + * had kept `transitions.size > 0` and silenced every quiescence check. + * + * Own file: a live action in a sibling test would mask the check the same way. + */ +import { expect, it } from "vitest"; +import { + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + latest +} from "../src/index.js"; + +it.fails( + "the flush right after disposing a projection with a held refetch passes the quiescence invariants (INV-4)", + async () => { + const [q, setQ] = createSignal(0); + const fetches: Array<() => void> = []; + let s!: { n: number }; + const dispose = createRoot(d => { + [s] = createStore<{ n: number }>( + () => { + const v = q(); + return new Promise(r => fetches.push(() => r({ n: v * 10 }))); + }, + { n: -1 } + ); + createRenderEffect( + () => s.n, + () => {} + ); + return d; + }); + flush(); + fetches.shift()!(); + for (let i = 0; i < 3; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } + setQ(1); // refetch, never lands + flush(); + expect(latest(() => s.n)).toBe(0); // creates the leaf's companion + dispose(); + expect(() => flush()).not.toThrow(); // INV-4 here on next + expect(latest(() => s.n)).toBe(s.n); // (externally the two agree — the stale pair is internal) + } +); diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index fc4cb88f6..7373cfcd3 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -13,13 +13,9 @@ * the content pass entered the hold, the creation direct-committed). * Mainline creation over the same value is born held (A29). * - * S3 — VIOLATION (INV-4), pinned it.fails: a projection leaf's latest() - * shadow is stale on the flush right after the store's root is disposed - * while a refetch is held. Transient (it recovers a microtask later), - * but a __TEST__ quiescence check in that window throws — and a throw - * from the runtime's own scheduled flush leaves the scheduler mid-flush. - * Surfaced when #3488/O3 stopped leaking the parked transactions that - * had masked every quiescence check in the posture matrix. Spec O5. + * S3 — INV-4 after disposing a projection mid-refetch: its own file, + * tests/inv4-projection-dispose-shadow.test.ts (the live actions S1/S2 + * leave behind would mask the quiescence check here). Spec O5. * * (A first cut also reported the projection's seed leaking as a value inside * boundary content, and `isPending` false / override invisible behind a @@ -41,13 +37,6 @@ import { latest } from "../src/index.js"; -const settle = async () => { - for (let i = 0; i < 3; i++) { - await new Promise(r => setTimeout(r, 0)); - flush(); - } -}; - const never = () => new Promise(() => {}); describe("S1 — adopted, unflushed: verdict channels inside the adopting action (A28 (1)/(2)) — signal vs store", () => { @@ -131,41 +120,3 @@ describe("S2 — creation in boundary content over a held value publishes it (OB expect(s.n).toBe(0); }); }); - -describe("S3 — INV-4: a projection leaf's latest() shadow after its root is disposed mid-refetch (spec O5) — VIOLATION, pinned it.fails", () => { - // Reproduces on `next`: the flush right after `dispose()` trips the - // quiescence invariant (the shadow holds the pre-refetch value, is not - // dirty, and the leaf's committed value differs). A microtask later the - // shadow is re-derived and the same check passes — so this is a window, - // not a permanent divergence; under __TEST__ the window is fatal. - it.fails( - "the flush right after disposing a projection with a held refetch passes the quiescence invariants", - async () => { - const [q, setQ] = createSignal(0); - const fetches: Array<() => void> = []; - let s!: { n: number }; - const dispose = createRoot(d => { - [s] = createStore<{ n: number }>( - () => { - const v = q(); - return new Promise(r => fetches.push(() => r({ n: v * 10 }))); - }, - { n: -1 } - ); - createRenderEffect( - () => s.n, - () => {} - ); - return d; - }); - flush(); - fetches.shift()!(); - await settle(); - setQ(1); // refetch, never lands - flush(); - expect(latest(() => s.n)).toBe(0); // creates the leaf's shadow - dispose(); - expect(() => flush()).not.toThrow(); // INV-4 here on next - } - ); -}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index d9be74b6a..95276b165 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -1175,6 +1175,10 @@ module.exports = [ // the core walks it on solid-js's server owners (`ownerPath`, // `OBSERVE.exclude`). ~+40 B across the prod scenarios; the observe ones // moved by gzip noise or shrank. + // Reporter liveness reads this pass's deps; a dropped dep retires the reporter + // and wakes every parked transaction (fuzzer #3446 P1, spec O3, 2026-09-16): + // 15,170 B before the #3496 rebase (+30 over its base); 0 B minified in + // the signals floor (24,578 flat). limit: "15.20 KB", modifyEsbuildConfig }, @@ -1280,7 +1284,10 @@ module.exports = [ // ~+170 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "16.70 KB", + // Reporter liveness reads this pass's deps; a dropped dep retires the reporter + // and wakes every parked transaction (fuzzer #3446 P1, spec O3, 2026-09-16): + // 16,730 B (+40 over base); 0 B minified in the signals floor (24,578 flat). + limit: "16.75 KB", modifyEsbuildConfig: observeEsbuildConfig }, { From c7ec862fb6b966854e8e71f1846b18e1f8fdcf39 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 08:14:48 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20restore=20#3491's=20docstrings=20and?= =?UTF-8?q?=20changeset,=20drop=20the=20design=20doc=20=E2=80=94=20both=20?= =?UTF-8?q?were=20swept=20in=20by=20a=20soft-reset=20squash=20from=20a=20p?= =?UTF-8?q?re-#3491=20working=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .changeset/action-await-yield-docs.md | 5 + packages/signals/docs/DESIGN-CONSOLIDATION.md | 122 ------------------ packages/signals/src/core/action.ts | 17 ++- packages/signals/src/signals.ts | 8 ++ 4 files changed, 25 insertions(+), 127 deletions(-) create mode 100644 .changeset/action-await-yield-docs.md delete mode 100644 packages/signals/docs/DESIGN-CONSOLIDATION.md diff --git a/.changeset/action-await-yield-docs.md b/.changeset/action-await-yield-docs.md new file mode 100644 index 000000000..60a5e88dd --- /dev/null +++ b/.changeset/action-await-yield-docs.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Docs: inside an `action`, a bare `yield` is required after an `await` before anything that creates a reader — `until()`, `latest()`, a memo or effect, a mount — not only before writes. The `until()` docstring's own example had `await` straight into `yield until(...)`; the `until(...)` expression is evaluated in the post-`await` continuation, outside the transaction, and its predicate reader is born held there (#3482). Example corrected. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md deleted file mode 100644 index 66ff186ce..000000000 --- a/packages/signals/docs/DESIGN-CONSOLIDATION.md +++ /dev/null @@ -1,122 +0,0 @@ -# Consolidation — one implementation per rule - -**Status:** design, 2026-09-16. Read-only pass over `next` at `5fa224a4a` (#3479 in). Nothing here is implemented. Written for a decision, not as a plan of record. - -## 1. Why - -The last two months' async fixes are ~four rules, each fixed several times at different sites: - -| Rule (stated per outcome) | Sites that each decide it (enforced per site) | Fixes to the same rule | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| Which value does a reader off the hold see | `readNodeFast`, `read` fast block, `read` slow tail, `overrideRead`, `latestRead`, `gatedRead`, `laneReadsCommitted`, `readsHeldCommitted`, store `nodeValue` / `serveDataKey` / `pendingBackingVisible` / `heldFromReader` / `visibleOverride` / `optimisticView` | #3330 #3334 #3460 (two places) A29 (three sites) A28 (four sites) | -| Is this reporter live | `reporterBlocksSource` (one predicate, five stand-ins) + three independent _wake_ sites: `disposeChildren`, `recompute` tail, boundary reset | #3372 #3375 #3426 #3458 #3463 #3488 | -| Dependencies are the committed frame's (A30) | `commitPendingNode` trim, `runEffect` trim, `heldTrims` (unchanged pass), `trimStaleDeps` at pass end | #3410 #3438 #3461 #3469 | -| Decided at the pass, known at the verdict | `CONFIG_HELD_CHILDREN`+`_pendingFirstChild`/`_pendingDisposal`, `_modified`+`_queueStash`, `heldRevealed`, `_gatedSubs`, `heldTrims`, `_contested`, `_flushedStaged` | one bespoke mechanism per fix | - -Two instruments now exist that did not when those fixes were written: the posture matrix (621 enumerated cells: state × posture × reader → served value, entanglement) and the semantic fuzzer (#3446, 20 laws over generated graphs; baseline on this `next`: 984 pass / 4 fail / 12 policy). Both say "zero semantic change" is now a checkable claim rather than a hope, which is the precondition for any of what follows. - -Three open violations are the concrete targets; each is a consequence of the site count: - -- **O3, same-flush form** (fuzzer P1, cases 21/79; pinned `it.fails`): gate closes and source is written in one flush → the reporter's pass runs under the transaction and _stages_ its value, so A30 keeps its previous dep on the memo linked past `_depsTail` until commit; `reporterBlocksSource`'s deps scan walks the whole list, finds the kept dep, and calls the reporter live. The hold keeps the dep that keeps the hold. Rule 2's predicate reading Rule 3's deferral — verified by probe 2026-09-16 (pass ran once; not pending; `_pendingValue = "hidden"`; deps `[show, memo]`, tail after `show`). -- **O4 / S1** (pinned `it.fails`): after same-tick adoption, the signal's `unflushedValue` reads a stamped node with no stash as "flushed, held" and `latest`/`isPending` see a write no flush carried; the store's `flushedStaged` path does not. Two definitions of "unflushed". Rule 1. -- **O2** (recorded, not ruled): creation under a transaction / in boundary content escapes the hold while mainline creation is born held. One rule (A29) implemented at one of its sites. Rule 1 / Rule 4. A ruling question first — the consolidation makes whichever answer is chosen hold everywhere. - -## 2. Inventory (as of `5fa224a4a`) - -Condensed from a read-only walk; line numbers are approximate to ±5 and will drift. - -### Rule 1 — value selection - -Core, in evaluation order per site: - -- `readNodeFast` (`core.ts` ~1699–1737): bail gate → `READ_SLOW` on any special mode (`latestReadActive`, `pendingCheckActive`, `_fn`, `_firewall`, override, snapshot, `activeTransition`, lane, `unflushedStaged && pending`, strict); else link; then **T1**: `!c || pending === NOT_PENDING || CHILDREN_FORBIDDEN || (stale && heldFromStale)` → `_value`, else `enterStagedRead; _pendingValue`. -- `read` fast block (~1739–1783): same eligibility, same **T1** verbatim. -- `read` slow tail (~1966–1995): `noCommitted && !c` → throw; `unflushedValue` arm (A28) → committed / stash + `markLateLinker`; then **T1 extended**: `+ laneReadsCommitted`, `+ (CONFIG_HELD_TRUTH && !latest && !AUTHORITATIVE)`, `+ !noCommitted` guard on the stale arm. -- `read` override arm (~1912–1938): active override, not authoritative, `!unflushedOverride` → tracked with lane/superseded → `overrideRead`, else `unwrapOverride`. -- `read` pending arm (~1821–1878): stale carve-out (`!UNINITIALIZED && !INPUTS_PUBLISHED && !laneLive && heldFromStale`) → committed; else throw / `laneSuspends`. -- `heldFromStale` (~1544–1555): foreign transaction → true, with side registration into `_gatedSubs` / `_asyncReporters`. -- `enterStagedRead` (~1578–1611): A29 entry; companion/verdict exemptions; born-held record for mainline creation. -- `unflushedValue` / `unflushedOverride` (~1640–1663): A28. -- `overrideRead` (`optimistic.ts` ~429–445): `stale && readsHeldCommitted` → `_value`; not superseded → override; stale foreign owner → override; else enter + pending/committed. -- `gatedRead` (~556–567), `laneReadsCommitted` (~573–614), `readsHeldCommitted` (`lanes.ts` ~143–154): lane-side "prefer committed" with `_gatedSubs` registration. -- `latestRead` (`verdict.ts` ~479–551), `flushedStaged` (~170–176), `computePendingState` (~259–314): verdict channels; re-derive visible override, unflushed, stale-foreign, shadow pending. - -Store twins (`store/next/store.ts`, `optimistic.ts`): `heldFoldTransition` / `foreignHold` / `heldFromReader` (≡ `heldFromStale` for backings), `readSource` + `pendingBackingVisible` (≡ T1 extended for backings, plus draft / write-override / opt-family arms), `heldTruthMasked` (≡ HELD_TRUTH arm), `visibleOverride` (≡ override arm's `unflushedOverride` gate), `nodeValue` (untracked view: override → pending → backing), `serveDataKey` (per-key: length / opt / draft overlay, then `readNodeFast`/`readNode` tracked or `nodeValue` untracked), `optimisticView` (deep compose of flushed overrides). - -**Duplicated conditions (each is a place a rule change must be threaded by hand):** T1 ×2 verbatim, T1-extended ×1 + store backing twin; stale-foreign → committed ×5; CHILDREN*FORBIDDEN → committed ×3; A28 unflushed ×6 call sites over two helpers (signal) plus `flushedStaged` (verdict) — and the store gets a \_different* answer for adopted nodes (S1); override-vs-truth ×3; HELD_TRUTH mask ×2; `enterStagedRead` on staged serve ×4. - -### Rule 2 — reporter liveness - -- Predicate: `reporterBlocksSource` (`scheduler.ts` ~1499–1542): DISPOSED → dead; ZOMBIE → walk to non-zombie parent, judge by its transaction vs verdict; boundary walk (`_collectionType & PENDING && !_initialized`) → dead (A33); `_pendingSources.has(source)` → live; deps scan through `_parentSource`/`_firewall` → live; `pending && _error.source === source` → live. Callers: `sourceObserved` → `transitionComplete`, `waitingTransition`, `enterWaiting`, `_endOptimism`, `_transitionBlocked`. -- Registration: `notify` (~897–931, INV-3), `heldFromStale`, store optimistic path. -- **Events that retire a reporter, each pushing `wokenTransitions` independently:** `disposeChildren` (`owner.ts` ~86, #3372), `recompute` tail (`core.ts` ~727, #3488), boundary reset → `wakeParked` (`boundaries.ts` ~319). Consumed in flush's `finally` on an otherwise idle pass (~892). -- **Verdict placement:** `transitionComplete` at ~774, after `runHeap(dirtyQueue)` and **before** effects; on incomplete: `stashQueues` (~805) parks the _entire_ render/user queues, `finalizePureQueue(null, true)`, return. This ordering is O3's same-flush form. - -### Rule 3/4 — deferred decisions (one structure each today) - -| Decision recorded at the pass | Carrier | Applied at commit | Dropped at park | -| ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------- | -| Staged value | node `_pendingValue`, `t._pendingNodes` | `commitPendingNode(s)` | kept (re-stamped) | -| Children of a held pass | `CONFIG_HELD_CHILDREN`, `_x._pendingFirstChild/_pendingDisposal`, zombie heap | `_dispose(zombie=true)` in `commitPendingNode` | zombie heap cancelled if batch===txn; immediate on re-run | -| Effect run owed | `_modified`, `_queueStash` | `restoreQueues` → `runEffect` (re-enqueues if `_valueTransition` open) | stashed whole-queue | -| Deps trim (A30) | `_depsTail`, module `heldTrims[]` | `commitPendingNodes` drains; `runEffect` trims; per-node in `commitPendingNode` | `heldTrims.length = 0` | -| Gated / stale readers to replay | `t._gatedSubs` | `finalizePureQueue` enqueue+clear | kept; merged on merge; adopted from ambient | -| Held-truth reveal | node `CONFIG_HELD_TRUTH`, module `heldRevealed[]` | post-`_resolveOptimistic` `insertSubs` | never (park never commits) | -| Cross-txn effect write | `t._contested` | `finalizePureQueue` enqueue | merged | -| Held rewrite's flushed value (A28) | `_x._flushedStaged`, module `unflushedRewrites[]` | cleared at flush start | n/a (tick-local) | - -Ambient `_batch` and a live `Transition` already share the same field set — the "cargo" concept exists; it has no lifecycle API. - -## 3. Shape - -Three moves, ordered by blast radius. Each is zero-semantic-change by construction _except_ where it closes a pinned violation, and each is gated on the matrix, both oracles, and the fuzzer baseline before and after. - -### 3a. Rule 2 — one retirement event, one verdict placement - -**`retireReporter(reporter)`** — a single entry point that the three wake sites call instead of pushing `wokenTransitions` themselves. Body is today's dedup'd push + `schedule()`, keyed on `reporter._transition`. Pure refactor; the three call sites lose their inline copies. - -**Same-flush O3.** A first reading of this case blamed effect parking (the run stashed, never proving itself dead). The probe says otherwise: the compute ran, staged `"hidden"`, and the effect is judged live only because `reporterBlocksSource`'s deps scan reads the dep A30 deliberately keeps linked past `_depsTail` for the commit to trim. Two rules, one wrong answer; the fix is in the predicate, not the scheduler: - -_Liveness reads the pass's deps, not the committed frame's._ For a reporter with a staged pass (`_pendingValue !== NOT_PENDING`), the deps scan stops at `_depsTail` — the dependencies this pass actually read. A kept tail is the committed frame's business (A30: a write to a dep the committed value still derives from must reach the node) and says nothing about whether the reporter still observes the flight. One predicate, one line, and it is the `readerLive()` consolidation's first concrete content: the predicate must know which frame it is asking about. - -Case 21 then resolves without any parking change: the pass no longer reads the memo → not live → the transaction completes at the verdict already in place, the same flush. Stash-by-world (`#3407` applied to `stashQueues`) is _not_ needed for this and should not be done on its account; it remains a separate question (§6). - -### 3b. Rule 1 — shared predicates, then one `serve` - -Not a single `serve()` first. The perf constraint is hard and measured (2026-09-15): `readNodeFast` past ~460 B of bytecode, or a call on its staged branch, costs 10–15% on propagation; `setSignal` past the inline budget costs 10–20% on the write loop. The fast path must stay a tiny inlinable guard that handles the trivial case and bails. Value selection therefore has exactly **two** implementations by design — the fast ternary (T1) and one slow `serve` — and the target is to make the third-through-eleventh disappear, not the second. - -Step 1 — **shared predicates**, no behavior change: `readerSeesCommitted(el, c)` (= T1-extended's disjunction, including HELD_TRUTH and lane arms), `visibleOverride(el)` (already exists store-side; core's override arm inlines the same test), `unflushed(el)` with **one** definition used by `unflushedValue`, `flushedStaged`, `pendingBackingVisible` and `nodeValue`, and `readerClass(ctx)` — the three ways a reader relates to a hold, today spread over three unrelated flags: **derives** (a tracked pass: joins, or is born held), **displays** (a render/user effect's apply: sees the committed frame now, replays at the reveal — `_gatedSubs`), **observes** (verdict pulls `_verdictPull`, companions `_parentSource`: mirrors of the flushed world, never join). Direct-commit readers (`CONFIG_DIRECT_COMMIT` — `resolve()`/`until()`) are **not** observers: they are derivers with a tunnel _inside their own transaction_ (the arm that lets a hold not deadlock on its own acknowledgment), and from mainline over a **foreign** hold they wait for the commit like any deriver — #3492 pins that a mainline `resolve()` must never resolve with an unrelated action's unrevealed frame, which is exactly what exempting them from born-held (#3490) leaked. `enterStagedRead`, `heldFromStale` and `recompute`'s commit arm each test a different subset of these flags today. This step closes **S1**: "unflushed" = staged outside a flush and not yet carried by one, whatever the stamp — one predicate, so the signal and the store cannot disagree. Mechanism: `queuePendingNode` outside a flush already sets `unflushedStaged`; a per-node bit set there and cleared by the carrying flush (`resyncUnflushedCompanions` walks the batch's pending nodes — it is already the flush-start hook) makes adoption irrelevant to the test. - -Step 2 — **`serve(el, reader)`** as the slow tail: `read`'s slow arms, `overrideRead`, `latestRead`'s value selection and the store's `nodeValue`/`readSource` value decision call it; the store keeps its structural arms (draft overlay, length, chained, opt family) and delegates the _value_ decision. `gatedRead`/`laneReadsCommitted`/`readsHeldCommitted` fold into `readerSeesCommitted` with their `_gatedSubs` registration as a side effect of the predicate, as `heldFromStale` already does. - -Bytes: expect roughly neutral to slightly positive. Three mechanism-preserving consolidations this month came back +13…+85 B; the pitch is one site per rule, not size. - -### 3c. Rule 3/4 — cargo lifecycle - -Give the shared batch/transaction field set the two functions it lacks: `applyCargo(t)` (today's `commitPendingNodes` + `_gatedSubs` replay + `heldRevealed` wake + `heldTrims` drain + zombie dispose, in the order `finalizePureQueue` runs them) and `dropCargo(t)` (today's park path: `heldTrims.length = 0`, zombie cancel, `stashQueues`). `heldTrims` and `heldRevealed` move from module arrays onto the transaction they belong to (a module array is only correct while one transaction commits at a time, which `finalizePureQueue` guarantees today — by accident of sequencing, not by construction). New deferrals then have exactly one place to go. - -This is the largest move and the one with the least direct violation behind it; it can wait for the first new "decided at the pass" fix to motivate it, or be done when 3a/3b have settled. - -## 4. Verification protocol (per PR) - -1. `tests/visibility-oracle.test.ts`, `-store.test.ts`: every cell unchanged. -2. `tests/visibility-oracle-posture.test.ts`: 621-cell report diffed against the pre-change report; the only permitted diffs are the cells a pinned violation says should flip. -3. Fuzzer (#3446) campaign, same seed: baseline 984 / 4 / 12; permitted change is the pinned violation's cases. -4. CodSpeed on the PR; write-loop benches (`update1to1`, `update1to1000`, `diamond`, `avoidable`) alternating pairs; `--print-bytecode` for `readNodeFast`, `read`, `setSignal`, `recompute` before/after. -5. Size: floor and the nine brotli scenarios; report the delta, do not sell it. - -## 5. Sequencing - -1. **3a** — `retireReporter` + the deps-scan bounded by `_depsTail` for staged passes. Closes O3's same-flush form (fuzzer 4 → 0 expected). Smallest blast radius; touches `reporterBlocksSource` and three wake sites. -2. **3b step 1** — shared predicates incl. one `unflushed`. Closes S1. Touches `core.ts` read arms, `verdict.ts`, store `store.ts`; no fast-path change. -3. **O2 ruling**, then whichever answer, applied once via `enterStagedRead` (born held everywhere: the `creatingPass` prototype, +83 B) or via `recompute`'s create arm (escapes everywhere: retire the mainline born-held form). -4. **3b step 2** — `serve`. -5. **3c** — cargo lifecycle, when motivated. - -## 6. Open questions for the maintainer - -- **Stash-by-world (not required for O3):** `stashQueues` parks the whole render/user queue when a transaction parks, including effects dirtied only by a mainline write in that round. #3407 read literally says those belong to mainline and should run. Not a violation anyone has pinned; flagged as a candidate rule to make explicit, not a change to make now. -- **O2:** born held everywhere, or escapes everywhere. Either is consistent; the current state (mainline held, transaction/boundary creation escapes) is the only inconsistent option. **Insight from #3482 (2026-09-16):** born-held bundles two decisions that should be separate — _ownership_ (the created value belongs to the transaction it derived from) and _application_ (skip the effect's first run, replay at commit). The ownership half was right even in #3482's misuse: the post-`await` `until()` _was_ the action's reader, and born-held correctly made it the action's. What deadlocked was a reader created in the wrong posture (post-`await`, mainline by mechanism) over its _own_ action's hold — and from mainline, waiting for the commit is the correct behavior for that reader class (#3492: a mainline `resolve()` over a foreign hold must not see the held frame; the direct-commit tunnel is only for a reader inside its own transaction). So born-held was right on both halves there; the misuse is what put the reader in a posture where "right" deadlocks, and the docs/lint are the fix. "Born held everywhere" remains the recommendation for O2's actual question — creation _under_ a transaction / in boundary content — ownership and application both following derivation, as mainline creation already does (the `creatingPass` prototype, +83 B). The `CONFIG_DIRECT_COMMIT` exemption proposed in #3482 is declined on the evidence, not on taste. A further argument for posture-independence: the posture is exactly what users get wrong (`await` vs `yield`), so a rule that changes with the posture turns a documentation slip into a semantic one. -- **`readsHeldCommitted` and the lane arms:** folding them into `readerSeesCommitted` assumes lanes are "a transaction with an override"; if lanes are meant to diverge from transactions later, keep them as a separate predicate that `serve` consults. -- **Post-`await` posture:** pinned by #3492 for the direct-commit readers (three postures: own step, own `await` continuation, foreign mainline) as a standalone file. Worth folding into the matrix as a posture (`ownActionAfterAwait`) so the other reader kinds get the same rows; not urgent. -- **Loosening:** once `serve` exists, each of its arms is a constraint with a measurable blast radius (flip it, rerun the matrix). Candidates surfaced so far: O2 (two born-held forms → one), the `CONFIG_HELD_TRUTH` mask (one arm, two sites), and the stale-foreign carve-out in the pending arm (`INPUTS_PUBLISHED`), which exists to serve one shape (#3305). diff --git a/packages/signals/src/core/action.ts b/packages/signals/src/core/action.ts index b43fcf668..318c8a5cb 100644 --- a/packages/signals/src/core/action.ts +++ b/packages/signals/src/core/action.ts @@ -64,15 +64,22 @@ function restoreTransition(seq: number, transition: Transition, fn: () => T): * `yield` is the transaction-safe suspension point: the action waits for a * yielded promise and re-enters the transaction before running the code after * it. A plain `await` does NOT — the runtime has no hook into an async - * generator's internal await continuations, so writes to fresh signals - * between an `await` and the next `yield` escape the transaction and commit - * immediately. `await` is still the ergonomic choice for typed results; just - * put a bare `yield` before any writes that follow it: + * generator's internal await continuations, so code between an `await` and + * the next `yield` runs OUTSIDE the transaction: writes to fresh signals + * commit immediately, and anything that creates a reader there — `until()`, + * `latest()`, a memo or effect, a mount — is created mainline, where a read of + * this action's held state makes it born held (A29): staged with the + * transaction and replayed at its commit. For `until()` that commit is the + * settle its own promise holds open (#3482). `await` is still the ergonomic + * choice for typed results; just put a bare `yield` before any write or + * reader creation that follows it — including the expression of the next + * `yield`, which is evaluated before the step re-enters: * * ```ts * const saved = await api.createTodo(text); // typed result - * yield; // re-enter the transaction before writing + * yield; // re-enter the transaction before writing or reading * setTodos(t => { ... }); + * yield until(() => todos.some(t => t.id === saved.id)); * ``` * * (For the same reason, don't call `flush()` inside an action body — it diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 513851e25..8aa09ef00 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -932,12 +932,20 @@ export interface UntilOptions { * * Must be called *outside* a tracking scope. * + * Inside an action, call it from a step: after an `await`, put a bare `yield` + * before `yield until(...)`. The runtime cannot hook an async generator's + * `await` continuation, so the `until(...)` expression — which CREATES the + * predicate's reader — would otherwise run outside the transaction; created + * there it is born held (A29) and replays only at the commit its own promise + * holds open (#3482). See {@link action}. + * * @example * ```ts * const send = action(async function* (text: string) { * const clientId = crypto.randomUUID(); * setMessages(m => { m.push({ clientId, text, pending: true }); }); // optimistic * await socket.send({ clientId, text }); // fire-and-forget transport + * yield; // re-enter the transaction after the await * // Hold until the live source echoes the write (authoritative view — * // the optimistic row above cannot satisfy this): * yield until(() => messages.some(m => m.clientId === clientId), { timeout: 10_000 }); From 0e80ca704ffe23c3cee4b61758f3f582c9c4f3dd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 16:14:41 -0700 Subject: [PATCH 4/4] chore: refresh #3495 generated rules and size caps Regenerate RULES-INDEX after the reporter-liveness docs update and reconcile the combined Brotli limits after rebasing over #3496. Co-authored-by: GPT-5.6 Sol via Cursor Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- scripts/size/.size-limit.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d293f5c46..87371b76b 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×5 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×2 effect.ts×1 scheduler.ts×4 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 95276b165..f1d1a1fe9 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -275,7 +275,10 @@ module.exports = [ // `_laneOverride` engine hook and its override test admits a derived one; // the rest (laneOverride, the derived arms in the verdict, lane and status // modules) sheds with the engine. - limit: "9.35 KB", + // Reporter-liveness fix rebased over `_parent` mangling (#3495 + #3496, + // 2026-09-16): measured at 9,379 B. The signals floor is unchanged + // minified; the combined property names shift brotli layout. + limit: "9.38 KB", modifyEsbuildConfig }, { @@ -1070,7 +1073,9 @@ module.exports = [ // ~+250 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "29.90 KB", + // Reporter-liveness fix rebased over `_parent` mangling (#3495 + #3496, + // 2026-09-16): measured at 29,903 B; combined brotli layout drift. + limit: "29.91 KB", modifyEsbuildConfig }, { @@ -1178,8 +1183,9 @@ module.exports = [ // Reporter liveness reads this pass's deps; a dropped dep retires the reporter // and wakes every parked transaction (fuzzer #3446 P1, spec O3, 2026-09-16): // 15,170 B before the #3496 rebase (+30 over its base); 0 B minified in - // the signals floor (24,578 flat). - limit: "15.20 KB", + // the signals floor (24,578 flat). Combined with `_parent` mangling: + // 15,250 B; cap ratcheted to the measured output. + limit: "15.25 KB", modifyEsbuildConfig }, { @@ -1407,7 +1413,9 @@ module.exports = [ // ~+160 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "28.35 KB", + // Reporter-liveness fix rebased over `_parent` mangling (#3495 + #3496, + // 2026-09-16): measured at 28,363 B; combined brotli layout drift. + limit: "28.37 KB", modifyEsbuildConfig: observeEsbuildConfig }, {