Skip to content

fix(signals): reporter liveness reads this pass's deps; a dropped dep retires the reporter (fuzzer #3446 P1, remaining forms) - #3495

Merged
ryansolid merged 4 commits into
nextfrom
fix/staged-reporter-deps
Sep 16, 2026
Merged

ryansolid merged 4 commits into
nextfrom
fix/staged-reporter-deps

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Closes spec O3 fully. Fuzzer (#3446), same campaign as every run this week: 994 pass / 0 fail / 6 policy, from 984 / 4 / 12 after #3488.

The remaining forms

After #3488 the fuzzer's law P1 still failed on 4 cases (21, 79). Both are "a reporter stopped observing the flight and nobody noticed", in shapes #3488's wake site didn't cover:

Case 21 — gate and write in one flush. Probing showed the effect's compute does run in that flush and stages "hidden". A30 keeps a staged pass's previous deps linked past _depsTail until the commit trims them — so reporterBlocksSource's deps scan, walking the whole list, found the kept dep on the memo and called the effect live. The hold kept the dep that kept the hold. Rule 2's predicate reading Rule 3's deferral.

Case 79 — an initialized memo refetching. The reader shows the committed value through the stale-reader carve-out (heldFromStale) and registers as a reporter without ever being pending — so #3488's "recovered from pending" wake never fires. And the transaction waiting on it registered it without stamping it (a later write's hold over a flight an earlier step observed), so a stamp-keyed wake named the wrong transaction.

Fix (three corrections, scheduler.ts + core.ts)

  1. reporterBlocksSource's deps scan is bounded at _depsTail — this pass's reads, not the committed frame's kept tail. A trimmed list ends there anyway; a pass that read nothing has a null tail; a pending reporter's source is in the prefix (linked before the throw), so the _pendingSources and error arms are unaffected.
  2. recompute's tail retires a reporter when its pass dropped a dep (deps past the tail, or a pass that read nothing) — not only when it recovered from pending.
  3. The retirement wakes every parked transaction (wakeParked), not the reporter's stamp. One idle pass per parked transaction; done ones return at re-entry.

Effects only (reporters register from render-effect notification, INV-3). Same-flush pin flipped from it.fails.

What the fix uncovered

The posture matrix had been leaking parked transactions — dead reporters never re-judged, this exact bug — which kept transitions.size > 0 and silenced every __TEST__ quiescence invariant for the rest of each run. With the leak gone, quiescence checks run again and surface a pre-existing INV-4: a projection leaf's latest() shadow reads stale on the synchronous flush right after the store's root is disposed mid-refetch (spec O5). Reproduces standalone on next; externally the leaf and its latest() agree, so the stale pair is an internal companion owner (the projection's firewall node is the candidate); the window closes a microtask later. Under __TEST__ the runtime's own scheduled flush throws in that window and leaves the scheduler mid-flush.

  • Pinned it.fails in tests/inv4-projection-dispose-shadow.test.ts (its own file: a live action in a sibling test masks the check).
  • The matrix runner now records INVARIANT_VIOLATIONs per cell (new invariant column) instead of dying; the two cells that trigger INV-4 (gatedAway × the projection states) are excluded until it's fixed. Every other cell's served value is unchanged vs the pre-fix report.

Gate

signals 2,734 + 2 expected fails (INV-4; S1/O4) / solid 620 / web 825. Floor 24,578 flat (0 B minified); two CSR brotli caps +30 / +40 B. CodSpeed pending.

@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0e80ca7

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

coveralls commented Sep 16, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35161441724

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 improve performance by 16.74%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 174 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
projection derive: write one NESTED field (reference) 2.1 ms 1.8 ms +16.74%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/staged-reporter-deps (0e80ca7) with next (d741d1e)

Open in CodSpeed

ryansolid and others added 4 commits September 16, 2026 16:10
… retires the reporter and wakes every parked transaction (fuzzer #3446 P1, remaining forms)

Three corrections to how a parked transaction learns that a reporter
stopped observing the flight it waits on (A15 / #3426; spec O3):

1. `reporterBlocksSource`'s deps scan is bounded at `_depsTail` — this
   pass's reads. A staged pass keeps its previous deps linked past the tail
   until the commit trims them (A30), so a reporter whose pass had stopped
   reading the source in the same flush as the write still looked live
   through the kept dep, and the hold it kept was the commit that would have
   trimmed the dep that kept it. Rule 2's predicate reading Rule 3's
   deferral (fuzzer case 21).
2. recompute's tail retires a reporter when its pass DROPPED a dep, not
   only when it recovered from pending: a reporter registered by the
   stale-reader carve-out (heldFromStale, an initialized source refetching)
   displays the committed value and is never pending (fuzzer case 79).
3. The retirement wakes every parked transaction (wakeParked), not the
   reporter's stamp — the transaction waiting on it registered it without
   stamping it (a later write's hold over a flight an earlier step
   observed). One idle pass per parked transaction; done ones return.

Fuzzer, same campaign (seed 3289, 1000 cases): 994 pass / 0 fail / 6
policy, from 984 / 4 / 12. Same-flush pin flipped from it.fails.

Posture matrix: the runner records INVARIANT_VIOLATIONs per cell instead
of dying (new `invariant` column). With the parked-transaction leak gone,
quiescence checks run again and surface a pre-existing INV-4 — a
projection leaf's latest() shadow is stale on the flush right after its
root is disposed mid-refetch (spec O5; pinned it.fails as S3 in
posture-store-parity.test.ts; standalone repro on `next`). The two matrix
cells that trigger it are excluded until it is fixed; every other cell's
served value is unchanged.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…d it.fails in its own file (spec O5); size caps

Surfaced by the O3 fix: with the parked-transaction leak gone, the posture
matrix's quiescence checks run again and INV-4 fires on a pre-existing
window — the synchronous flush right after disposing a projection store
with a held refetch and a latest() companion on a leaf. Externally the leaf
and its latest() agree; the stale pair is an internal companion owner, and
the window closes a microtask later. Pinned in its own file because a live
action in a sibling test masks the quiescence check the same way the leak
did. Two brotli caps +50 B for the O3 fix (0 B minified in the floor).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…both were swept in by a soft-reset squash from a pre-#3491 working tree

Co-authored-by: Cursor <cursoragent@cursor.com>
Regenerate RULES-INDEX after the reporter-liveness docs update and reconcile the combined Brotli limits after rebasing over #3496.

Co-authored-by: GPT-5.6 Sol via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/staged-reporter-deps branch from 0de7bdd to 0e80ca7 Compare September 16, 2026 23:14
@ryansolid
ryansolid merged commit 228829a into next Sep 16, 2026
7 checks passed
ryansolid added a commit to brenelz/solid that referenced this pull request Sep 17, 2026
… latest() of a dead leaf creates none (spec O5, INV-4)

Disposing a store whose async source was mid-refetch left the latest()
shadow of a leaf orphaned: never derived (its compute read through the
projection in flight, the backfilled override stood in), its override
dropped at the settle, NotReady/uninitialized forever against a leaf whose
committed value differed — INV-4 at the next quiescence. Externally
coherent (latest() fell back to the committed value), but a companion
outliving its source, which is what INV-9's rationale forbids. The
disposal snap covered the disposed computed's own companions only; a
projection's leaves hang off the firewall, not the child chain.

Two parts:
- disposeChildren(self) snaps the companions of the firewall's
  _companionChildren (the set markFirewallChildCompanions maintains,
  solidjs#3038); snapCompanionsToState retires a shadow whose owner's firewall is
  disposed — a disposed shadow reads as absent, a later read recreates it
  from the committed view. The isPending signal snaps as the owner's does.
- latestRead of a leaf whose firewall is disposed serves the committed
  value and creates no shadow: a boundary's content re-running after the
  teardown recreated one that no teardown would ever retire. That
  recreated shadow was the posture matrix's actual trigger.

Surfaced once solidjs#3495 stopped leaking parked transactions, which had kept
`transitions.size > 0` and masked every __TEST__ quiescence check in the
matrix. All 621 matrix cells run with zero invariant violations; the two
excluded cells are back. Pin flipped from it.fails and extended with the
post-disposal read. +104 B minified (owner.ts, core floor); five brotli
caps +13…+43 B.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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