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/projection-leaf-companions-die-with-firewall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A projection's leaf companions die with the projection. Disposing a store whose async source was mid-refetch left the `latest()` shadow of a leaf orphaned: never derived (its compute read through the projection in flight, the backfilled override stood in), its override dropped at the settle, NotReady forever against a leaf whose committed value differed — the `__TEST__` quiescence invariant INV-4 at the next flush. Externally coherent, but a companion outliving its source. The firewall's teardown now snaps the companions of its companion-bearing leaves (the shadow is retired; a later read recreates it from the committed view), and `latest()` of a leaf whose firewall is already disposed serves the committed value without creating a shadow — a boundary's content re-running after the teardown had recreated one that nothing would retire. Surfaced once #3495 stopped leaking parked transactions, which had masked every quiescence check in the posture matrix; all 621 matrix cells now run with zero invariant violations.
24 changes: 12 additions & 12 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +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
### O5. INV-4 — a projection leaf's `latest()` shadow is stale on the flush right after its root is disposed mid-refetch — fixed

**Status:** **violation, recorded** 2026-09-16 — surfaced by O3's fix: the posture matrix had been leaking parked transactions (dead reporters never re-judged), which kept `transitions.size > 0` and silenced every quiescence invariant for the rest of the run. With the leak gone, INV-4 fires. Pre-existing on `next` (standalone repro: projection store with a held refetch, `latest()` read of a leaf, `dispose()`, synchronous `flush()`); pinned `it.fails` in `tests/inv4-projection-dispose-shadow.test.ts` (its own file: a live action in a sibling test masks the check). Externally the leaf and its `latest()` agree throughout — the stale pair is an internal companion owner. Transient — the shadow is re-derived a microtask later — but under `__TEST__` a throw from the runtime's own scheduled flush leaves the scheduler mid-flush, so the matrix excludes the two triggering cells (`gatedAway` × the projection states) until fixed.
**Where to look:** the disposal snap (`disposeChildren` → `_snapCompanions`) covers the disposed computed's own companions; a projection's leaves hang off the firewall, not the child chain, and their companions are re-derived only by the scheduled pass that follows.
**Status:** **violation, fixed** 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.
**Cause:** the leaf's shadow had never derived a value — its compute reads through a projection in flight (NotReady), so the backfilled override stood in — and the settle after disposal dropped the override, leaving a shadow NotReady/uninitialized forever against a leaf whose committed value differs. Not a skipped sync: an orphan. The disposal snap covered the disposed computed's own companions only; a projection's leaves hang off the firewall, not the child chain.
**Mechanism:** `disposeChildren(self)` snaps the companions of the firewall's `_companionChildren` (the set `markFirewallChildCompanions` already maintains, #3038), and `snapCompanionsToState` retires a shadow whose owner's firewall is disposed — `getLatestValueComputed` treats a disposed shadow as absent, so a later read recreates it from the committed view; the isPending signal snaps as the owner's own does. And `latestRead` of a leaf whose firewall is disposed serves the committed value and creates no shadow — a boundary's content re-running after the teardown recreated one (a disposed shadow reads as absent) that no teardown would ever retire; that recreated shadow was the matrix's actual trigger. The matrix's two excluded cells are back; 0 invariant cells across all 621.

## Superseded rules (kept verbatim)

Expand Down
8 changes: 8 additions & 0 deletions packages/signals/src/core/owner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CONFIG_AUTO_DISPOSE,
CONFIG_CHILD_COMPANIONS,
CONFIG_CHILDREN_FORBIDDEN,
CONFIG_TRANSPARENT,
defaultContext,
Expand Down Expand Up @@ -79,6 +80,13 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo
// false, and notifies subscribers still watching the companion.
const n = node as Computed<unknown>;
if (n._x?._pendingSignal || n._x?._latestValueComputed) GlobalQueue._snapCompanions!(n);
// A firewall's leaves have no lifecycle of their own, so a companion on
// one of them outlives its source the same way (INV-9's rationale). The
// firewall knows which leaves carry companions (CONFIG_CHILD_COMPANIONS,
// #3038): snap them with it — the snap retires a shadow whose firewall is
// disposed (spec O5).
if (n._config & CONFIG_CHILD_COMPANIONS)
n._x!._companionChildren!.forEach(GlobalQueue._snapCompanions! as (leaf: unknown) => void);
// A pending reader parked in a transaction may be the only thing holding
// it (#3372): its death is a completion event the transaction must be
// re-judged for, and nothing else re-enters a parked transaction.
Expand Down
19 changes: 19 additions & 0 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from "./core.js";
import { NotReadyError } from "./error.js";
import { link } from "./graph.js";
import { dispose } from "./owner.js";
import { enqueueSub, insertIntoHeap, markHeap, queueFor } from "./heap.js";
import { devTrackCompanionOwner, InvariantHooks } from "./invariants.js";
import {
Expand Down Expand Up @@ -409,6 +410,18 @@ function snapCompanionsToState(owner: Signal<any> | Computed<any>): void {
}
const shadow = owner._x?._latestValueComputed;
if (shadow && !(shadow._flags & REACTIVE_DISPOSED)) {
// A leaf whose firewall is disposed (the projection's teardown snaps its
// companion-bearing leaves): the shadow's compute reads through a
// disposed, possibly still-pending projection and would sit
// NotReady/uninitialized forever — never derived, its backfilled override
// dropped at the settle — against a leaf whose committed value differs
// (INV-4 at the next quiescence; spec O5). It dies with its source;
// getLatestValueComputed treats a disposed shadow as absent, so a later
// read recreates it from the committed view.
if ((owner as FirewallSignal<any>)._firewall?._flags & REACTIVE_DISPOSED) {
dispose(shadow);
return;
}
if (
(shadow._x?._overrideValue === undefined || shadow._x?._overrideValue === NOT_PENDING) &&
shadow._pendingValue === NOT_PENDING &&
Expand Down Expand Up @@ -477,6 +490,12 @@ function uninitializedSource(el: Signal<any> | Computed<any>): boolean {
}

function latestRead<T>(el: Signal<T> | Computed<T>): T {
// A leaf of a DISPOSED projection has no flushed world left to mirror: a
// shadow created for it now would read through the dead firewall, sit
// NotReady/uninitialized forever, and no teardown would ever retire it (the
// firewall's already ran — spec O5). Serve the committed value; create
// nothing. (A read of a disposed node freezes at its last commit, #3024.)
if ((el as FirewallSignal<T>)._firewall?._flags & REACTIVE_DISPOSED) return el._value as T;
const pendingComputed = getLatestValueComputed(el);
const prevPending = latestReadActive;
setLatestReadActive(false);
Expand Down
99 changes: 53 additions & 46 deletions packages/signals/tests/inv4-projection-dispose-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
/**
* INV-4 after disposing a projection mid-refetch — VIOLATION, pinned it.fails
* (spec O5; posture matrix S3).
* A projection's leaf companions die with the projection (spec O5).
*
* 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.
* Was: with a projection store's refetch held and a `latest()` companion
* created on a leaf, the synchronous flush right after `dispose()` tripped
* the __TEST__ quiescence invariant INV-4. The leaf's shadow had never
* derived a value — its compute reads through a projection in flight, so the
* backfilled override stood in — and the settle after disposal dropped the
* override, leaving a shadow that is NotReady/uninitialized forever against a
* leaf whose committed value is 0. Externally coherent (`latest()` fell back
* to the committed value), but a companion outliving its source is what
* INV-9's rationale forbids. Now `disposeChildren(self)` retires the
* companions of the firewall's `_companionChildren`: the shadow is disposed
* (a later read recreates it from the committed view), the isPending signal
* snaps. And `latest()` of a leaf whose firewall is already disposed serves
* the committed value without creating a shadow — a boundary's content
* re-running after the teardown recreated one that no teardown would retire.
* Surfaced when #3495 / O3 stopped leaking the parked transactions that had
* masked every quiescence check in the posture matrix.
*
* Own file: a live action in a sibling test would mask the check the same way.
* Own file: a live action in a sibling test would mask the check.
*/
import { expect, it } from "vitest";
import {
Expand All @@ -26,37 +30,40 @@ import {
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;
});
it("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();
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)
}
);
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 before the fix
// A latest() read AFTER the disposal (a boundary's content re-running, a
// stale handler) must not recreate a shadow on the dead leaf — nothing
// would ever retire it. It serves the committed value and creates nothing.
expect(latest(() => s.n)).toBe(s.n);
const [, poke] = createSignal(0);
poke(1);
expect(() => flush()).not.toThrow(); // the recreated shadow tripped INV-4 here
});
7 changes: 0 additions & 7 deletions packages/signals/tests/visibility-oracle-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,13 +341,6 @@ describe("visibility oracle — posture matrix (discovery)", () => {
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);
Expand Down
25 changes: 20 additions & 5 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ module.exports = [
// 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",
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 9,393 B (+13 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "9.40 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -523,7 +526,10 @@ module.exports = [
// (+1 over the cap); +100 B minified in the signals floor (24,478 -> 24,578).
// Hold-consistency batch 2 (#3479, 2026-09-15): measured at 16,343 B; the signals
// core delta, see the core floor note.
limit: "16.45 KB",
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 16,481 B (+31 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "16.50 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -659,7 +665,10 @@ module.exports = [
// promote-on-revert, skipped by the body-end supersession and the
// authoritative-blockage census, merged through and suspended on in lanes,
// pending flowing through it in status. Retained here by `latest()`.
limit: "12.05 KB",
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 12,074 B (+24 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "12.10 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -1075,7 +1084,10 @@ module.exports = [
// write of `onError` onto the root owner is the only prod-floor cost).
// 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",
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 29,953 B (+43 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "30.00 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -1415,7 +1427,10 @@ module.exports = [
// write of `onError` onto the root owner is the only prod-floor cost).
// 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",
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 28,392 B (+22 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "28.40 KB",
modifyEsbuildConfig: observeEsbuildConfig
},
{
Expand Down
Loading