Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/staged-reporter-deps-tail.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 13 additions & 13 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,20 @@ 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

**Status:** **violation, recorded** 2026-09-16 — posture matrix over the store states beside the signal states (`staged, ambient × foreignAction`): the store leaf answers `latest` 0 / `isPending` false inside the adopting action's body; the signal answers 1 / true. Pinned `it.fails` (signal) beside the passing store twin in `tests/posture-store-parity.test.ts` (S1).
**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.
Expand Down
42 changes: 25 additions & 17 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import {
heldTrims,
runInTransition,
schedule,
wokenTransitions,
wakeParked,
zombieQueue
} from "./scheduler.js";
import type {
Expand Down Expand Up @@ -715,22 +715,30 @@ export function recompute(el: Computed<any>, 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<any>)._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,
Expand Down
16 changes: 15 additions & 1 deletion packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> | Computed<any> | undefined;
while (current) {
if (current === source || (current as any)._firewall === source) return true;
Expand Down
62 changes: 62 additions & 0 deletions packages/signals/tests/inv4-projection-dispose-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
);
17 changes: 8 additions & 9 deletions packages/signals/tests/posture-born-held-and-observation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
4 changes: 4 additions & 0 deletions packages/signals/tests/posture-store-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading