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/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 09e6e809f..87371b76b 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… | @@ -74,7 +74,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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… | +| 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 | 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 52ac30b53..816a8edb7 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/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) Cited by tests and by A24's reasoning; the statements below are as they stood when superseded. 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/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-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..7373cfcd3 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -13,6 +13,10 @@ * the content pass entered the hold, the creation direct-committed). * Mainline creation over the same value is born held (A29). * + * 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 * fallback. All three were a runner artifact — the boundary content re-ran 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 + "**" : "—"} |` ); } } diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index d9be74b6a..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 }, { @@ -1175,7 +1180,12 @@ 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. - limit: "15.20 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): + // 15,170 B before the #3496 rebase (+30 over its base); 0 B minified in + // the signals floor (24,578 flat). Combined with `_parent` mangling: + // 15,250 B; cap ratcheted to the measured output. + limit: "15.25 KB", modifyEsbuildConfig }, { @@ -1280,7 +1290,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 }, { @@ -1400,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 }, {