Repository navigation
Atom: stream-backed atom's finalizer never runs when consumed via a derived atom and idleTTL exceeds the emission interval #7526
Description
What version of Effect is running?
effect@4.0.0-rc.112
What steps can reproduce the bug?
I was using Claude Opus 5 to evaluate Effect v4, and it uncovered what it believes to be a genuine, reproducible bug.
The following is the bug report prepared by Claude.
An Atom backed by a Stream that holds an external resource (via Effect.acquireRelease) is
never disposed — and its finalizer never runs — when both of the following hold:
- it is consumed through a derived atom rather than being subscribed to directly, and
- the registry's
idleTTLis greater than the stream's emission interval.
The leak is self-sustaining: the node is scheduled for removal, but the still-running stream's own
writes cancel the pending removal, so it is rescheduled forever.
Standalone repro — no UI framework involved, effect core only:
// npm i effect@4.0.0-rc.112 && node repro.mjs <idleTTL> <emitIntervalMs>
import * as Atom from "effect/unstable/reactivity/Atom"
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
import * as Effect from "effect/Effect"
import * as Stream from "effect/Stream"
import * as Queue from "effect/Queue"
const ttl = process.argv[2] === undefined ? 400 : Number(process.argv[2])
const EMIT_MS = Number(process.argv[3] ?? 100)
let released = false
const source = Stream.callback((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
console.log("[ACQUIRE] resource opened")
return setInterval(() => Queue.offerUnsafe(queue, Date.now()), EMIT_MS)
}),
(handle) => Effect.sync(() => {
console.log("[RELEASE] resource closed")
released = true
clearInterval(handle)
}),
).pipe(Effect.asVoid),
)
const feedAtom = Atom.withLabel(Atom.make(() => source), "FEED")
const derivedAtom = Atom.withLabel(
Atom.make((get) => AsyncResult.isSuccess(get(feedAtom)) ? "ready" : "init"), "DERIVED")
const registry = AtomRegistry.make({ defaultIdleTTL: ttl })
const dump = (tag) => {
const rows = []
for (const [key, node] of registry.getNodes()) {
rows.push(`${String(node.atom.label ?? key).split(",")[0].padEnd(8)}` +
` state=${node.currentState().padEnd(6)} listeners=${node.listeners.size} children=${node.children.size}`)
}
console.log(`[${tag}] ` + (rows.join(" | ") || "(no nodes)"))
}
const unsubscribe = registry.subscribe(derivedAtom, () => {}, { immediate: true })
setTimeout(() => {
dump("BEFORE unsubscribe")
unsubscribe()
setTimeout(() => {
dump("AFTER unsubscribe +2000ms")
console.log(`\nRESULT: resource released? ${released} -> ${released ? "CLEAN" : "LEAKED"}`)
process.exit(released ? 0 : 1)
}, 2000)
}, 600)What is the expected behavior?
After the last subscriber unsubscribes, the derived atom and the stream atom should both be
removed and the stream's acquireRelease finalizer should run, releasing the resource.
What do you see instead?
The DERIVED node is removed correctly, but FEED is left orphaned and permanently resident —
listeners=0 children=0 state=valid — and [RELEASE] never prints.
[ACQUIRE] resource opened
[BEFORE unsubscribe] DERIVED state=valid listeners=1 children=0 | FEED state=valid listeners=0 children=1
[AFTER unsubscribe +2000ms] FEED state=valid listeners=0 children=0
RESULT: resource released? false -> LEAKED
The behaviour is fully deterministic and depends only on idleTTL vs the emission interval:
idleTTL |
emit interval | result |
|---|---|---|
| 400 | 100ms | LEAKED (3/3 runs) |
| 400 | 2000ms | CLEAN |
| 5000 | 100ms | LEAKED |
| 5000 | 2000ms | LEAKED |
| 0 | 100ms | CLEAN |
| 1 | 100ms | CLEAN |
Subscribing to feedAtom directly is always clean; the leak requires the derived-atom path.
Additional information
The mechanism appears to be in AtomRegistry:
Node.remove()correctly detaches the child and callsregistry.removeNode(parent).removeNodeseesatomHasTtl(atom) === trueand therefore defers removal via
setNodeTimeout(node)instead of removing immediately.- The stream is still running. Every emission calls
ensureNode(atom), which does
else if (this.atomHasTtl(atom)) { this.removeNodeTimeout(node) }— cancelling its own
pending removal.
So a node awaiting a TTL sweep is resurrected by its own still-running producer, indefinitely.
With idleTTL: 0/undefined, atomHasTtl is false, removeNode deletes immediately, and there
is no window in which the resurrection can happen — which is why those cases are clean.
A possible fix would be for ensureNode not to cancel a pending removal timeout when the node
has listeners.size === 0 && children.size === 0 — i.e. writes originating from the node's own
producer should not keep an already-orphaned node alive.
Note this affects @effect/atom-react too when the module-level default registry is used
(defaultIdleTTL: 400); it is only avoided with <RegistryProvider>, which leaves idleTTL
undefined. See the companion issue (#7527) about @effect/atom-solid hardcoding a TTL.
Minor, possibly separate: passing defaultIdleTTL: 0 emits
TimeoutNaNWarning: NaN is not a number. Timeout duration was set to 1.
Activity