diff --git a/.changeset/until-held-frame-before-call.md b/.changeset/until-held-frame-before-call.md new file mode 100644 index 000000000..afe252a98 --- /dev/null +++ b/.changeset/until-held-frame-before-call.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`until()` (and `resolve()`, awaitable `refresh()`) no longer deadlocks an action when the confirming frame was staged before the call (#3482). Since #3451 a reader created mainline under a hold is born held: it skips its first run and is replayed at the commit. For a promise-delivery effect the commit is the action's settle — the very thing its promise holds open — so an `until()` reached after the server had already broadcast the confirming frame timed out (or hung without a timeout). A `CONFIG_DIRECT_COMMIT` reader is the tunnel through a hold by contract and applies on its own microtask; `enterStagedRead` now exempts it from entering and from being born held, the same exemption verdict pulls have. The predicate sees the held frame, the action settles, and the commit reveals frame and overlay revert together. Regression from `d80cd1f6`; `2.0.0-rc.8` is unaffected. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index a4103ad2f..c6755cc5c 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 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 until-held-frame-before-call.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/src/core/core.ts b/packages/signals/src/core/core.ts index ecc92d5ae..9f7f51460 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1597,7 +1597,16 @@ export function enterStagedRead( // Verdict pulls are observations, not derivations: a latest() / // isPending() call from mainline must never enter a transaction (it // would capture the rest of the caller's synchronous block). - if (GlobalQueue._verdictPull) return; + // Likewise a promise-delivery effect (resolve() / until() / awaitable + // refresh()'s waiter — CONFIG_DIRECT_COMMIT): it reads staged truth by + // contract — it is the tunnel that keeps a hold deadlock-free — and + // applies on its own microtask, not the transaction's stashed queues, so + // it neither enters the transaction nor is born held. Born held, an + // until() created mainline AFTER its confirming frame was staged (the + // server broadcast before it answered the mutation) would skip its first + // run and be replayed only at the commit its own promise is holding open + // (#3482). + if (GlobalQueue._verdictPull || ctx._config & CONFIG_DIRECT_COMMIT) return; if ( ctx._flags & REACTIVE_RECOMPUTING_DEPS && !(ctx._config & CONFIG_OPTIMISTIC) && diff --git a/packages/signals/tests/until-held-frame-before-call.test.ts b/packages/signals/tests/until-held-frame-before-call.test.ts new file mode 100644 index 000000000..7c2cea1a5 --- /dev/null +++ b/packages/signals/tests/until-held-frame-before-call.test.ts @@ -0,0 +1,144 @@ +/** + * until() created AFTER its confirming frame was staged (#3482). + * + * The single-primitive shape the until() docstring recommends: an optimistic + * row on the store a live stream feeds, confirmed by the server's echo. When + * the server broadcasts to subscribers BEFORE it answers the mutation, the + * confirming frame lands while the action is still awaiting the answer and is + * held under the action's optimism. The action then reaches `yield until(...)` + * from mainline (after its await). + * + * #3451 made mainline creation under a hold born held (A29). A born-held + * effect skips its synchronous first run and is replayed at the commit — but + * the commit IS the action's settle, which the until() promise is holding + * open. Deadlock: with a timeout, TimeoutError; without one, forever. + * + * Ruling: a CONFIG_DIRECT_COMMIT reader (resolve / until / awaitable + * refresh's waiter) is the tunnel through a hold by contract and applies on + * its own microtask, so `enterStagedRead` neither enters it nor bears it held + * — the same exemption verdict pulls already have. The predicate sees the + * held frame, the action settles, the commit reveals frame + overlay revert. + * + * Reproduction and analysis by @brenelz (#3482). Regression from d80cd1f6. + */ +import { expect, test } from "vitest"; +import { + action, + createOptimisticStore, + createRenderEffect, + createRoot, + flush, + 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); + } + }; +} + +/** The optimistic-row + socket-echo shape; `frameFirst` picks the ordering. */ +async function reserveScenario(frameFirst: boolean) { + const feed = stream(); + let answer!: (row: Row) => void; + const mutation = new Promise(res => (answer = res)); + const views: string[] = []; + let reserve!: () => Promise; + + createRoot(() => { + const [store, setStore] = createOptimisticStore( + () => feed.iterable, + { rows: [] }, + { key: "id" } + ); + reserve = action(async function* () { + setStore(d => { + d.rows.push({ id: "temp", status: "pending" }); + }); + const saved = await mutation; + yield until(() => store.rows.some(r => r.id === saved.id), { timeout: 200 }); + }); + createRenderEffect( + () => store.rows.map(r => `${r.id}:${r.status}`).join(",") || "empty", + v => { + views.push(v); + } + ); + }); + + feed.push({ rows: [] }); + await settle(); + expect(views.at(-1)).toBe("empty"); + + const done = reserve().then( + () => "settled", + e => e + ); + await settle(); + expect(views.at(-1)).toBe("temp:pending"); + + const confirmed: Snapshot = { rows: [{ id: "res_1", status: "confirmed" }] }; + if (frameFirst) { + // Server mutates and broadcasts first — the frame lands while the action + // is still awaiting the mutation's answer, and is held under its optimism. + feed.push(confirmed); + await settle(); + expect(views.at(-1)).toBe("temp:pending"); + // Then it answers, and the action reaches its until(). + answer({ id: "res_1", status: "confirmed" }); + } else { + // Control: the answer arrives first, until() subscribes, then the frame. + answer({ id: "res_1", status: "confirmed" }); + await settle(); + expect(views.at(-1)).toBe("temp:pending"); + feed.push(confirmed); + } + await settle(); + + const outcome = await done; + expect(outcome).not.toBeInstanceOf(TimeoutError); + expect(outcome).toBe("settled"); + await settle(); + expect(views.at(-1)).toBe("res_1:confirmed"); +} + +test("until() sees a confirming frame that landed on the held store before until() was called", async () => { + await reserveScenario(true); +}); + +test("control: until() subscribed before the confirming frame lands (unchanged)", async () => { + await reserveScenario(false); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index c80b0a3ff..b6c7d4a09 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -881,7 +881,13 @@ module.exports = [ // ~+200 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "19.80 KB", + // until() born held (#3482, 2026-09-16): 19.80 -> 19.85 KB, measured at + // 19.82 (macOS). One bit test in enterStagedRead's mainline arm — a + // CONFIG_DIRECT_COMMIT reader (resolve/until/refresh waiter) is neither + // entered nor born held, the exemption verdict pulls have — ~+20 B + // minified, read +20..+40 brotli across the scenarios (−30 on observe): + // layout. Correctness fix on the core's read path; nothing to shed. + limit: "19.85 KB", modifyEsbuildConfig }, { @@ -1161,7 +1167,9 @@ module.exports = [ // ~+180 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "15.15 KB", + // until() born held (#3482, 2026-09-16): 15.15 -> 15.20 KB, measured at + // 15.18 (macOS). See the hydrating (no stores) note; same bit test. + limit: "15.20 KB", modifyEsbuildConfig }, {