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/gated-reporter-releases-hold.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 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.
20 changes: 10 additions & 10 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
queuePendingNode,
runInTransition,
schedule,
wokenTransitions,
zombieQueue
} from "./scheduler.js";
import type {
Expand Down Expand Up @@ -276,8 +277,8 @@ export function recompute(el: Computed<any>, 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
Expand Down Expand Up @@ -679,6 +680,22 @@ 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();
}
}
// 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
71 changes: 71 additions & 0 deletions packages/signals/tests/posture-born-held-and-observation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(() => {}); // 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<number>(() => {});
});
createRenderEffect(
() => (show() ? m() : "hidden"),
() => {}
);
});
flush();
setShow(false);
setS(1);
flush();
await tick();
expect(s()).toBe(1);
});
});
6 changes: 5 additions & 1 deletion packages/signals/tests/treeshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
109 changes: 96 additions & 13 deletions packages/signals/tests/visibility-oracle-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number>(() => {}); // 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 => {
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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<Row> {
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;
Expand All @@ -157,6 +196,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise<Row
case "memo": {
const d = createRoot(d => {
const m = createMemo(() => {
if (gated && !show()) return "gated";
let v: Cell;
try {
v = x();
Expand All @@ -177,14 +217,53 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise<Row
return d;
});
disposers.push(d);
break;
}
case "effect": {
const d = createRoot(d => {
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();
Expand All @@ -206,7 +285,8 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise<Row
served,
passValue,
afterSourceRelease,
foreignStillHeld
foreignStillHeld,
afterGate
};
}

Expand All @@ -215,34 +295,37 @@ const fmt = (c: Cell | undefined) => (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"
);
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 ?? "—"} |`
);
}
}
Expand Down
Loading
Loading