⚠️ This issue was created by a scheduled, automated Claude code-review check. It was not filed by a human. Please verify the finding before acting on it. This is a race, so it is intermittent, but the analysis below is concrete and the consequences are severe.
Summary
The node-lifecycle generation guard introduced with dora node replace (#2988, commit 79afcc8) drops strictly-older node events only:
// binaries/daemon/src/lib.rs:347
fn event_generation_is_stale(entry_generation: u64, event_generation: u64) -> bool {
event_generation < entry_generation
}
Its justification (doc comment at 339-346 and the call-site comment at 1685-1695) is that "a newer-than-entry generation is always the entry's own racing restart successor," so a newer generation must be let through or the successor's one-shot Subscribe is lost. That invariant holds for the restart mechanism — but dora node replace breaks it, because a replacement is a brand-new incarnation with its own generation that can be lower than a concurrent restart of the outgoing incarnation. When that happens, the outgoing incarnation's restart-zombie carries a higher generation than the replacement, so its Subscribe/SendOut/OutputsDone events pass the gate and are applied to the replacement.
Why the inversion is possible
Both the replacement spawn and the restart loop draw from the same global monotonic counter, and the replacement's generation is minted early (before its slow build), while restart is disabled late:
spawn_node mints the replacement's generation from the global counter before the build:
binaries/daemon/src/spawn/spawner.rs:465 → let generation = crate::running_dataflow::next_node_generation();
- The restart loop mints its successor's generation from the same global counter:
binaries/daemon/src/spawn/prepared.rs:525 → let new_generation = crate::running_dataflow::next_node_generation();
- In the replace handler the outgoing incarnation's restart is only disabled after the replacement is fully spawned:
binaries/daemon/src/lib.rs:2956 → outgoing.disable_restart(); (spawn is at 2937-2941; commit/entry-removal at 2952-2953).
- The daemon event loop is blocked on the inline build+spawn the whole time —
handle_coordinator_event is awaited inline at lib.rs:1656, and the ReplaceNode arm awaits task.await (build, 2937) and prepared.spawn().await (2941) — so the outgoing node's exit, its restart's ProcessHandleReplaced, and the zombie's connection events all queue up behind the commit.
Concrete sequence
Node X has restart_policy: always (or on-failure), current entry generation = 9.
dora node replace X … arrives. Handler calls spawn_node, minting G_replace = 10 (global → 11), then blocks the event loop on the build + process spawn.
- During that window
X's process exits (a clean exit for an always node, or a crash). X's restart loop is a separate task, still enabled (disable_restart not yet reached). It mints new_generation = 11 (> 10), stores it into X's listener's shared generation_counter, and spawns a zombie process on the still-open old listener.
- The zombie connects and its
Subscribe{11} / SendOut{11} / OutputsDone{11} events are stamped generation 11 and queued (event loop still blocked).
- Build finishes; replace commits: old entry (gen 9) removed, replacement inserted at gen 10, handler returns.
- Event loop drains the queue:
- Zombie's
ProcessHandleReplaced{prev=9,new=11} → entry gen is 10 ≠ 9 → RejectedStale → drop-kills the zombie. Good.
- But the zombie's already-queued
Event::Node{generation=11} hit the gate: event_generation_is_stale(10, 11) = (11 < 10) = false → not stale → applied to the replacement's entry.
Impact
Subscribe{11} reinstalls subscribe_channels[X] pointing at the (dying) zombie. Landing after the replacement's own Subscribe{10}, the replacement receives no inputs and silently hangs.
OutputsDone{11}/CloseOutputs{11} runs handle_outputs_done for X, propagating output-closed downstream — consumers stop reading the replacement's outputs.
SendOut{11} injects spurious/duplicate output under X's id.
The exit path is already safe because it uses exact matches_generation (running_dataflow.rs:125), so the zombie's terminal SpawnedNodeResult{11} is correctly dropped. That asymmetry is the tell: terminal events are exact-match (sound against a higher-generation orphan), but control/data Event::Nodes are strictly-older (unsound against one).
Suggested fixes (either closes it)
- Targeted: disable the outgoing incarnation's restart loop at the top of the replace handler, before
spawn_node mints the replacement generation and before the build — re-enabling it only if a pre-commit step fails (to preserve the "outgoing left running on failure" guarantee).
- Robust: stop trusting "any newer generation is my successor." Track the specific successor generation the entry's own restart loop announced (e.g. a
pending_successor_generation on RunningNode, set when the loop advances the counter and cleared when ProcessHandleReplaced lands), and accept an Event::Node only when its generation equals the entry generation or that announced successor — dropping every other newer generation.
Note the inline build+spawn on the event loop (lib.rs:2937-2941) both blocks the whole daemon for the build duration and widens this race window.
Summary
The node-lifecycle generation guard introduced with
dora node replace(#2988, commit79afcc8) drops strictly-older node events only:Its justification (doc comment at 339-346 and the call-site comment at 1685-1695) is that "a newer-than-entry generation is always the entry's own racing restart successor," so a newer generation must be let through or the successor's one-shot
Subscribeis lost. That invariant holds for the restart mechanism — butdora node replacebreaks it, because a replacement is a brand-new incarnation with its own generation that can be lower than a concurrent restart of the outgoing incarnation. When that happens, the outgoing incarnation's restart-zombie carries a higher generation than the replacement, so itsSubscribe/SendOut/OutputsDoneevents pass the gate and are applied to the replacement.Why the inversion is possible
Both the replacement spawn and the restart loop draw from the same global monotonic counter, and the replacement's generation is minted early (before its slow build), while restart is disabled late:
spawn_nodemints the replacement's generation from the global counter before the build:binaries/daemon/src/spawn/spawner.rs:465→let generation = crate::running_dataflow::next_node_generation();binaries/daemon/src/spawn/prepared.rs:525→let new_generation = crate::running_dataflow::next_node_generation();binaries/daemon/src/lib.rs:2956→outgoing.disable_restart();(spawn is at 2937-2941; commit/entry-removal at 2952-2953).handle_coordinator_eventis awaited inline atlib.rs:1656, and theReplaceNodearm awaitstask.await(build, 2937) andprepared.spawn().await(2941) — so the outgoing node's exit, its restart'sProcessHandleReplaced, and the zombie's connection events all queue up behind the commit.Concrete sequence
Node
Xhasrestart_policy: always(oron-failure), current entry generation = 9.dora node replace X …arrives. Handler callsspawn_node, mintingG_replace = 10(global → 11), then blocks the event loop on the build + process spawn.X's process exits (a clean exit for analwaysnode, or a crash).X's restart loop is a separate task, still enabled (disable_restartnot yet reached). It mintsnew_generation = 11(> 10), stores it intoX's listener's sharedgeneration_counter, and spawns a zombie process on the still-open old listener.Subscribe{11}/SendOut{11}/OutputsDone{11}events are stamped generation 11 and queued (event loop still blocked).ProcessHandleReplaced{prev=9,new=11}→ entry gen is 10 ≠ 9 →RejectedStale→ drop-kills the zombie. Good.Event::Node{generation=11}hit the gate:event_generation_is_stale(10, 11) = (11 < 10) = false→ not stale → applied to the replacement's entry.Impact
Subscribe{11}reinstallssubscribe_channels[X]pointing at the (dying) zombie. Landing after the replacement's ownSubscribe{10}, the replacement receives no inputs and silently hangs.OutputsDone{11}/CloseOutputs{11}runshandle_outputs_doneforX, propagating output-closed downstream — consumers stop reading the replacement's outputs.SendOut{11}injects spurious/duplicate output underX's id.The exit path is already safe because it uses exact
matches_generation(running_dataflow.rs:125), so the zombie's terminalSpawnedNodeResult{11}is correctly dropped. That asymmetry is the tell: terminal events are exact-match (sound against a higher-generation orphan), but control/dataEvent::Nodes are strictly-older (unsound against one).Suggested fixes (either closes it)
spawn_nodemints the replacement generation and before the build — re-enabling it only if a pre-commit step fails (to preserve the "outgoing left running on failure" guarantee).pending_successor_generationonRunningNode, set when the loop advances the counter and cleared whenProcessHandleReplacedlands), and accept anEvent::Nodeonly when its generation equals the entry generation or that announced successor — dropping every other newer generation.Note the inline build+spawn on the event loop (
lib.rs:2937-2941) both blocks the whole daemon for the build duration and widens this race window.