Repository navigation
fix(signals): every answer resolved in the tick a write re-asks it rides the write's flush (#4495) - #4499
Conversation
…s answered, then re-asked (D-22, F-9) An async memo's first answer resolves, and in the same synchronous turn a plain write re-asks it; the promise reaction runs after the write, so the landing and the re-ask are processed in one flush. The runtime treated the answer as never produced: - `asyncWrite` dropped it outright when the write's notification had marked the node dirty (a stale-answer guard meant for re-asks). - Otherwise it landed staged, the re-ask left the node pending and uninitialized, and `isPending` gave it no verdict (`false` from the flush's probe, never re-run), while an identity memo or a derived store over it never derived from it (`latest` of either threw: [ROOT-L]). Now a first answer always lands; `isPending` of a node answered and re-asked in one flush is the re-ask's verdict (true); and a memo with no value of its own that reads it derives from the answer once, then re-runs on the missed-wake latch and waits on the re-ask holding it (a verdict engine hook, so the hello-world floor pays only the call). Amends D-22's 2026-10-28 clause per the 2026-10-30 ruling: a plain write re-asking the memo carries the answer like an action's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…s answered, then re-asked (D-22, F-9) An async memo's first answer resolves, and in the same synchronous turn a plain write re-asks it; the promise reaction runs after the write, so the landing and the re-ask are processed in one flush. The runtime treated the answer as never produced: - `asyncWrite` dropped it outright when the write's notification had marked the node dirty (a stale-answer guard meant for re-asks). - Otherwise it landed staged, the re-ask left the node pending and uninitialized, and `isPending` gave it no verdict (`false` from the flush's probe, never re-run), while an identity memo or a derived store over it never derived from it (`latest` of either threw: [ROOT-L]). Now a first answer always lands; `isPending` of a node answered and re-asked in one flush is the re-ask's verdict (true); and a memo with no value of its own that reads it derives from the answer once, then re-runs on the missed-wake latch and waits on the re-ask holding it (in `read`'s pending path, behind the verdict engine's hook so the floor folds it). Amends D-22's 2026-10-28 clause per the 2026-10-30 ruling: a plain write re-asking the memo carries the answer like an action's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered
…ask's flush The reader-seat table (#4438) predicted NotReady (row D3) for a memo with no value reading a first answer staged in the flush that re-asks it; under D-22 (amended 2026-10-30) the memo derives from it once. Row D3' predicts the staging (P), and `read` records the slot it serves. __TEST__ only: dist is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
isPending/latest +37 B brotli (+151 B minified) and the compiled live page: the first-answer serve in `read`'s pending path and the `isPending` verdict for it. The observe-attribution and compiled scenarios carry the core half (+10 B minified on every floor; the rest is brotli layout). Caps at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…st answer 38.76 KB -> 38.79 KB, recorded minified 117,227 -> 117,379 B, measured at 38,778 B brotli. Size-Exception in the PR body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…wered Ledger conflicts in scripts/size/scenarios.js resolved to next's entries, then re-measured over next @ 5f07d04f: isPending/latest 11.15 KB (30,730), compiled live 42.78 KB (125,611); frozen floors simple-app 9.80 KB (26,762), hydrating 18.78 KB (53,608), base SC 33.68 KB (101,628), live SC 38.82 KB (117,464). Size-Exception in the PR body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered # Conflicts: # scripts/size/floor-caps.json # scripts/size/scenarios.js
…-x1ore2-reask-landing-answered
…-x1ore2-reask-landing-answered # Conflicts: # scripts/size/scenarios.js
…eader waiting on it, off lanes, at any depth read()'s D-22 branch served the staged first answer to a reader that did not wait on it (a first pass: the seat table's D3 row predicts NR) and to a verdict lane's pass (V6: D-37, a lane sees the screen plus its own guesses). It now requires passLane === null and that the reader wait on the source a read of the node parks on (_error.source): the node itself one level down, the async it derives from at any depth, so identity(identity(m)) reads as m does (E2 at depth 2). The seat's D3' row says the same (!inSeat, the parked-on source, no staging of its own). handleAsync's answered-then-reasked guard is the verdict engine's (holdWorkHook): plain-only bundles keep the plain return. D-23 exception 1: an answered-then-reasked node is not a first load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
isPending/latest, compiled live SC and live + router grow with the verdict-engine branch (+159..+161 B minified); no frozen floor moves: plain-only bundles are unchanged in minified bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered
…flush, out of line read()'s D-22 arm served a reader whose pass then threw anyway (a read beside the served one parked on the same source): the throw re-parked it on that source, the missed-wake latch re-ran it, and the re-run qualified again — a synchronous loop within one flush (async-gen 91501 #749, minimized: a boundary's children memo over two Shows that both park on the re-asked memo). The arm now stamps the served pass's clock on the reader (`_x._served`) and serves a reader once a flush; the re-run throws and waits. The seat's D3' row says the same. The arm moves out of read() into `servesStaged`, called behind `observeFlightHook` on the pending-throw path, so read() itself grows by a call (bytecode 1318 -> 1170; next 1139). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…its landing released servesStaged served a reader with no value only while it still waited on the source. When the memo's input is read through latest() or a sync memo, the write's re-ask reaches the memo after its landing ran, and the landing had already released the identity memo's wait: it ran with no value, waiting on nothing, and threw, so latest(identity(m)) and a derived store stayed on [ROOT-L] while latest(m) showed the answer (async-gen E2/E3 3289 #310, 91501 #1993, #2609). D-22 as amended says a derivation with no value of its own reads like the memo at any depth; which the flush ran first is not the reader's. A reader that is not pending is served too (one served pass a flush, as before); one pending on another source is not. The seat's D3' row says the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered # Conflicts: # scripts/size/floor-caps.json # scripts/size/scenarios.js
…30c670 Live server components 38.85 -> 38.96 KB (117,747; Size-Exception in the PR body), isPending/latest 11.17 -> 11.26 KB (31,002), compiled live 42.79 -> 42.85 KB (125,887); live + router re-records minified (143,150). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-reask-landing-answered # Conflicts: # packages/signals/docs/RULES-DERIVED.md # scripts/size/floor-caps.json # scripts/size/scenarios.js
…0fc285 Takes next's ledger (recorded after #4471/#4479) and re-records only the scenarios this PR moves: isPending/latest 11.19 -> 11.28 KB (31,053), live server components 38.91 -> 38.96 KB (117,793; Size-Exception in the PR body), compiled live 42.83 -> 42.87 KB (125,938), live + router 48.51 -> 48.58 KB (143,201). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…des the write's flush (#4495) Two async memos m1, m2 answer, then a write re-asks both, in one tick. m1's landing flushed synchronously (`asyncWrite`), and that flush carried the queued write: it re-asked m2 before m2's answer's reaction ran, so the answer arrived superseded (`_inFlight !== result`) and was dropped. A memo over both then had no value and `latest` of it threw (D-22 says 13). A landing that arrives while a write's flush is queued (a staged value in `pendingNodes`, captured before the landing stages its own) no longer flushes: the write's flush carries it with every answer whose reaction is queued ahead of it (F-10), each answered, then re-asked (D-22, F-9). Only `latest` and `isPending` tell the difference, so it is gated on the verdict engine (`holdWorkHook`); a stream's next pull still waits on its landing's flush. Size: isPending/latest 11.28 -> 11.30 KB (31,087 B), compiled live server components 42.87 -> 42.90 KB (125,978 B); hello world unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
🦋 Changeset detectedLatest commit: 6b98aba The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 41118325782Coverage remained the same at 78.1%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
|
Size gate ( Generated by Claude Code |
Merging this PR will improve performance by 9.15%1 warning
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
…4499, #4500, #4493, #4488, #4498) (#4507) next @ b5077ea is over six caps after the batch merged back to back (each fix PR carried no ledger edits, per the batch process). Re-records, measured over next @ b5077ea (linux): - signals: + createStore: 14.97 -> 14.99 KB, minified 43,924 -> 43,956 - signals: + isPending/latest: 11.29 -> 11.35 KB, minified 31,101 -> 31,245 - app: compiled CSR: 26.33 -> 26.36 KB, minified 80,035 -> 80,074 - page: live server components (frozen floor): 38.98 -> 39.10 KB, minified 117,858 -> 118,033 - page: compiled live server components: 42.95 -> 43.01 KB, minified 126,018 -> 126,181 - page: live + router: 48.58 -> 48.65 KB, minified 143,269 -> 143,439 and the recorded minified only (caps unchanged) where the batch drifted more than 20 B: hydrating + every store (98,535 -> 98,561), compiled hydrating (100,803 -> 100,836), base server components (frozen, 101,675 -> 101,706), compiled base server components (108,048 -> 108,081), base + router (125,503 -> 125,539). Hello world is 9,971 B (cap 9.98 KB). The build leaves the pinned mangle-props.json unchanged. Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 Co-authored-by: Claude <noreply@anthropic.com>
Before: two async memos m1 and m2 both answer, then a write re-asks both, all in one tick.
c = m1() + m2()has no value yet.latest(c)threw NotReadyError, because m2's answer was dropped. The one-source case (c = m1()) already read the answer after #4459.After:
latest(c)reads 13 (both answers) andisPending(c)is true until the re-ask lands. Every answer that resolved before the write is answered, then re-asked, whatever order the answers resolve in.Cause:
setS(2)stages the write and queues its flush. m1's answer reaction runs first, lands, and calledflush()synchronously. That flush carried the write, so it re-asked m2 before m2's answer reaction ran, and m2's answer was then dropped as superseded.How: in
handleAsync's landing, before the landing stages its own value, it notes whether a write is staged with its flush queued (pendingNodesholding a staged_pendingValue). If so, the landing does not flush itself: the write's queued flush carries this landing and every answer queued ahead of it (F-10). Streams still flush per yield. An answer that resolves after the write is still dropped as superseded. The check is gated onholdWorkHook, because onlylatest/isPendingcan tell an answered-then-reasked node from one with no value. With plain reads only, the sequence is identical before and after.Rules: D-22 (both answers are produced values; answered, then re-asked), F-10 (a write is visible at the flush that carries it), F-9 (the re-ask is a new question, so c is pending on it).
Tests:
tests/two-sources-answered-reasked-4495.test.ts, 8 cases: one and two sources, reverse resolve order, c reading m2 first, only one source re-asked (both orders), the re-ask's answers landing next, and two controls (answered in its own tick; answered after the write stays dropped). 4 of the 8 fail on next. Signals 6088 passed / 17 expected fail; web 1579 passed / 7 expected fail.Limits, not pinned: a status-only flush already queued before the answer and the write still re-asks and drops that answer; an async iterator's first yield in this ordering is excluded on purpose to keep per-yield flushes.
Timing change: with
holdWorkHookloaded, code that resolves a flight, writes in the same tick, then awaits the flight's own promise (res(5); setU(1); await p; u()) now sees the write unflushed (u()is 0), the same as awaiting any plain promise. Before, the landing's own flush carried the write.Size (client, vs next 643a7a7; no ledger edits in this PR):
Every other scenario is unchanged; hello world stays at 9,973 B.
Closes #4495
Generated by Claude Code
Generated by Claude Code