Skip to content

fix: avoid stale and duplicate observers on concurrent Effect revalidation#24500

Open
heruan wants to merge 2 commits into
mainfrom
fix/effect-double-registration
Open

fix: avoid stale and duplicate observers on concurrent Effect revalidation#24500
heruan wants to merge 2 commits into
mainfrom
fix/effect-double-registration

Conversation

@heruan
Copy link
Copy Markdown
Member

@heruan heruan commented Jun 2, 2026

Problem

Effect.revalidate() registers signal-tree observers without holding the Effect monitor — this is deliberate, since holding the monitor across signal-tree calls caused the ABBA deadlock fixed in #24362. A consequence is that several revalidation / invalidation / activation attempts can run concurrently. The second synchronized block then did an unconditional registrations.addAll(...), so two overlapping attempts each added a generation of observers, leaving duplicate (and potentially stale) listeners on the signal trees.

This is a follow-up to the post-merge review feedback on #24362: the drainRegistrations() in revalidate()'s first block closed one narrow leak but established no real invariant — nothing prevented another attempt from committing right after the block exits.

Fix

A generation counter, bumped under the Effect monitor by revalidate(), invalidate(), and activate(). Each attempt captures myGeneration = ++generation in its first synchronized block and, in its second block, installs its observers only if generation == myGeneration; otherwise it removes the observers it created (outside the monitor, preserving the deadlock fix). "Latest attempt wins" then holds for every interleaving, and stale attempts clean up after themselves instead of leaking.

activate()'s fast path now uses the same generation check instead of its previous !registrations.isEmpty() heuristic, which could otherwise keep a stale generation and leave the effect tracking the wrong dependencies.

Tests

  • concurrentRevalidations_doNotDoubleRegisterObservers — freezes one revalidation between its two synchronized blocks while another runs to completion; asserts a single observer generation. Fails on the pre-fix code.
  • concurrentRevalidations_latestGenerationWins — proves the semantic property (the effect ends up tracking the latest run's dependency), which a naive "discard if non-empty" guard would get wrong.

Both use a deterministic queueing dispatcher and latches (no timing flakiness). Full EffectTest (49) and the whole signals package (648) pass.

heruan added 2 commits June 2, 2026 14:19
Two revalidations that both pass revalidate()'s first synchronized block
while registrations is empty each add their own observers in the second
block, leaving the effect with two observer generations. The test makes
the interleaving deterministic with a private queueing dispatcher and a
latch that freezes one revalidation inside the action -- after it has
captured its observers but before its second synchronized block -- while
the other revalidation runs to completion.
…ation

revalidate() registers observers without holding the Effect monitor to
avoid an ABBA deadlock with the signal tree lock. As a result several
revalidation, invalidation and activation attempts can run concurrently,
and the second synchronized block installed its observers unconditionally
-- two overlapping attempts each added a generation, leaving duplicate
and potentially stale listeners on the signal trees.

Track a generation counter, bumped under the monitor by every attempt.
An attempt installs its observers only if its captured generation is
still current; otherwise it removes the observers it created. The most
recent attempt therefore always wins, so the effect tracks the
dependencies of its latest run and never accumulates duplicates. The
activate() fast path uses the same check instead of its previous
is-empty heuristic, which could keep a stale generation.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Jun 2, 2026

@github-actions github-actions Bot added the +0.0.1 label Jun 2, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

Test Results

 1 430 files   - 2   1 430 suites   - 2   1h 21m 32s ⏱️ - 2m 56s
10 054 tests  - 5   9 986 ✅  - 5  68 💤 ±0  0 ❌ ±0 
10 526 runs   - 5  10 457 ✅  - 5  69 💤 ±0  0 ❌ ±0 

Results for commit ae31435. ± Comparison against base commit a35f9be.

@heruan heruan marked this pull request as ready for review June 2, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant