fix(signals): second write while an async chain is in flight (#3373, #3374, #3375, #3376) - #3381
Conversation
…3374, #3375, #3376) A flight's landing retires only the node's own pending entry (`landStatus`). An input re-asked mid-flight (`a` restarted while `b`'s first flight was up) marks `b` pending on `a` by propagation with `b`'s flight still current — nothing superseded it — and the full `clearStatus` at landing wiped that entry: the stale answer read as settled, the transaction's reporter for `a` found nothing pending below it, and the newer signal committed beside the older derived value (`2 / 1`, #3373); `isPending(b)` blipped false (#3376). The landed value is still written and held; `a`'s settle releases it or its value change re-asks `b`. Complement at registration: a fresh flight drops entries its inputs propagated earlier — the run read them, so a pending input was masked for it (an active override, A17) and does not describe the answer. `transitionComplete` tests a source's own flight by its self entry in `_pendingSources`, not `_error.source`, which a later-pending input overwrites on propagation while the flight is still in the air — the held write no longer commits ahead of its answer once the load is re-asked under an `on`-scoped boundary (#3375). A collecting `Loading` boundary records every source the notifying effect is pending on, not only the one the notification carries. Status propagation dedupes on the effect's `_pendingSources`, so a source carried across an `on` reset was never re-reported and its re-flight stayed invisible — the boundary revealed when its one collected source settled with the effect still pending (#3375). A stale reader served a pending node's committed value by the reveal carve-out (`heldFromStale`) joins the transaction's reporters for that node when it has an entry: it displays the pre-flight value, so a keyed remount that disposes the reader that opened the entry no longer lets a same-value rewrite commit the held write while the derivation is in flight (`Count: 1` beside `Details: 0`, #3374). Entries still open only from queue notification (INV-3); a boundary-consumed flight stays consumed. Core floor 22,648 -> 22,780 (+132 B); size caps bumped with notes. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: a82d2e3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 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 |
Coverage Report for CI Build 34688159512Coverage remained the same at 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will improve performance by ×3.6
Performance Changes
Tip Curious why performance improved? Comment Comparing |
…ts readers observed (#3375) Ruled 2026-09-12: fallback-caught async holds nothing, in both orders. A reader registered as a reporter while its boundary showed content stops blocking once the boundary's `on` changes and it flips to the fallback (`reporterBlocksSource` walks the reporter's queue chain); if nothing outside the boundary consumes the flight, the hold is over. A reader outside the boundary still holds. The flush judges only the active transaction, so the reset wakes parked transactions (`wokenTransitions`, deduped) and the flush re-enters a woken one from the finally of an idle pass (`!scheduled`), adopting no unrelated ambient work. Pins: the #3375 frames now release count=1 at the reset and page=2 commits plain; a companion pin keeps the hold with an outside reader. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Fixes #3373, #3374, #3375, #3376 — the four reports from @GabbeV that share one shape: a second write arrives while an async chain started by the first is still in flight, and the engine's hold accounting loses track of the flight.
Each was reproduced at the
@solidjs/signalslevel with a manual clock (packages/signals/tests/async-chain-supersession.test.ts, seven pins) before fixing. All reproduce onnext@344ed054.Root causes and fixes
#3373 / #3376 — a stale first-hop landing commits the newer signal
a = memo(delay(count)),b = memo(delay(a)).count=1startsa1;a1lands and startsb1;count=2arrives and startsa2whileb1is still up.a2only changesa's status (not yet its value), sobis marked pending onaby propagation withb1still current — nothing supersedes it. Whenb1lands,asyncWrite'sclearStatus(b)wiped all ofb's pending sources, includinga.b's staged 1 woke the text effect, which saw nothing pending; the transaction's reporter forafound nothing blocking; it committedcount=2, b=1witha2in the air →2 / 1(#3373), andisPending(b)read false for the gap (#3376).Fix: the landing uses
landStatus, which retires only the node's own entry. With another source still pending the node stays derivatively pending on it; the landed value is written and held (it is still the answer for the inputs it was asked with), and the input's settle releases it — or its value change re-asks the node. Pinned both ways:a2landing changed (reveal2 / 2atb2) and unchanged (b1's held answer reveals ata2's landing, no extra flight).Complement at registration:
handleAsyncnow drops pending entries the node's inputs propagated earlier. The run that asked the flight read every input without throwing, so a still-pending input was masked for it (an active override, A17) and does not describe the new answer. Without this the A17 spec pin regressed (the override-derived flight was held by the authoritative refetch).#3375 —
Loading on={page}reveals content with details still pendingTwo mechanisms:
Boundary collection. The
onreset clears_sourcesand rebuilds from later notifications.notifyStatusdedupes on the effect's_pendingSources, so a source the effect already carried across the reset (details, from thecountwrite's flight) is never re-reported, anddetails' re-flight afterpageData2lands stays invisible. WhenpageDatasettled,_sourcesemptied and the boundary revealed with the effect still pending ondetails. Fix: a collecting boundary records every source the notifying effect is pending on (node._x._pendingSources), gated to theSTATUS_PENDINGtype — an error boundary must not collect pending sources, since_checkSourceswould hold its fallback on them.Transaction completion.
transitionCompletetested "the source's own flight is up" with_error.source === source. Propagation from an input that went pending later overwrites_errorwith the input, sodetails— still flying — read as settled and thecount=1hold committed at 5500, ahead of its answer. Fix: the test is the source's self entry in_pendingSources, which onlynotifyStatus's source path adds and the landing / supersede sweep retire.Note for the reviewer: the pinned timeline records
page=2riding thecounthold (Sum: 3 @8500). That is A15 applying —pageData, a reader ofpage, lives in the hold since its earlier landing joined it — where before it was leaking early through the_error.sourceglitch. If anonreset should release the page write instead, that is a spec ruling, not this bug.#3374 — a same-value rewrite after a keyed remount publishes early
count=1is held withdetailsin flight.<Show keyed when={version()}>remounts the reader; the new reader is served the committedDetails: 0by the A15 reveal carve-out (correct — the frame still showsCount: 0) and recorded for replay. But the transaction's only reporter fordetailswas the disposed reader. The same-valuesetCount(1)activates the transaction;transitionCompletefinds no live reporter, drops the source and commitscount=1withdetails1still up →Count: 1besideDetails: 0for a second.Fix: a carve-out reader joins the transaction's reporters for the node when it already has an entry (
heldFromStale, one Map lookup). It observes the flight (A15: async work observed by a reader settles as one unit with the writes that asked it) and dies with disposal like any reporter (reporterBlocksSourcesees the dep the read linked). INV-3 is preserved as "entries open only from queue notification" — a boundary-consumed flight has no entry and stays consumed; the internals doc is amended accordingly. A chain's intermediate memo never reaches the carve-out (the read re-pulls it and enters the transaction), and a node that is both an origin and pending on an upstream re-ask blocks through its own flight until it lands — both pinned.Size
Core floor
22,648 → 22,780(+132 B minified) after a golf pass (from +259 on the first cut). Brotli scenarios +32…+73 B; nine.size-limit.jscaps bumped with notes;size-limitexits 0.Verification
tscclean onsrc/and the new test (remainingtscerrors are pre-existing test files onnext).spec-async-semanticspin holds, including A17 (the registration clear exists for it)..changeset/fix-async-chain-second-write.md(@solidjs/signals, patch).INTERNALS-ASYNC-STATE.md—landStatusvsclearStatus,transitionComplete's own-flight test, INV-3's join site, the carve-out paragraph's "does not entangle" qualified.The one edit with blast radius beyond the four repros is the
transitionCompleteown-flight test; suites are clean, but it is the line to watch on CI/CodSpeed.— Claude via Cursor