Skip to content

test(Atom): family finalizer must not evict a newer atom for the same key (failing) - #429

Open
Tanner-Scadden wants to merge 1 commit into
tim-smart:mainfrom
Tanner-Scadden:atom-family-stale-finalizer-test
Open

Tanner-Scadden wants to merge 1 commit into
tim-smart:mainfrom
Tanner-Scadden:atom-family-stale-finalizer-test

Conversation

@Tanner-Scadden

@Tanner-Scadden Tanner-Scadden commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

This adds a failing regression test for a race in Atom.family's WeakRef / FinalizationRegistry cache. It contains only the test, as a reproduction. A suggested fix is below, and I'm happy to push it here or in a follow-up if you want it.

The bug

family registers each new atom with a FinalizationRegistry whose callback removes the cache entry by key:

const registry = new FinalizationRegistry<Arg>((arg) => {
  MutableHashMap.remove(atoms, arg)
})

The callback doesn't check that the entry still points at the atom that was collected. Finalizer callbacks run at some point after collection, not right away, so this sequence can happen:

  1. Atom A for key K is created, then released.
  2. GC collects A. ref.deref() now returns undefined, but A's finalizer callback hasn't run yet.
  3. A new lookup family(K) sees the dead WeakRef, creates atom B, and caches K -> WeakRef(B).
  4. A's finalizer callback runs and removes K's entry, which now holds B.
  5. The next family(K) creates atom C, even though B is still alive and in use.

Impact

After step 5, code that kept a reference to B (for example a poller calling registry.refresh(b), or a callback that captured the atom) and code that reads family(K) again (for example components re-rendering) have different atoms for the same key. Refreshes and writes to B are never seen by readers of C, so the UI silently stops updating. It only happens under particular GC timing, which makes it intermittent and hard to reproduce in real apps.

The test

The test stubs WeakRef and FinalizationRegistry with vitest.stubGlobal so it doesn't depend on real GC timing. It marks A as collected, looks up the key again to get B, then calls A's captured finalizer callback by hand, and asserts that family(1) still returns B.

 FAIL  |@effect-atom/atom| test/Atom.test.ts > Atom > family finalizer does not evict a newer atom for the same key
AssertionError: expected { _id: 'Atom', keepAlive: false, …(2) } to equal { _id: 'Atom', keepAlive: false, …(2) }

Compared values have no visual difference.

 ❯ test/Atom.test.ts:730:14
    728|
    729|       // B is still alive, so the family must keep returning it
    730|       assert.strictEqual(count(1), b)
       |              ^
    731|     } finally {
    732|       vitest.unstubAllGlobals()

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed | 74 skipped (76)

Run with pnpm vitest run packages/atom/test/Atom.test.ts -t family.

Suggested fix (not included in this PR)

Only remove the entry if it is still the WeakRef that was registered for the collected atom:

const atoms = MutableHashMap.empty<Arg, WeakRef<T>>()
const registry = new FinalizationRegistry<readonly [Arg, WeakRef<T>]>(([arg, ref]) => {
  const current = MutableHashMap.get(atoms, arg)
  if (current._tag === "Some" && current.value === ref) {
    MutableHashMap.remove(atoms, arg)
  }
})
// ...
const ref = new WeakRef(newAtom)
MutableHashMap.set(atoms, arg, ref)
registry.register(newAtom, [arg, ref])

The held value holds only the WeakRef, not the atom, so registering it doesn't keep the atom alive. I checked locally that with this change the new test passes, along with the rest of Atom.test.ts (76/76).

Notes

🤖 Generated with Claude Code

… key

Adds a failing regression test. When an atom created by Atom.family is
collected, its FinalizationRegistry callback removes the cache entry by
key without checking whether the entry still refers to the collected
atom. If a newer atom for the same key was cached before the callback
ran, it is evicted and the next lookup creates a third atom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant