diff --git a/.changeset/gated-reporter-releases-hold.md b/.changeset/gated-reporter-releases-hold.md new file mode 100644 index 000000000..6aff8ea20 --- /dev/null +++ b/.changeset/gated-reporter-releases-hold.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A render effect that stops reading a pending async memo no longer keeps the memo's source held. A pending reporter that recovers without its flight landing — its pass no longer reads the source, e.g. a `show()` gate closed — is a completion event for the transaction it reported to: `recompute` now wakes that parked transaction (`wokenTransitions`, the third site after disposal #3372 and boundary reset #3375) so it is re-judged and its held writes commit. Before, `reporterBlocksSource` already judged the effect dead but nothing re-asked the parked transaction, and an ordinary write to the source stayed staged for as long as the flight stayed up — forever when it never landed. Found by the semantic fuzzer (#3446, law P1, 21/1000 cases) and reproduced as the posture matrix's `effect × gatedAway` cell. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index da1724085..e9e5c2613 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,13 +59,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×3 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×4 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 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.test.ts×12 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×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.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.test.ts×10 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.test.ts×6 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:315` | 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:322` | — | 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:323` | 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:330` | — | 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.test.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.test.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… | @@ -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:385` | 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:395` | 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:401` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:408` | — | 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:420` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:393` | 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:403` | 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:409` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:416` | — | 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:428` | — | 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:351` | — | 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:361` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:359` | — | 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:369` | — | — | - [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 diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 7894a8366..160fac3a4 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -308,6 +308,14 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H **Current behavior:** a memo + render effect created inside a live action's body over a value another action holds (or over the superseded / body-ended states) direct-commits (`recompute`'s `create && bornHeld === null` arm — `stagedEntry` is only recorded when `activeTransition` is null) and its render effect publishes the held value, while untracked reads keep the committed frame until both actions settle. Mainline creation over the same value is born held (A29's creation-time form, 2026-09-14). Creation inside the HOLDING action's own body is unaffected: the body's write is unflushed (A28), the creation reads the committed frame. **Tension:** A29 (born held) applies to mainline creation only; the "isn't visible" premise does not hold for a render effect created in the body. A future ruling either extends A29 to every posture (`creatingPass` in `enterStagedRead`, prototyped 2026-09-15: +83 B, suite green) or states creation-escapes as the rule and re-examines the mainline form. +### O3. A render effect gated away from a never-landing flight keeps the source's write held — fixed + +**Status:** **violation, fixed** 2026-09-16 — fuzzer #3446 law P1 ("ordinary writes publish after a drain when no visible reader still needs an unresolved answer"), 21 of 1,000 cases in one symptom group, reduced to case 854; reproduced in the posture matrix (`effect × gatedAway` on the "observed only by the matrix reader" state) and pinned in `tests/posture-born-held-and-observation.test.ts`. +**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). + ## 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 137c5d8e2..bd60d109e 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -99,6 +99,7 @@ import { queuePendingNode, runInTransition, schedule, + wokenTransitions, zombieQueue } from "./scheduler.js"; import type { @@ -276,8 +277,8 @@ export function recompute(el: Computed, create: boolean = false): void { // A conditional can drop its pending source and recover to an unchanged // value, leaving blocked dependents outside that source’s settle walk. const outgoingError = el._statusFlags & STATUS_ERROR ? el._x?._error : undefined; - const outgoingPendingSources = - el._statusFlags & STATUS_PENDING ? el._x?._pendingSources : undefined; + const wasPending = (el._statusFlags & STATUS_PENDING) !== 0; + const outgoingPendingSources = wasPending ? el._x?._pendingSources : undefined; // Pending SOURCE-hood, captured before the compute clears status: a node // whose own flight parked dependents self-registers in _pendingSources // (notifyStatus, isSource). If this recompute supersedes that flight and @@ -679,6 +680,22 @@ 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(); + } + } // 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/tests/posture-born-held-and-observation.test.ts b/packages/signals/tests/posture-born-held-and-observation.test.ts index b4a39c2d4..67f1fd7cf 100644 --- a/packages/signals/tests/posture-born-held-and-observation.test.ts +++ b/packages/signals/tests/posture-born-held-and-observation.test.ts @@ -121,3 +121,74 @@ describe("B — creation under a transaction escapes the hold (OBSERVED, spec O2 expect(published).toEqual([1]); }); }); + +describe("P1 — a reader that stopped reading the flight does not keep its hold (fuzzer #3446 P1; A15 / #3426 live reporter)", () => { + // Reduced from fuzzer case 854 (seed 3289) and the posture matrix's + // `effect × gatedAway` cell on the "observed only by the matrix reader" + // state. A render effect DIRECTLY observing an uninitialized async memo + // registers as the flight's reporter; when it re-runs without reading the + // memo (gate closed) nothing visible needs the unresolved answer, yet the + // source's ordinary write stays held — forever, since the flight never + // lands. The predicate (`reporterBlocksSource`) already judged the effect + // dead after its re-run; nothing RE-JUDGED the parked transaction — the + // flush that re-ran the effect had no active transaction. Fixed at + // recompute's tail: a pending reporter that recovers without its flight + // landing wakes the transaction it reported to (wokenTransitions — the + // third site after disposal #3372 and boundary reset #3375). + it("gating a render effect away from a never-landing memo releases the source's write", async () => { + const [s, setS] = createSignal(0); + const [show, setShow] = createSignal(true); + const published: unknown[] = []; + createRoot(() => { + const m = createMemo(() => { + s(); + return new Promise(() => {}); // never lands + }); + createRenderEffect( + () => (show() ? m() : "hidden"), + v => { + published.push(v); + } + ); + }); + flush(); + setS(1); + flush(); + expect(s()).toBe(0); // held by the observed flight — correct + setShow(false); + flush(); + await tick(); + expect(published).toEqual(["hidden"]); // the reader re-ran and no longer derives from m + expect(s()).toBe(1); // P1: nothing visible still needs the answer — publish + }); +}); + +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 () => { + const [s, setS] = createSignal(0); + const [show, setShow] = createSignal(true); + createRoot(() => { + const m = createMemo(() => { + s(); + return new Promise(() => {}); + }); + createRenderEffect( + () => (show() ? m() : "hidden"), + () => {} + ); + }); + flush(); + setShow(false); + setS(1); + flush(); + await tick(); + expect(s()).toBe(1); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 257209004..3e408aaf5 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -321,7 +321,11 @@ describe("pay-for-use tree-shaking (#2883)", () => { // (`unflushedStaged`) instead of `_running`: inline, they cost ~140 B of // setSignal bytecode and 10–20% on the write-loop benches (+156 B here). // Measured at 24,478 rebased over #3464–#3471 (`next` 23,750 → 24,478). - expect(minifiedBytes).toBeLessThan(24_600); + // A pending reporter recovering without its flight landing wakes its + // parked transaction (fuzzer #3446 P1, spec O3, 2026-09-16): +100 B + // (24,478 -> 24,578), `wasPending` and the wokenTransitions site at + // recompute's tail. + expect(minifiedBytes).toBeLessThan(24_700); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts index 1899d9008..de783888d 100644 --- a/packages/signals/tests/visibility-oracle-posture.test.ts +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -21,9 +21,16 @@ * the fallback is showing, the reader is not on screen * - disposedReader the reader is built mainline and its root is DISPOSED * before the source's hold is released — a dead reporter + * - gatedAway the memo reader is built mainline behind a `show()` gate, + * then the gate closes: the reader is ALIVE but no longer + * derives from `x`. Records `x`, `isPending(x)` and the + * state's held SOURCE write after the gate closes, before + * any release — a reader that stopped reading the flight + * must not keep its hold (fuzzer #3446 P1, 2026-09-15) * * Readers under a posture: untracked, memo (its pass value and what a render - * effect over it publishes), latest, isPending. + * effect over it publishes), effect (a render effect reading `x` DIRECTLY — + * the reporter shape the fuzzer's P1 reduction used), latest, isPending. * * This is the discovery pass: no cell is pinned. Every cell is recorded to * VISIBILITY_POSTURE_REPORT (a markdown table per state) so red cells can be @@ -52,17 +59,46 @@ import { type Cell, type State } from "./visibility-oracle.harness.js"; -import { STATES } from "./visibility-oracle.states.js"; +import { STATES as ORACLE_STATES } from "./visibility-oracle.states.js"; + +/** Matrix-only states (no reader-kind expectations): shapes whose question is + * the READER's hold rather than the served value. */ +const MATRIX_STATES: State[] = [ + { + // Fuzzer #3446 P1 (case 854, reduced): the matrix reader is the flight's + // ONLY observer; the source is written while it observes (perturb); then + // the reader gates away. Nothing visible still needs the unresolved + // answer — does the ordinary write publish? (`source` after the gate.) + name: "pending own async, observed only by the matrix reader (fuzzer P1)", + build() { + const [q, setQ] = createSignal(0); + let x!: () => unknown; + const dispose = createRoot(d => { + x = createMemo(() => { + q(); + return new Promise(() => {}); // never lands + }); + return d; + }); + // No flush here: the memo's first computation happens under the matrix + // reader's observation (the fuzzer's schedule), not swept dormant first. + return { x, dispose, source: q, perturb: () => setQ(1) }; + }, + expect: {} as State["expect"] + } +]; +const STATES = [...ORACLE_STATES, ...MATRIX_STATES]; const POSTURES = [ "mainline", "foreignAction", "foreignLane", "behindFallback", - "disposedReader" + "disposedReader", + "gatedAway" ] as const; type Posture = (typeof POSTURES)[number]; -const READERS = ["untracked", "memo", "latest", "isPending"] as const; +const READERS = ["untracked", "memo", "effect", "latest", "isPending"] as const; type Reader = (typeof READERS)[number]; const classify = (fn: () => unknown): Cell => { @@ -79,7 +115,7 @@ const classify = (fn: () => unknown): Cell => { * accessor (null for mainline) so the probe can confirm the foreign action is * still live after the source's hold is released. */ function enter(posture: Posture, build: () => void): { y: (() => number) | null } { - if (posture === "mainline" || posture === "disposedReader") { + if (posture === "mainline" || posture === "disposedReader" || posture === "gatedAway") { build(); return { y: null }; } @@ -132,12 +168,15 @@ type Row = { passValue?: Cell; // memo: what its pass read 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 }; const rows: Row[] = []; async function cell(state: State, posture: Posture, reader: Reader): Promise { const built = state.build(() => {}); - const { x, dispose } = built instanceof Promise ? await built : built; + const { x, dispose, source, perturb } = built instanceof Promise ? await built : built; + const [show, setShow] = createSignal(true); + const gated = posture === "gatedAway"; const sourceHolds = holds.length; // everything pushed so far belongs to the source let served: Cell = HELD; let passValue: Cell | undefined; @@ -157,6 +196,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise { const m = createMemo(() => { + if (gated && !show()) return "gated"; let v: Cell; try { v = x(); @@ -177,14 +217,53 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise { + createRenderEffect( + () => { + if (gated && !show()) return "gated"; + let v: Cell; + try { + v = x(); + } catch (e) { + pass.push( + e instanceof NotReadyError + ? "throws:NotReady" + : `throws:${(e as Error)?.constructor?.name}` + ); + throw e; + } + pass.push(v); + return v; + }, + v => { + log.push(v as Cell); + } + ); + return d; + }); + disposers.push(d); } } }); flush(); - if (reader === "memo") { + if (perturb) { + perturb(); // the write the reader's hold is about, made while it observes + flush(); + } + if (reader === "memo" || reader === "effect") { served = log.length ? log[log.length - 1] : HELD; passValue = pass.length ? pass[pass.length - 1] : HELD; } + let afterGate: string | undefined; + if (gated) { + setShow(false); + flush(); + await settle(); + afterGate = `${fmt(classify(x))} / ${source ? fmt(classify(source)) : "—"} / ${fmt(classify(() => isPending(x)))}`; + } if (posture === "disposedReader") { for (const d of disposers.splice(0)) d(); flush(); @@ -206,7 +285,8 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise (c === undefined ? "" : c === HELD ? "HELD" describe("visibility oracle — posture matrix (discovery)", () => { for (const state of STATES) for (const posture of POSTURES) - for (const reader of READERS) + for (const reader of READERS) { + if (posture === "gatedAway" && reader !== "memo" && reader !== "effect") continue; // a gate needs a tracked reader it(`${state.name} × ${posture} × ${reader}`, async () => { rows.push(await cell(state, posture, reader)); expect(true).toBe(true); }); + } afterAll(() => { const out: string[] = ["# visibility oracle — posture matrix (discovery, no pins)", ""]; for (const state of STATES) { out.push(`## ${state.name}`, ""); out.push( - "| reader | posture | served | memo pass | x after source release | foreign still held |" + "| reader | posture | served | memo pass | x after source release | foreign still held | after gate (x / source / isPending) |" ); - out.push("|---|---|---|---|---|---|"); + out.push("|---|---|---|---|---|---|---|"); for (const reader of READERS) { const base = rows.find( r => r.state === state.name && r.reader === reader && r.posture === "mainline" @@ -235,14 +317,15 @@ describe("visibility oracle — posture matrix (discovery)", () => { for (const posture of POSTURES) { const r = rows.find( r => r.state === state.name && r.reader === reader && r.posture === posture - )!; + ); + if (!r) continue; const entangled = base && posture !== "mainline" && 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} |` + `| ${reader} | ${posture} | ${fmt(r.served)}${servedDiff ? " **≠**" : ""} | ${fmt(r.passValue)} | ${fmt(r.afterSourceRelease)}${entangled ? " **ENTANGLED**" : ""} | ${r.foreignStillHeld === null ? "—" : r.foreignStillHeld} | ${r.afterGate ?? "—"} |` ); } } diff --git a/packages/signals/tests/visibility-oracle.harness.ts b/packages/signals/tests/visibility-oracle.harness.ts index e6b69175d..0c98db93a 100644 --- a/packages/signals/tests/visibility-oracle.harness.ts +++ b/packages/signals/tests/visibility-oracle.harness.ts @@ -53,16 +53,25 @@ export const READERS = [ ] as const; export type Reader = (typeof READERS)[number]; +/** What a state builder returns. `source`: the mainline signal whose ordinary + * write the state's flight holds (when there is one) — the posture matrix + * asks whether that write publishes once no visible reader needs the flight. */ +export type Built = { + x: () => unknown; + dispose: () => void; + /** The mainline signal whose ordinary write the state's flight holds. */ + source?: () => unknown; + /** A write to make AFTER the matrix reader exists (the reader observes the + * flight it opens) — states whose question is about the reader's hold. */ + perturb?: () => void; +}; + export type State = { name: string; /** Build the state. Returns the node accessor and a disposer. The stale * foreign reader must be created BEFORE the state is entered, so builders * receive a hook to install it. */ - build: ( - installStale: (x: () => unknown) => void - ) => - | { x: () => unknown; dispose: () => void } - | Promise<{ x: () => unknown; dispose: () => void }>; + build: (installStale: (x: () => unknown) => void) => Built | Promise; expect: Record; }; diff --git a/packages/signals/tests/visibility-oracle.states.ts b/packages/signals/tests/visibility-oracle.states.ts index 87c015f56..2a6afb09c 100644 --- a/packages/signals/tests/visibility-oracle.states.ts +++ b/packages/signals/tests/visibility-oracle.states.ts @@ -349,7 +349,7 @@ export const STATES: State[] = [ installStale(x); setQ(1); // new question; flight never lands flush(); - return { x, dispose }; + return { x, dispose, source: q }; // q's write (1) is what the flight holds }, expect: { untracked: rule( diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 16f1faff2..347dfc691 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -498,7 +498,10 @@ module.exports = [ // merged. // A28 — writes visible at flush, read-side (2026-09-15): measured at 16,155 B // against `next` (+305 B); the signals core delta, see the core floor note. - limit: "16.20 KB", + // A pending reporter recovering without its flight landing wakes its parked + // transaction (fuzzer #3446 P1, spec O3, 2026-09-16): measured at 16,201 B + // (+1 over the cap); +100 B minified in the signals floor (24,478 -> 24,578). + limit: "16.25 KB", modifyEsbuildConfig }, {