From e9c464bf6f32915ea353aceb860767f87a6f789b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 02:32:35 -0700 Subject: [PATCH 1/3] docs(signals): a bare `yield` after `await` guards reader creation too, not only writes (#3482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action docstring said to put a bare `yield` before any WRITES that follow an `await`. The rule is broader: code between an `await` and the next `yield` runs outside the transaction, so anything that creates a reader there — until(), latest(), a memo or effect, a mount — is created mainline and, reading this action's held state, is born held (A29): staged with the transaction and replayed at its commit. For until() that commit is the settle its own promise holds open; the action deadlocks (#3482). The expression of the next `yield` is evaluated before the step re-enters, so `yield until(...)` directly after an `await` is the broken shape — and it was the shape the until() docstring's own example showed. Both docstrings corrected; no runtime change (the runtime cannot hook an async generator's await continuations, which is why `yield` is the step primitive). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/action-await-yield-docs.md | 5 + packages/signals/docs/DESIGN-CONSOLIDATION.md | 121 ++++++++++++++++++ packages/signals/src/core/action.ts | 17 ++- packages/signals/src/signals.ts | 8 ++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 .changeset/action-await-yield-docs.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 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 new file mode 100644 index 000000000..ef4410bb3 --- /dev/null +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -0,0 +1,121 @@ +# 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`. 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. +- **`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. +- **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 e978f867e2947256718a5240b21ab8a8babc39ea Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 02:58:27 -0700 Subject: [PATCH 2/3] docs(signals): regenerate RULES-INDEX for the A29 citations in action/until docstrings The rules index tracks src/ citations per rule; the new docstrings cite A29 from action.ts and signals.ts. Docs-only, no changeset needed beyond the branch's existing one. Co-authored-by: Claude Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index a4103ad2f..2237737eb 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 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 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… | | 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… | From 7c625c2e609b21198872309982c0bbd27c70c7be Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 03:08:54 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs(signals):=20drop=20DESIGN-CONSOLIDATIO?= =?UTF-8?q?N.md=20from=20this=20PR=20=E2=80=94=20it=20rode=20in=20from=20a?= =?UTF-8?q?=20staged=20file=20on=20another=20branch;=20it=20belongs=20to?= =?UTF-8?q?=20the=20consolidation=20design=20review,=20not=20the=20#3482?= =?UTF-8?q?=20docs=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/signals/docs/DESIGN-CONSOLIDATION.md | 121 ------------------ 1 file changed, 121 deletions(-) delete mode 100644 packages/signals/docs/DESIGN-CONSOLIDATION.md diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md deleted file mode 100644 index ef4410bb3..000000000 --- a/packages/signals/docs/DESIGN-CONSOLIDATION.md +++ /dev/null @@ -1,121 +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`. 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. -- **`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. -- **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).