tim-smart / tim-smart/effect-atom
Bug: nested `Atom.family` can silently lose its dependency edge to GC
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 792
- Forks
- 49
- PR merge metrics
- No merged PRs in 30d
Description
Summary
Atom.family's cache uses WeakRef + FinalizationRegistry so that an atom
nobody ever touches via the Registry can be garbage collected instead of
being pinned forever. That works correctly for the case family originally
shipped with (T extends Atom<any>, pre-a642c93):
once something calls get/set/subscribe on the returned atom, the
Registry's own nodes map holds a strong reference to it, which
transitively keeps the WeakRef alive too. Whether that constraint still
reflects the intended usage today is exactly what we'd like input on — see
below.
family's type signature is T extends object, not T extends Atom<any>
(widened from the latter in a642c93,
"allow Rx.family to return anything"). This makes a nested family —
Atom.family(group => Atom.family(key => Atom.make(...))) — type-check with
no cast, because the inner Atom.family(...) call itself returns
(arg) => Atom<A>, a function, which satisfies object.
For a nested family, outer(group) returns the middle-level family accessor
function, not an atom. That function is never passed to get/set/
subscribe, so the Registry never holds a strong reference to it — nothing
anchors it. If the JS engine collects it during a period where it's otherwise
unreferenced, the outer family's WeakRef cache entry for group goes
stale. The next call to outer(group) re-runs f(group), minting a new
middle-level function with an empty cache — so outer(group)(key) now
returns a brand-new, disconnected leaf atom: same logical key, different
object identity. Anything that had already established a dependency edge on
the old leaf atom object (e.g. a derived atom's get(...) call) keeps
pointing at the orphaned original, which nothing will ever write to again.
Minimal reproduction
import { Atom, Registry } from "@effect-atom/atom"
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// CONTROL — single-level family: survives the GC gap.
{
const source = Atom.family((_key: string) => Atom.make(0))
const derived = Atom.make((get) => get(source("k")))
const registry = Registry.make({ defaultIdleTTL: 400 })
registry.subscribe(derived, () => {})
registry.set(source("k"), 1)
await delay(30)
;(globalThis as any).gc?.()
console.log(registry.get(derived)) // 1 — correct
}
// BUG — nested family: can go stale across the same GC gap.
{
const source = Atom.family((_group: string) =>
Atom.family((_key: string) => Atom.make(0)),
)
const derived = Atom.make((get) => get(source("g")("k")))
const registry = Registry.make({ defaultIdleTTL: 400 })
registry.subscribe(derived, () => {})
registry.set(source("g")("k"), 1)
await delay(30)
;(globalThis as any).gc?.()
console.log(registry.get(derived)) // 0 under `node --expose-gc` — stale
}
Run with NODE_OPTIONS=--expose-gc for a deterministic repro; without it,
it's timing-dependent on natural GC and only intermittently reproduces.
The mechanism confirms directly: holding an ordinary strong reference to the
middle-level function across the same gap (const middle = source("g"); registry.set(middle("k"), 1), instead of registry.set(source("g")("k"), 1))
prevents the failure entirely, which is consistent with the intermediate
accessor being the unprotected object.
Where this bit us
Atom.family(courseId => Atom.family(userId => Atom.make(...))) — a natural
way to express a two-key family given the API and types as they stand today —
is what a production member-management table used to store a per-member edit
overlay. Rows intermittently reverted to stale server data after a
block/unblock/role-change, non-deterministically, worse the longer a real
user session ran (more time for GC to run).
The workaround was to flatten the nested family to a single level — either
one Atom.family keyed by a composite Data.tuple, or one atom per outer
key holding a map for the inner key — which avoids the multi-level
Atom.family shape entirely.
Why I think this is a library issue, not app misuse
- The type signature (
T extends object) explicitly permitsfto return
a non-Atomobject — including another family accessor function — with
no cast or suppression needed. Nesting is not an undocumented edge case;
it's a pattern the types describe. - The commit that introduced this ("allow Rx.family to return anything")
suggests a deliberate widening. - The failure mode is silent: no exception, no rejected promise, just a
stale read that's indistinguishable from a value nobody ever changed.
Possible fixes (tradeoffs, not a strong opinion on which)
- Narrow the type back:
family: <Arg, T extends Atom<any>>(...).
Makes nestedAtom.familycalls a compile error. Zero runtime change,
keeps theWeakRefguarantee intact for the case it was built for.
Breaking change for any consumer currently nesting families. - Detect non-atom returns at runtime (e.g.
AtomTypeId in newAtom) and
fall back to a strongly-referenced cache entry for those. Keeps nested
families working, but reintroduces unbounded cache growth at whichever
level isn't a real atom — proportional to the outer key's cardinality,
which could be large depending on how a consumer nests (e.g.
Atom.family(userId => Atom.family(commentId => ...))for a busy feed). - Document the hazard and recommend a composite-key single-level family
(Atom.family((key: readonly [A, B]) => ...), usingData.tuplefor
correctEqual/Hash-based deduplication) as the supported pattern for
multi-key families, without changing the type or runtime.
Happy to send a PR for whichever direction you'd prefer.
Environment
@effect-atom/atom/@effect-atom/atom-react0.7.0- Node.js v22.22.1 (repro run under both plain and
--expose-gc)
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the Atom.family cache and Registry interaction described in the issue, then run the nested-family reproduction under Node with NODE_OPTIONS=--expose-gc. Compare the nested and single-level controls across the GC gap, and confirm that the chosen behavior preserves dependency identity and avoids stale reads.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100