Skip to content

Atom: stream-backed atom's finalizer never runs when consumed via a derived atom and idleTTL exceeds the emission interval #7526

Closed

Description

@jonathn

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:

  1. it is consumed through a derived atom rather than being subscribed to directly, and
  2. the registry's idleTTL is 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 calls registry.removeNode(parent).
  • removeNode sees atomHasTtl(atom) === true and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions