From db1c17357ba11ba351c83f90a71ad5530e4452e4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 03:02:06 -0700 Subject: [PATCH] test(signals): pin the direct-commit readers by posture over a held frame (#3482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit until() and resolve() (CONFIG_DIRECT_COMMIT) created after a confirming frame was staged under an action's optimism: - inside the action after a bare `yield` — the action's own reader; the DIRECT_COMMIT arm delivers under its own hold and the action settles (the documented form, #3491); - inside the action from an `await` continuation with no bare `yield` — a mainline reader over its own hold: born held, replays at the commit its promise holds open, times out (#3482 as filed); - outside the action — a mainline reader over a FOREIGN hold: pending through the hold, served the committed view after the commit, never the unrevealed frame. The third is the pin the suite lacked: the exemption proposed in #3482 and declined in #3490 passed 2,733 tests while making a mainline resolve() serve another action's speculative frame. Verified: with that one-liner applied the second and third fail; on next all three pass. Rules index regenerated (the file cites A29). Co-authored-by: Claude Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- .../direct-commit-readers-posture.test.ts | 221 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 packages/signals/tests/direct-commit-readers-posture.test.ts diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index a4103ad2f..5251e69b2 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | 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` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 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 … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.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… | | 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… | diff --git a/packages/signals/tests/direct-commit-readers-posture.test.ts b/packages/signals/tests/direct-commit-readers-posture.test.ts new file mode 100644 index 000000000..29de243c4 --- /dev/null +++ b/packages/signals/tests/direct-commit-readers-posture.test.ts @@ -0,0 +1,221 @@ +/** + * The promise-delivery readers — until(), resolve() (and awaitable refresh()'s + * waiter; CONFIG_DIRECT_COMMIT) — by posture, over a frame HELD under an + * action's optimism (#3482). + * + * The shape: an optimistic row on the store a live stream feeds, confirmed by + * the server's echo. The server broadcasts before it answers the mutation, so + * the confirming frame lands while the action is in flight and is staged under + * its hold — the UI keeps showing the optimistic row until the action settles. + * + * Two postures for a reader created after that frame is held: + * + * - INSIDE the action, after a bare `yield` re-entered the transaction: the + * reader is the action's own. `recompute`'s DIRECT_COMMIT arm delivers its + * result on a microtask under the action's own hold — the predicate sees the + * held frame, the action settles, the commit reveals frame + overlay revert. + * This is the documented form (`action()`: code between an `await` and the + * next `yield` runs outside the transaction; the expression of + * `yield until(...)` is evaluated BEFORE that yield re-enters). + * + * - MAINLINE — outside the action, or inside it from an `await` continuation + * with no bare `yield` first: the reader is not the action's. It is born + * held (A29) and replays at the commit: it serves the COMMITTED view, never + * the foreign hold's unrevealed frame. From outside the action that is the + * only correct answer — `resolve()` is routinely called outside actions, and + * an in-flight action's speculative frame must not leak to it. From inside + * the action without the bare `yield` it is the deadlock #3482 reported: + * the commit the reader waits for is the settle its own promise holds open. + * Same mechanism, one posture — which is why an exemption for the + * direct-commit readers in `enterStagedRead` (proposed in #3482, declined in + * #3490) is wrong: it cannot tell the two apart and serves the leak to fix + * the deadlock. + * + * Reproduction and analysis by @brenelz (#3482); docs fixed in #3491. + */ +import { expect, test } from "vitest"; +import { + action, + createOptimisticStore, + createRenderEffect, + createRoot, + flush, + resolve, + TimeoutError, + until +} from "../src/index.js"; + +type Row = { id: string; status: "pending" | "confirmed" }; +type Snapshot = { rows: Row[] }; + +const settle = async (n = 3) => { + for (let i = 0; i < n; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +// A manually pumped AsyncIterable — what a live() server-function stream +// materializes as on the client. +function stream() { + const buffered: IteratorResult[] = []; + let waiter: ((r: IteratorResult) => void) | null = null; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>(res => { + if (buffered.length) res(buffered.shift()!); + else waiter = res; + }), + return: () => Promise.resolve({ done: true as const, value: undefined }) + }) + }; + return { + iterable, + push(value: T) { + const r = { done: false as const, value }; + if (waiter) { + const w = waiter; + waiter = null; + w(r); + } else buffered.push(r); + } + }; +} + +const CONFIRMED: Snapshot = { rows: [{ id: "res_1", status: "confirmed" }] }; + +/** Optimistic row + live feed, the action's body supplied by the test. */ +function harness(body: (ctx: { store: Snapshot; mutation: Promise }) => AsyncGenerator) { + const feed = stream(); + let answer!: (row: Row) => void; + const mutation = new Promise(res => (answer = res)); + const views: string[] = []; + let run!: () => Promise; + let store!: Snapshot; + + createRoot(() => { + const [s, setStore] = createOptimisticStore( + () => feed.iterable, + { rows: [] }, + { + key: "id" + } + ); + store = s; + run = action(async function* () { + setStore(d => { + d.rows.push({ id: "temp", status: "pending" }); + }); + yield* body({ store: s, mutation }); + }); + createRenderEffect( + () => s.rows.map(r => `${r.id}:${r.status}`).join(",") || "empty", + v => { + views.push(v); + } + ); + }); + + return { + feed, + answer: () => answer({ id: "res_1", status: "confirmed" }), + views, + run, + get store() { + return store; + } + }; +} + +test("inside the action after a bare yield: until() sees the frame held under its own action", async () => { + const h = harness(async function* ({ store, mutation }) { + const saved = await mutation; + yield; // re-enter the transaction — the documented form + yield until(() => store.rows.some(r => r.id === saved.id), { timeout: 200 }); + }); + h.feed.push({ rows: [] }); + await settle(); + const done = h.run().then( + () => "settled", + e => e + ); + await settle(); + h.feed.push(CONFIRMED); // broadcast first: held under the action + await settle(); + expect(h.views.at(-1)).toBe("temp:pending"); + h.answer(); + await settle(); + expect(await done).toBe("settled"); + await settle(); + expect(h.views.at(-1)).toBe("res_1:confirmed"); +}); + +test("inside the action WITHOUT the bare yield: until() is mainline, born held, and times out (#3482 as filed)", async () => { + const h = harness(async function* ({ store, mutation }) { + const saved = await mutation; + // `until(...)` is evaluated in the await continuation, before this yield + // re-enters — a mainline reader over the action's own hold. + yield until(() => store.rows.some(r => r.id === saved.id), { timeout: 100 }); + }); + h.feed.push({ rows: [] }); + await settle(); + const done = h.run().then( + () => "settled", + e => e + ); + await settle(); + h.feed.push(CONFIRMED); + await settle(); + h.answer(); + await settle(); + await new Promise(r => setTimeout(r, 120)); + await settle(); + expect(await done).toBeInstanceOf(TimeoutError); +}); + +test("mainline, outside the action: resolve() and until() over a foreign hold serve the committed view, after the commit — never the held frame", async () => { + let release!: () => void; + const gate = new Promise(r => (release = r)); + const h = harness(async function* ({ mutation }) { + await mutation; + yield; + yield gate; // keep the hold open past the answer + }); + h.feed.push({ rows: [] }); + await settle(); + const done = h.run().then( + () => "settled", + e => e + ); + await settle(); + h.feed.push(CONFIRMED); + await settle(); + expect(h.views.at(-1)).toBe("temp:pending"); + + // Outside the action, after the frame is held. A reader here is not the + // action's: it must not see the unrevealed frame. + let resolved: unknown = "pending"; + let acked: unknown = "pending"; + resolve(() => h.store.rows.map(r => r.id).join(",") || "empty").then( + v => (resolved = v), + e => (resolved = e) + ); + until(() => h.store.rows.some(r => r.id === "res_1"), { timeout: 1000 }).then( + v => (acked = v), + e => (acked = e) + ); + h.answer(); + await settle(); + expect(h.views.at(-1)).toBe("temp:pending"); + expect(resolved).toBe("pending"); + expect(acked).toBe("pending"); + + release(); + await settle(); + expect(await done).toBe("settled"); + await settle(); + expect(h.views.at(-1)).toBe("res_1:confirmed"); + expect(resolved).toBe("res_1"); + expect(acked).toBe(true); +});