This issue was created by a scheduled, automated Claude code-review check that looks at recently merged PRs. The findings were verified against the source, but please double-check before acting on them.
Summary
PR #2619 (feat(memory-pool): GPU-to-GPU adaptive transport…, commit e99ba21) added cross-process cleanup: when a node calls free_memory_pool, the daemon broadcasts NodeEvent::FreeMemoryPool to every other node that registered or read the pool, and each recipient releases its per-process GPU/host/IPC/shmem resources.
Two paths defeat that guarantee and reintroduce the per-process leak the feature is meant to prevent.
Bug 1 — broadcast is silently dropped when the receiver's event channel is full
binaries/daemon/src/lib.rs, FreePinnedMemory handler (~line 4025):
for (node, channel) in &dataflow.subscribe_channels {
if touched.contains(node.as_ref())
&& node.as_ref() != node_id.as_ref()
&& let Err(e) = send_with_timestamp(channel, event.clone(), &self.clock)
{
tracing::warn!(/* failed to deliver FreeMemoryPool */);
}
}
send_with_timestamp (binaries/daemon/src/event_types.rs:213) returns Ok(false) — not Err — when the channel is full (it try_sends and logs "CRITICAL: control event dropped despite headroom reservation"). The loop above only inspects the Err (channel-closed) case, so an Ok(false) drop is neither retried nor surfaced by this site. When it happens, the recipient never runs process_pending_memory_pool_frees, and its GPU buffer / pinned+transit host buffer / IPC handle / shmem mapping for that pool leak for the life of the process.
Note the sibling send_reload loop (~line 4060) explicitly handles the Ok(false) "event dropped (channel full)" case; this new site does not.
Suggested fix: match on the bool, e.g. warn (and/or retry) on Ok(false) as well as Err.
Bug 2 — the read-with-free path frees the pool but never broadcasts
binaries/daemon/src/lib.rs, ReadPinnedMemory { free: true } handler (~line 3972):
if free
&& let Err(err) = self.memory_pool.free_memory_pool(&id, node_id.as_ref())
{
tracing::warn!("Failed to free memory pool {} after reading: {}", …);
}
free_memory_pool was changed by this PR to return (metadata, touched_by), and the new FreePinnedMemory handler uses touched_by to broadcast the cleanup. But this read-and-free path discards the Ok((metadata, touched)) tuple and performs no broadcast. So freeing a pool through ReadPinnedMemory { free: true } releases the daemon table entry and unlinks the shmem, yet leaves every other touched process holding its per-process resources — the exact leak the PR set out to fix, reintroduced via the other free entry point.
read_pinned_memory(id, free=true) is a public Rust node API (apis/rust/node/src/node/mod.rs:2358). The Python API currently always passes free=false, so the Python feature does not trigger this today, but the two free entry points are inconsistent.
Suggested fix: factor the "broadcast to touched nodes (excluding initiator)" logic into a shared helper and call it from both the FreePinnedMemory handler and the ReadPinnedMemory { free: true } path.
Related lower-severity observations (same subsystem)
PENDING_FREES never drained for non-Python nodes. apis/rust/node/src/event_stream/thread.rs always intercepts FreeMemoryPool and pushes into the process-global PENDING_FREES set, but drain_freed_pools() is only ever called by the Python node API — there is no Rust-side caller. A pure-Rust node that reads a pool will accumulate entries that are never drained (bounded by distinct pool count; strings only). It is effectively dead wiring for the Rust API.
- Cleanup is only drained at the top of
next() (apis/python/node/src/lib.rs:1259). A receiver that finishes its input loop (e.g. the sender frees the pool as its last action and no further inputs arrive) never drains the pending free, so its GPU/transit buffers are held until the process exits — a timeliness gap for scarce VRAM rather than a hard leak.
This is distinct from #2881 (daemon-side pool tracking not cleaned on RemoveNode); the paths above concern the new explicit-free broadcast added in #2619.
Summary
PR #2619 (
feat(memory-pool): GPU-to-GPU adaptive transport…, commite99ba21) added cross-process cleanup: when a node callsfree_memory_pool, the daemon broadcastsNodeEvent::FreeMemoryPoolto every other node that registered or read the pool, and each recipient releases its per-process GPU/host/IPC/shmem resources.Two paths defeat that guarantee and reintroduce the per-process leak the feature is meant to prevent.
Bug 1 — broadcast is silently dropped when the receiver's event channel is full
binaries/daemon/src/lib.rs,FreePinnedMemoryhandler (~line 4025):send_with_timestamp(binaries/daemon/src/event_types.rs:213) returnsOk(false)— notErr— when the channel is full (ittry_sends and logs "CRITICAL: control event dropped despite headroom reservation"). The loop above only inspects theErr(channel-closed) case, so anOk(false)drop is neither retried nor surfaced by this site. When it happens, the recipient never runsprocess_pending_memory_pool_frees, and its GPU buffer / pinned+transit host buffer / IPC handle / shmem mapping for that pool leak for the life of the process.Note the sibling
send_reloadloop (~line 4060) explicitly handles theOk(false)"event dropped (channel full)" case; this new site does not.Suggested fix: match on the
bool, e.g. warn (and/or retry) onOk(false)as well asErr.Bug 2 — the read-with-free path frees the pool but never broadcasts
binaries/daemon/src/lib.rs,ReadPinnedMemory { free: true }handler (~line 3972):free_memory_poolwas changed by this PR to return(metadata, touched_by), and the newFreePinnedMemoryhandler usestouched_byto broadcast the cleanup. But this read-and-free path discards theOk((metadata, touched))tuple and performs no broadcast. So freeing a pool throughReadPinnedMemory { free: true }releases the daemon table entry and unlinks the shmem, yet leaves every other touched process holding its per-process resources — the exact leak the PR set out to fix, reintroduced via the other free entry point.read_pinned_memory(id, free=true)is a public Rust node API (apis/rust/node/src/node/mod.rs:2358). The Python API currently always passesfree=false, so the Python feature does not trigger this today, but the two free entry points are inconsistent.Suggested fix: factor the "broadcast to touched nodes (excluding initiator)" logic into a shared helper and call it from both the
FreePinnedMemoryhandler and theReadPinnedMemory { free: true }path.Related lower-severity observations (same subsystem)
PENDING_FREESnever drained for non-Python nodes.apis/rust/node/src/event_stream/thread.rsalways interceptsFreeMemoryPooland pushes into the process-globalPENDING_FREESset, butdrain_freed_pools()is only ever called by the Python node API — there is no Rust-side caller. A pure-Rust node that reads a pool will accumulate entries that are never drained (bounded by distinct pool count; strings only). It is effectively dead wiring for the Rust API.next()(apis/python/node/src/lib.rs:1259). A receiver that finishes its input loop (e.g. the sender frees the pool as its last action and no further inputs arrive) never drains the pending free, so its GPU/transit buffers are held until the process exits — a timeliness gap for scarce VRAM rather than a hard leak.This is distinct from #2881 (daemon-side pool tracking not cleaned on
RemoveNode); the paths above concern the new explicit-freebroadcast added in #2619.