Skip to content

fix(daemon): reclaim memory pools once no live node can reach them - #3014

Merged
trunk-io[bot] merged 3 commits into
mainfrom
fix-2881-pool-reclaim
Aug 13, 2026
Merged

fix(daemon): reclaim memory pools once no live node can reach them#3014
trunk-io[bot] merged 3 commits into
mainfrom
fix-2881-pool-reclaim

Conversation

@phil-opp

@phil-opp phil-opp commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #2881.

Pools registered by a node stayed in the daemon's table until the daemon itself
exited: neither RemoveNode nor a node crash released them, and finish_dataflow
did not either — so a long-running daemon (dora up) accumulated table entries and
/dev/shm segments across dataflows, walking toward the 512-entry pool cap.

Eagerly freeing a node's pools when it goes away would be wrong, as @SaitejaKommi
pointed out on the issue: a pool deliberately outlives its registrar. The normal
lifecycle is that a sender registers a pool and a receiver reads and frees it,
possibly well after the sender exited.

So reclaim by reachability instead. Each entry records who can still reach it:

  • touched_by — nodes that have opened the pool, and
  • potential_readers — the registrar's transitive downstream consumers as of
    registration time, which can still learn the pool id from a message already in
    flight (the daemon cannot see pool ids inside payloads, so the whole downstream
    closure counts).

A pool is released once none of those is still running locally. Every path that ends
an incarnation now checks: RemoveNode, ReplaceNode (whose outgoing exit event the
generation guard drops), and a crash, clean exit or restart. finish_dataflow
releases the remainder unconditionally — a finished dataflow has no node left to
serve, and its routing, channels and listeners are discarded in the same breath.

Nodes on other daemons never count as live: pools are host-local shared memory and
each daemon keeps its own table, so a remote consumer can never open this one's
segments.

Because a reclaimed pool has no live node in touched_by, there is nobody to send a
FreeMemoryPool notification to — unlike free_memory_pool, which must tell the
other holders to drop their per-process buffers.

finish_dataflow also sweeps the /dev/shm segments no table entry covers — a node
that died between creating a segment and registering it. That sweep is now scoped to
the nodes of this daemon, which it was not before: /dev/shm is host-wide, the
segment name embeds only the dataflow id, and finish_dataflow is a per-daemon
finish. With two daemons serving one dataflow on one machine (a machine: split),
whichever finished first would have unlinked the other's live segments, leaving a
consumer over there with ENOENT. RunningDataflow tracks its local nodes for this,
and the pre-existing spawn-time sweep — same hazard, narrower window — is scoped the
same way.

register_memory_pool gains a potential_readers parameter, MemoryPoolEntry a
field, and cleanup_orphans an is_local_node predicate, so this is a breaking
change to the dora-memory-pool crate surface. Its only consumer is dora-daemon.

Reachability cannot be a registration-time snapshot, because a running dataflow can
be rewired. dora graph connect S/out C/in after S registered a pool gives C a
path to it, and S is in its own reader set, so reclamation on S's exit would have
unlinked the segment while C still had the id in flight. AddMapping and AddNode
therefore widen the reader set of every pool the edge's source can reach, by the new
target's downstream closure. Recomputing the whole set at reclaim time is not an
option — RemoveNode tears the node's mappings down first — but widening on the two
paths that create edges covers the same ground.

The closure also follows edges whose receiver is on another daemon. mappings holds
only what this daemon delivers, so a local -> remote -> local chain used to stop at
the first hop and drop the local node at its end, even though a pool id can travel
the whole way. A remote node never counts as live, so following through one costs
nothing.

Tests: reachability rules and dataflow-scoped cleanup in dora-memory-pool; the
downstream closure (transitive, cycle-safe) on RunningDataflow; and the daemon
wiring — a pool survives an exited sender while its consumer runs, and is reclaimed
once the last consumer is gone, when the exiting node is its only reference, and
regardless of unrelated long-lived nodes. The finish path is covered too: it releases
a pool a live consumer could still reach, and its /dev/shm sweep unlinks this
daemon's orphan while leaving a co-located daemon's segment alone. Plus the two
reachability gaps: a consumer connected after registration keeps the pool alive until
it too is gone, and the closure reaches a local node whose only path runs through
another daemon.

@trunk-io

trunk-io Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

😎 Merged successfully - details.

phil-opp commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Automated review by Claude — fully automated review, no human in the loop; please verify before acting.

I reviewed the current diff. No issues found.

What I traced:

  • Reachability is touched_by ∪ potential_readers (libraries/extensions/memory-pool/src/lib.rs:64-70), and potential_readers is the full transitive downstream closure (binaries/daemon/src/running_dataflow.rs:1021-1039), so a pool id forwarded through any chain of edges keeps the pool alive — the closure follows every queued node's outputs, not just the registrar's direct consumers, and the cycle guard via reachable.insert terminates.
  • The exited node is correctly excluded from the live set in reclaim_memory_pools_after_exit (binaries/daemon/src/lib.rs:658-662), which matters because the SpawnedNodeResult path runs before the node leaves running_nodes; otherwise a consumer-less sender could never be reclaimed.
  • release_matching drops the table lock before the remove_file unlinks, matching the existing free_memory_pool locking discipline, and skipping FreeMemoryPool notifications is justified — reclamation only fires when no live node remains in either set, so there is nobody to notify.
  • Local-only liveness is correct: pools are host-local shared memory, so a remote consumer in the closure never pins one, and unlinking a segment a live node already mapped is safe (existing mappings survive shm_unlink).
  • finish_dataflow's unconditional cleanup_dataflow + cleanup_orphans is safe for dynamic nodes that outlive the finish, since their routing/channels are already torn down.
  • The tests exercise the actual change, including the leak scenario (unrelated node running), the in-flight-transfer scenario (consumer still running), dataflow scoping, and the late-reader-kept-via-touched_by case.

Generated by Claude Code

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@phil-opp the Trunk merge queue failed for this PR.

See the Trunk merge-status comment for details.

Posted as a new comment so GitHub sends an email — Trunk's sticky comment is edited in place and won't trigger a notification.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@phil-opp the Trunk merge queue failed for this PR.

See the Trunk merge-status comment for details.

Posted as a new comment so GitHub sends an email — Trunk's sticky comment is edited in place and won't trigger a notification.

@phil-opp phil-opp modified the milestones: 1.0, 1.1 Aug 13, 2026
phil-opp and others added 3 commits August 13, 2026 11:49
…2881)

Pools registered by a node stayed in the daemon's table until the daemon
itself exited: neither `RemoveNode` nor a node crash released them, and
`finish_dataflow` did not either, so a long-running daemon (`dora up`)
accumulated table entries and /dev/shm segments across dataflows.

Eagerly freeing a node's pools when it goes away is not correct, as
pointed out on the issue: a pool deliberately outlives its registrar —
the normal lifecycle is that a sender registers a pool and a *receiver*
reads and frees it, possibly well after the sender exited.

So reclaim by reachability instead. Each entry now records who can still
reach it: the nodes that have opened it (`touched_by`) plus the
registrar's transitive downstream consumers as of registration time,
which can still learn the pool id from a message already in flight. A
pool is released once none of those is running locally, which every path
that ends an incarnation now checks — `RemoveNode`, `ReplaceNode`, and a
crash, clean exit or restart. `finish_dataflow` releases the rest
unconditionally, since a finished dataflow has no node left to serve.

Remote nodes never count as live: pools are host-local shared memory and
every daemon keeps its own table, so a consumer on another daemon can
never open this one's segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cleanup_orphans` unlinked every `dora_pool_{dataflow_id}_*` segment on the
host. `/dev/shm` is host-wide but the name carries only the dataflow id, and
both call sites are per-daemon: with two daemons serving one dataflow on one
machine, the spawn-time sweep races the peer's spawn and the finish-time one
runs while the peer may still be going — so a finishing daemon destroyed the
peer's live segments and left its consumers with ENOENT.

Sweep only the segments of nodes this daemon owns. `RunningDataflow` records
them (`spawn_nodes` plus anything `AddNode` brings later), and the node id is
recoverable from the segment name, whose last component is always the
counter. A name that does not fit the pattern is left alone: leaving an
orphan is recoverable, unlinking a peer's live segment is not.

The finish-time release moves into a free function so it can be tested
without a whole `Daemon` — covering both that it drops the table entries a
finished dataflow still holds, and that its sweep spares a co-located
daemon's segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`potential_readers` was fixed at registration time, which is stale the
moment the graph changes. `dora graph connect S/out C/in` after `S`
registered a pool gives `C` a path to it, but `S` is in its own reader set
and reclamation fires on its exit — so the segment was unlinked while `C`
still had the id in flight. Before pools were reclaimed at all that transfer
worked, so this was a regression introduced with the reclamation itself.

`AddMapping` and `AddNode` — the two paths that add an edge to a running
dataflow — now widen the reader set of every pool the edge's source can
reach by the target's downstream closure. Recomputing the whole set at
reclaim time is not possible (`RemoveNode` tears the mappings down first),
but updating where edges are created covers the same ground.

The closure itself was also short: it walks `mappings`, which holds only
edges this daemon delivers, so a `local -> remote -> local` chain stopped at
the first hop and lost the local node at the end — while a pool id travels
the whole chain. Remote edges are recorded at spawn and followed. A remote
node never counts as live, so following through one only widens what the
closure can reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@phil-opp
phil-opp force-pushed the fix-2881-pool-reclaim branch from a8d2326 to 5a5712e Compare August 13, 2026 12:14
@phil-opp

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and addressed all three points.

The finish-time sweep crossing daemon boundaries. Correct, and the spawn-time
sweep had the same defect with a narrower window. cleanup_orphans now takes an
is_local_node predicate and attributes each /dev/shm file to a node before
unlinking it — the segment name's last component is always the counter, so the node
id splits off unambiguously even when it contains _. A name that does not fit the
pattern is left alone: leaving an orphan is recoverable, unlinking a peer's live
segment is not. RunningDataflow tracks the nodes this daemon owns (spawn_nodes
plus anything AddNode adds later) to scope both call sites. The false justification
on cleanup_orphans is gone.

The "known limitation" not covering its own case. Also correct, and it was a
regression rather than a limitation, so it is fixed rather than documented.
AddMapping and AddNode — the only two paths that add an edge to a running
dataflow; ReplaceNode rejects any input change — now widen the reader set of every
pool the edge's source can reach by the new target's downstream closure. Recomputing
the whole set at reclaim time is indeed impossible, but updating where edges are
created covers the same ground.

While in there: downstream_closure walked mappings, which holds only edges this
daemon delivers, so a local -> remote -> local chain stopped at the first hop and
dropped the local node at its end — even though a pool id travels the whole chain.
Remote edges are recorded at spawn and followed now. A remote node never counts as
live, so following through one only widens what the closure reaches.

Test coverage of the finish path. The finish-time release is a free function now,
so it can be exercised without a whole Daemon: one test that it drops the table
entries a finished dataflow still holds, one that its sweep spares a co-located
daemon's segments. Plus the late-connected consumer (both halves: pinned while it
runs, reclaimed once it is gone), the reader-set widening not leaking into unrelated
pools, the segment-name parsing edge cases, and the closure's remote hop.

Two residual gaps, both erring toward retention rather than premature release:

  • Runtime rewiring only reaches the target node's daemon, since that is where the
    coordinator routes AddMapping. A local -> remote -> local chain built at
    runtime through two dora graph connect calls therefore still misses the source
    daemon's reader sets.
  • potential_readers only grows: RemoveMapping drops the edge but nothing removes
    the node, so repeated connect/disconnect cycles widen every pool's reader set and
    decay reclamation back toward teardown-only.

Copy link
Copy Markdown
Collaborator Author

🤖 Automated review by Claude — fully automated, no human reviewed this; please verify before acting.

The two commits since my last review address both issues raised on 2026-08-11, and I traced them against the diff:

  • Cross-daemon finish/spawn sweepcleanup_orphans now takes an is_local_node predicate and attributes each /dev/shm/dora_pool_* segment to a node (rsplit_once('_') + counter.parse::<u64>() guard) before unlinking, skipping any name that doesn't fit the pattern. Both call sites pass a per-daemon owner set, so a co-located daemon's live segments are no longer destroyed by a peer's finish.
  • Rewiring regressionAddMapping/AddNode now call extend_pool_readers_for_new_edge after recording the edge, widening every reachable pool's readers by the target's downstream_closure; and downstream_closure now follows remote_edges, so a local -> remote -> local chain keeps the trailing local reader. A consumer connected after registration now pins the pool it can reach.

No new issues. The two residual gaps you documented (runtime local -> remote -> local rewiring reaching only the target daemon; potential_readers growing monotonically across connect/disconnect cycles) both err toward retention rather than premature free, so they're safe as follow-ups.


Generated by Claude Code

@trunk-io
trunk-io Bot merged commit 1ccbc00 into main Aug 13, 2026
16 checks passed
@trunk-io
trunk-io Bot deleted the fix-2881-pool-reclaim branch August 13, 2026 13:03
phil-opp added a commit that referenced this pull request Aug 13, 2026
Extracts the pinned/CUDA memory-pool transport (#2168, #2386, #2619) into
external/dora-pool, staged for lifting into its own repository.

dora 1.0 ships no pool API: the four Python methods, the three
DaemonRequest/two DaemonReply variants, the daemon-side registry and the
node-api plumbing are all removed. No seam is left behind — #1872 declined
to commit to this architecture, so dora should not ship a socket moulded to
its shape.

The parked copy is the post-#3014 code (pool reclamation, #2881), so it
carries that fix rather than the pre-fix state.

external/dora-pool/README.md leads with a seam contract: a budget for any
future reinstatement (<200 lines of dora, no new unsafe), a table of what
may never return in-tree, and re-entry criteria — #1872's five unanswered
design questions, the open correctness bugs, and a GPU CI story.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phil-opp added a commit that referenced this pull request Aug 13, 2026
Extracts the pinned/CUDA memory-pool transport (#2168, #2386, #2619) into
external/dora-pool, staged for lifting into its own repository.

dora 1.0 ships no pool API: the four Python methods, the three
DaemonRequest/two DaemonReply variants, the daemon-side registry and the
node-api plumbing are all removed. No seam is left behind — #1872 declined
to commit to this architecture, so dora should not ship a socket moulded to
its shape.

The parked copy is the post-#3014 code (pool reclamation, #2881), so it
carries that fix rather than the pre-fix state.

external/dora-pool/README.md leads with a seam contract: a budget for any
future reinstatement (<200 lines of dora, no new unsafe), a table of what
may never return in-tree, and re-entry criteria — #1872's five unanswered
design questions, the open correctness bugs, and a GPU CI story.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
trunk-io Bot pushed a commit that referenced this pull request Aug 13, 2026
…nd outside the 1.0 guarantees (#3152)

* refactor: park the memory-pool transport out of the tree

Extracts the pinned/CUDA memory-pool transport (#2168, #2386, #2619) into
external/dora-pool, staged for lifting into its own repository.

dora 1.0 ships no pool API: the four Python methods, the three
DaemonRequest/two DaemonReply variants, the daemon-side registry and the
node-api plumbing are all removed. No seam is left behind — #1872 declined
to commit to this architecture, so dora should not ship a socket moulded to
its shape.

The parked copy is the post-#3014 code (pool reclamation, #2881), so it
carries that fix rather than the pre-fix state.

external/dora-pool/README.md leads with a seam contract: a budget for any
future reinstatement (<200 lines of dora, no new unsafe), a table of what
may never return in-tree, and re-entry criteria — #1872's five unanswered
design questions, the open correctness bugs, and a GPU CI story.

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

* feat(extensions): add a generic extension channel for out-of-tree transports

A transport that lives outside dora still needs one thing only the daemon can
provide: reclamation after a crash. A node that dies cannot withdraw the
descriptor it published, so its readers keep mappings to memory nobody owns
(#2881 is that failure mode with a real transport attached).

Adds a dataflow-scoped table of opaque byte values whose lifetime the daemon
brokers — store / load / drop, plus a drained notification when a key goes
away. dora never interprets the namespace, key or value.

Deliberately generic rather than shaped around any one transport: naming the
protocol variants after the memory pool would freeze that architecture into
dora, which #1872 explicitly declined to do. A second extension needs no
change here at all.

Guarantees: only the storing node may overwrite a key; entries are scoped per
dataflow and per namespace; every node that stored or read a key is notified
when it is dropped; a dropped key is reclaimed on owner exit and on dataflow
finish; dropping an absent key is a no-op so retries are safe. Bounded at 8192
entries per dataflow and 4096 pending notifications per process.

This is a control plane for descriptors, not a data plane — values are copied
through the daemon. See docs/extensions.md.

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

* feat(tensor-pool): reinstate the transport as an opt-in extension

Keeps the feature and its Python API surface, but out of dora's core: it
lives at libraries/extensions/tensor-pool (beside ros2-bridge, same python/
subcrate shape) and reaches dora only through the generic extension channel.

Opt-in and off by default, two independent flags:

  maturin develop -m apis/python/node/Cargo.toml --features tensor-pool
  cargo build -p dora-daemon --features tensor-pool

A default build neither compiles nor exposes it — the receive-path drain
becomes an empty no-op and dora's core keeps zero pool vocabulary.

NOT covered by the 1.0 compatibility guarantees, stated in the README, the
crate description, the module header, every pymethod docstring and the .pyi
stubs, along with the open defects (#3015, #2935, #2890).

Named tensor-pool, not memory-pool: dora already has an unrelated
shared_memory_pool_size descriptor key (and DORA_NODE_SHM_POOL_SIZE) for the
Zenoh SHM buffer pool, and one name for both was a persistent source of
confusion. 'gpu-' would have been the other obvious fix but is inaccurate —
the CPU path works without CUDA and is the only one with CI coverage. This
renames the four Python methods (register_memory_pool -> register_tensor_pool
and likewise for write/read/free), the crates, the feature flags and the
example env keys: a user-visible break, permissible because the feature sits
outside the 1.0 guarantees, and cheaper now than later.

The six former daemon calls now go through extension_store / extension_load /
extension_drop / drain_dropped_extension_keys, with the descriptor encoded as
JSON dora never parses (python/src/seam.rs). The unsafe pointer arithmetic,
the seqlock and the embedded libcudart bindings stay on the extension's side.

Returning the crate to the workspace put it under -D warnings for the first
time, which surfaced three latent bugs: a deprecated downcast_into, a dead
initializer in the device-to-host copy path, and seqlock_begin_write with no
callers at all (every write path uses begin_if_even). All fixed.

Known gap: the smoke tests need a feature-built wheel plus torch, so they
ship as smoke-tests.rs.example rather than a cargo target that cannot
compile. The extension has 53 unit tests but no in-tree end-to-end coverage.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory pools may not be released when a node crashes or is dynamically removed

1 participant