Skip to content

fix(signals): a released memo's queued retry runs before the release (#4474) - #4535

Merged
ryansolid merged 3 commits into
nextfrom
fix/release-after-retry-4474
Nov 1, 2026
Merged

ryansolid merged 3 commits into
nextfrom
fix/release-after-retry-4474

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Fixes #4474

Cause

The memo in #4474 was never lazy — it was eager, but unowned, so it sits on the observation lifecycle (CONFIG_AUTO_DISPOSE). Blocked on an uninitialized async source at creation, it is re-enqueued by the source's landing like any dependent: the settle walk (settlePendingSource.settle, async.ts) sees _blocked, calls enqueueSub(node) to queue the retry — and, finding the node unobserved, pushes it onto released. Post-walk releaseIfSettledUnobserved → unobserved → deleteFromHeap pulled that retry back out of the heap. The memo never computed the landing. When a later flush re-asked the source and revived the node, commitPendingNode cleared STATUS_UNINITIALIZED on a node that had never produced a value, so latest() returned undefined beside isPending() === true — the screen pinned in latest-unread-identity-memo-4474.test.ts.

Fix

Do not let the release delete the retry the walk queued — run it, then release.

  • constants.ts: one bit, REACTIVE_RELEASE_AFTER = 1 << 22.
  • async.ts releaseIfSettledUnobserved: a node that is already REACTIVE_IN_HEAP is marked instead of released (node._flags |= REACTIVE_RELEASE_AFTER); a node not in the heap is released as before.
  • core.ts recompute: the bit is captured before the head wipe and, at the tail (after the missedWake block), releaseIfSettledUnobserved(el) runs — the node is released now if it is still unobserved and settled; one that went pending is an observer's again (its landing releases it); one re-queued is released at that pass's tail.

Variant U1 from the brief. U2 (dormantNodes-based) was built and rejected: it broke dormant-memo-chain-3554.test.ts (per-node dormantNodes.delete against nodes re-added mid-sweep) and measured +63 B minified vs U1's +48. No change to the wake side, no new arms in read/serve/verdictValue, no inline recompute.

Rules

  • D-22 — a memo nobody has read has still produced what the flushes it missed would have given it; laziness is never observable. Status in RULES-DERIVED.md updated: fixed 2026-10-31 at the release; "lazy memo" wording changed to "a memo on the observation lifecycle (unowned, or lazy: true)"; the parked wake attempt (fix/core-batch-4474-4417, ed85ca2a3) kept as one sentence of history. RULES-INDEX.md regenerated (no diff — no new IDs).
  • F-9 satisfied: a memo born held reads as its owned twin does; latest of it returns the landing's value, the plain read throws while pending.
  • [signals 2.0 beta.25] Autodispose leak: derivatively-pending lazy memo is never released after losing its last subscriber (recomputes forever) #2934 preserved: the release still happens when the pending state clears — one pass later, at that pass's tail, not never. No perpetual recompute: released-memo-retry-runs-4474.test.ts pins exactly one run / one fetch at the landing, zero runs on a later source write while released, then revival on read.
  • 2.0.0-rc.3 | didn't get the memo (isPending memo reads inconsistently in a transition) #3078 preserved: prepareComputed's read-revive is untouched; the pre-heap sweepDormant still reclaims (does not recompute) a dormant memo merely dirtied in the same tick — pinned in the new test file.

Tests

Flipped pins:

New pins (packages/signals/tests/released-memo-retry-runs-4474.test.ts):

Suites: full signals, solid, web client + server + hydrate, pnpm types — all green.

Generator

Replayed with zz-trace-ids.spec.ts (scratch, removed) base vs head, 284 ids in ~2 min: attempt-4's 91501 #15992; 7 #6390, #21333, #15285, #22337, #38671, #42673, #20240; comm -13 _b7 _cb7 (seed 7) and comm -13 _b3289 _cb3289 (seed 3289). 10 differ, all law improvements, zero new laws or errors, zero memo-twin disagreements.

Regression gate regression.spec.ts (seed 3289, 1500 cases vs baseline): pass, no regressions.

Size

Measured locally (darwin) with scripts/size over next @ e44f82445 (rebased; the base now includes #4494's store-field mangling), brotli / minified, bytes:

scenario base head Δ brotli Δ min
signals: core floor 7407 / 19918 7431 / 19966 +24 +48
signals: + isPending/latest 11370 / 31324 11394 / 31373 +24 +49
app: render + one signal (hello world) 9757 / 26749 9791 / 26799 +34 +50
app: hydrating (no stores) 18720 / 53612 18743 / 53665 +23 +53
page: live server components 39057 / 118135 39169 / 118188 +112 +53

Every floor pays the same +46–57 B minified (bit + ternary + capture + tail call); brotli deltas range −6…+112 by scenario (the live SC page's +112 is brotli context noise over a 53 B minified change). Within the brief's budget (≤ 55 B brotli on core floor and hello world). The gate (gate.mjs, head vs base) passes locally; + createStore and hydrating + every store primitive family are over their brotli caps on darwin but within the minified allowance (both were already over on next here — darwin/linux brotli skew) and are not bumped.

Cap bumps

Minimum the measured deltas require — each is a scenario over its brotli cap whose minified grew past the 20 B allowance. Cap = measured + 10 B rounded up to 0.01 KB; recorded minified = measured; dated ledger line on each.

scenario cap old → new recorded minified old → new
signals: core floor (floor-caps.json, frozen) 7.43 → 7.45 KB 19,923 → 19,966
signals: + until/refresh 8.06 → 8.08 KB 21,732 → 21,782
app: CSR, observe tier 15.20 → 15.22 KB 41,982 → 42,047
app: CSR, observe tier + attribution engine 29.80 → 29.82 KB 88,329 → 88,382
app: compiled floor 9.98 → 10.00 KB 27,284 → 27,331
page: live server components (floor-caps.json, frozen) 39.10 → 39.18 KB 118,123 → 118,188
page: compiled live server components 43.06 → 43.09 KB 126,293 → 126,346
page: live + router 48.69 → 48.71 KB 143,570 → 143,620

The brief pre-approved one cap bump; eight are needed (the set shifted with the rebase: hydrating + stores, compiled hydrating and base + router no longer need one; observe + attribution and compiled live SC do). Flagged for the maintainer's call.

Size-Exception: core floor 7.43 → 7.45 KB and live SC page 39.10 → 39.18 KB — #4474 correctness (D-22), +48 B minified on every floor.

Public API changes

None. REACTIVE_RELEASE_AFTER is an internal flag bit; no export, option, prop, signature or diagnostic changed.

Changed pins

  • packages/signals/tests/autodispose-pending-release.test.ts ([signals 2.0 beta.25] Autodispose leak: derivatively-pending lazy memo is never released after losing its last subscriber (recomputes forever) #2934): derivedRuns after the upstream resolves 1 → 2 (and the later counts shift by one). The landing's settle walk had already queued the blocked node's retry; that one pass now runs, then the node is released at the pass's tail. The contract pinned — no perpetual recompute, released when pending clears — holds.
  • packages/signals/tests/attribution-waterfall-eval.test.ts "one scope reading two async sources serializes them": the right fixture is now lazy: true. An eager unowned memo over root now re-runs at root's landing beside left (parallel, no waterfall — the ruled behaviour), so the fixture needed a memo that genuinely never runs until read to keep demonstrating the finding. Verified the amended fixture passes on base as well.

Notes

@changeset-bot

changeset-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c28ff03

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch

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

…4474)

An unowned (or lazy: true) memo blocked on an uninitialized async source is
re-enqueued by the source's landing like any dependent; the settle walk then
found it unobserved and released it, and the release pulled the retry back
out of the heap. The memo never computed the landing and woke later with no
value: latest() returned undefined beside isPending() === true.

The release now waits for the pass the walk queued (REACTIVE_RELEASE_AFTER):
releaseIfSettledUnobserved marks a node already in the heap instead of
releasing it, and recompute's tail releases it after the retry if it is still
unobserved and settled. One retry, then the release (#2934); reads revive and
the pre-heap sweep still reclaims a dormant memo dirtied in the same tick
(#3078). Laziness is never observable (D-22).

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/release-after-retry-4474 branch from 4c4d527 to 4e6cacd Compare October 31, 2026 09:16
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.41 KB +21 B (+0.3%) +49 B −111 B 7.43 KB ✅
signals: + createStore 14.97 KB +7 B (+0.0%) +53 B −144 B 14.99 KB ✅
signals: + isPending/latest 11.41 KB +19 B (+0.2%) +51 B 0 B 11.42 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB +21 B (+0.3%) +52 B −104 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB +20 B (+0.2%) +50 B −169 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.74 KB +23 B (+0.1%) +53 B −159 B 18.80 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.60 KB −15 B (−0.0%) +51 B 0 B 32.62 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.46 KB +36 B (+0.3%) +50 B −161 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.20 KB +8 B (+0.1%) +51 B 0 B 15.22 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.78 KB +9 B (+0.0%) +50 B −160 B 29.80 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.95 KB +15 B (+0.2%) +49 B −208 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.30 KB +31 B (+0.1%) +51 B −236 B 26.36 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.55 KB +21 B (+0.1%) +50 B −202 B 32.61 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.78 KB +33 B (+0.1%) +47 B −166 B 33.81 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.21 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.23 KB +124 B (+0.3%) +55 B −80 B 39.10 KB ⚠️ over by 135 B, 100 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.73 KB +10 B (+0.0%) +48 B −202 B 35.74 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.13 KB +4 B (+0.0%) +51 B 0 B 43.15 KB ✅ eager (counted): web.js 23.06 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.22 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.17 KB −33 B (−0.1%) +50 B −213 B 41.25 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.14 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.72 KB +47 B (+0.1%) +54 B 0 B 48.74 KB ✅ eager (counted): client.js 28.20 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.16 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.85 KB 0 B 0 B 0 B 22.87 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 135 B; minified 118,060 B vs 118,140 B recorded with the cap (−80 B) — 100 B of the 20 B minified allowance left; +55 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 31, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41273697845

Coverage remained the same at 78.134%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1367
Covered Lines: 1129
Line Coverage: 82.59%
Relevant Branches: 1098
Covered Branches: 797
Branch Coverage: 72.59%
Branches in Coverage %: Yes
Coverage Strength: 30.08 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 200 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing fix/release-after-retry-4474 (0f5daf3) with next (62745f6)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

ryansolid added a commit that referenced this pull request Nov 1, 2026
… on hello world) (#4560)

* perf(size): share the dependent walks, dep clearing and event-walk hops

The three async dependent walks (settle, errored settle, release) share
one visited-set walker; clearDeps reuses trimStaleDeps; the delegated
event walk shares its parent hop and owner-boundary test. Drops checks
already implied by pendingSource, and escalate's prod-dead default.
Cold paths only; no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(size): fold the trailing hold guard into one expression

Same truth table as the if: a non-effect holds, an effect holds unless
freshHook answers CAUGHT_REARMED; freshHook is still read once. About
-1 B minified on the boundary rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* fix(signals): the shared settle walk keeps a diamond join's early return

walkDependents skipped a node an earlier branch had walked before calling
the settle callback, so a diamond's join no longer took the settle walk's
early-return branch (a verdict reader's re-run, a staged error's mark) on
its second arrival. The callback now sees every arrival with a `seen`
flag; the release and errored walks ignore revisits as before. Pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(size): notifyStatus writes the status flags once; setPendingError picks one error

notifyStatus's pending-source branch computed the same flags the general
formula gives a pending status; both branches now share it, the order of
writes otherwise unchanged (neither pending-source helper reads the
flags). setPendingError chooses the given, current or a fresh
NotReadyError in one expression — the same error lands in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(signals): settle allocates the lane queues only with the lane seam

Without the lane engine installed the lanes pair stayed empty every
flush; it is now created only when the seam runs, and the merge below
reads it optionally. Same queues in the same order either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(size): flatten shares its thunk walk

flatten and flattenArray share isThunk/unwrap/skipped: the same checks in
the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(size): one helper queues an effect's owed run

commitPendingNode's born-held effect and notifyEffectStatus's errored
user effect both mark the run owed and queue the bound runner; they now
share queueEffectRun (the runner is the same function either way).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(size): template and render each read a value once

template's create reads the content's first child once; render calls
the app once before choosing the document or the insert path (the error
hook is set before it either way, as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore: changeset covers the added savings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* test(signals): the diamond pin links #4562

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* revert: flatten keeps next's thunk walk

Reverts fdb6140. Review measured it as worse on brotli (hello world -5 B,
render+one -12, CSR -15, compiled CSR -33 without it, for +62 B minified),
and flatten runs per row. flatten.ts is now identical to next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

---------

Co-authored-by: Claude <noreply@anthropic.com>
…4474

scripts/size takes next's side; the ledger is re-recorded from CI in the
next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Batch re-record for #4547, #4554, #4558, #4555, #4560 and #4535, from CI's
head size job 124750323866 on 0f5daf3. Five rows were over their brotli
caps with minified below the recorded size (#4560's savings): caps raised
to measured + 10 B rounded up to 0.01 KB, recorded minified lowered to
measured. The frozen floors are unchanged; page: live server components
stays over its brotli cap within the minified allowance (-80 B).
Hello world 9,954 B / 9.98 KB, render + one signal 9,780 B / 9.80 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Copy link
Copy Markdown
Member Author

Merged next @ 62745f6 (which includes #4560's floor savings) into this branch as 0f5daf3, then re-recorded the size ledger in c28ff03.

The "Cap bumps" table and the Size-Exception line in the description are superseded. On the merged branch no frozen floor moves and no cap needs raising for this fix. CI's head size job (124750323866) and a local run agree byte for byte:

scenario brotli / cap minified
app: compiled floor (hello world) 9,954 B / 9.98 KB 27,076
app: render + one signal 9,780 B / 9.80 KB 26,585
signals: core floor 7,411 B / 7.43 KB 19,812

c28ff03 is the batch re-record for #4547, #4554, #4558, #4555, #4560 and this PR. Five rows were over their brotli caps while their minified sizes sat below the recorded ones: isPending/latest, hydrating + every store, CSR observe tier, compiled live SC, and live + router. For each of those rows the cap goes to measured + 10 B and the recorded minified drops to measured. page: live server components is a frozen floor. It's left unchanged and stays over its brotli cap within the minified allowance (−80 B minified vs recorded).

The merge's scripts/size conflict took next's side, and the code merged cleanly. The #4474 tests and the diamond pin from #4560 pass locally.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

CodSpeed's −5.47% on "memo + sync render effect only (reference)" at c28ff03 is mostly noise; CodSpeed flagged it as run on different hardware. Callgrind measures this branch at +0.29% instructions per mount, which is under the 0.5% we treat as real.

How I measured: prod bundles built from packages/signals/src with Rolldown and the repo's mangle-props, run as valgrind --tool=callgrind node --predictable --single-threaded. The workload is the bench's mount(4000, false). Per-mount instructions are (3 mounts − 1 mount) / 2, so startup cancels out, and a repeat run matched to within 0.0002%.

build 1 mount 3 mounts per mount vs next
next 62745f6 503,919,062 884,506,258 190.29M —
this PR c28ff03 505,099,659 886,780,575 190.84M +0.29%

The +0.29% is the extra release-flag capture and the tail check in recompute.


Generated by Claude Code

@ryansolid
ryansolid merged commit 74126b1 into next Nov 1, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants