Skip to content

fix(signals): a render effect that stops reading a pending memo releases the memo's source (fuzzer #3446 P1) - #3488

Merged
ryansolid merged 2 commits into
nextfrom
fix/gated-reporter-hold
Sep 16, 2026
Merged

ryansolid merged 2 commits into
nextfrom
fix/gated-reporter-hold

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Stacked on #3485 (the posture matrix cell this fixes); merges cleanly after it.

Bug

A render effect that directly observed a pending async memo, then re-ran without reading it (a show() gate closed), kept the memo's source held — forever when the flight never lands:

const m = createMemo(() => { s(); return new Promise(() => {}); });
createRenderEffect(() => (show() ? m() : "hidden"), );
flush(); setS(1); flush();     // s held by m's flight — correct
setShow(false); flush();       // effect re-runs "hidden", no longer reads m
// before: s() === 0 and isPending(s) === true, forever

Found by the semantic fuzzer (#3446, law P1: "ordinary writes publish after a drain when no visible reader still needs an unresolved answer") — 21 of 1,000 cases in one symptom group, case 854. Reproduced as the posture matrix's effect × gatedAway cell; the memo-between variant (memo × gatedAway) already released, which narrowed it to the direct-effect reporter.

Cause

Not the liveness predicate: reporterBlocksSource already judged the re-run effect dead (no pending status, no pending sources, deps without the memo). Nobody re-asked: the flush that re-ran the effect had no active transaction, and transitionComplete only judges the active one. wokenTransitions' own doc comment names the class — "a reporter that stops counting for another reason … no check comes" — with two sites: disposal (#3372) and boundary reset (#3375). This is the third.

Fix

recompute's tail: a pending reporter (render effect) that recovers without its flight landing — wasPending && !(STATUS_PENDING) after the pass — wakes the transaction it reported to (wokenTransitions.push(t); schedule()), skipped under that transaction's own flush (the landing that recovers a reader there is judged by that flush). Reporters are stamped, so no unstamped fallback. +100 B minified (24,478 → 24,578); +createStore brotli cap +1 B.

Fuzzer, same campaign against the fix: 21 → 4 failures, policy unchanged (12; those translate to plain Solid that publishes synchronously — the fuzzer's wait-budget model).

Remaining, pinned it.fails (spec O3, remaining form)

The 4 are one shape: gate and write in the same flush (case 21; case 79 multi-step). The effect is notified pending by the write (registering as reporter) and dirtied by the gate in the same flush; the verdict runs after the pure phase, before effects, so the effect still looks live, the transaction parks, and the effect's run — the one that would prove it dead — is stashed with the transaction. The hold keeps the run that would release it. That's the effect-phase parking question (#3407), not a fifth predicate arm; left for the consolidation.

Tests

posture-born-held-and-observation.test.ts: P1 pin flipped from it.fails to it; same-flush form added as it.fails. signals 2,495 + 1 expected fail; solid 620; web 823.

ryansolid and others added 2 commits September 15, 2026 23:56
…er, the fuzzer's P1 as a cell and a pinned violation

Adds to the posture matrix:
- posture `gatedAway`: the reader is built behind a `show()` gate, then the
  gate closes — alive, no longer deriving from `x`. Records `x`,
  `isPending(x)` and the state's held SOURCE write after the gate closes,
  before any release.
- reader `effect`: a render effect reading `x` DIRECTLY (the reporter shape
  the fuzzer's reduction used; the matrix only had memo + effect-over-memo).
- state contract: `source` (the mainline signal whose write the flight holds)
  and `perturb` (a write made after the matrix reader exists, so the reader
  observes the flight it opens).
- a matrix-only state, "pending own async, observed only by the matrix
  reader (fuzzer P1)".

Result: fuzzer #3446 law P1 (21/1000 cases, one symptom group, case 854)
reproduces as exactly one cell — `effect × gatedAway` on that state: the
source's write stays held after the only reader gated away; with a memo
between the effect and the flight (`memo × gatedAway`) it releases. Pinned
`it.fails` in posture-born-held-and-observation.test.ts; recorded as spec O3
(violation, open): A15 / #3426 live reporter — "re-ran and no longer derives
from the source" is the fifth liveness case, to be fixed in one readerLive()
predicate rather than a fifth arm of reporterBlocksSource.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ses the memo's source (fuzzer #3446 P1)

A pending REPORTER that recovers without its flight landing — its pass no
longer reads the source (a show() gate closed) — stops counting for the
transaction it reported to (A15 / #3426: the hold lasts while a live
reporter observes the flight). `reporterBlocksSource` already judged the
re-run effect dead; nothing RE-JUDGED the parked transaction, since the
flush that re-ran the effect had no active transaction, and the writes held
with it stayed staged for as long as the flight stayed up — forever, for one
that never lands.

recompute's tail now treats that recovery as the completion event it is and
wakes the transaction (wokenTransitions — the third site after disposal
#3372 and boundary reset #3375), skipped under the transaction's own flush,
which judges the landing itself. Reporters are stamped, so no unstamped
fallback is needed.

Found by the semantic fuzzer (#3446, law P1, 21/1000 cases in one symptom
group, case 854); reproduced as the posture matrix's `effect × gatedAway`
cell on the "observed only by the matrix reader" state — the memo-between
variant (`memo × gatedAway`) already released, which is what narrowed it to
the direct-effect reporter. Pin flipped from it.fails; spec O3 fixed.
+100 B minified (24,478 -> 24,578).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 62b0a22

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

This PR includes changesets to release 11 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

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

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35067907246

Coverage remained the same at 71.46%

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: 1018
Covered Lines: 772
Line Coverage: 75.83%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.94 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 16, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 175 untouched benchmarks


Comparing fix/gated-reporter-hold (62b0a22) with next (48463f5)1

Open in CodSpeed

Footnotes

  1. No successful run was found on next (3a09d3f) during the generation of this report, so 48463f5 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

2 participants