Thanks to visit codestin.com
Credit goes to github.com

Skip to content

daemon: restart_loop that breaks after announcing SpawnedNodeResult{restart:true} leaks the node in running_nodes → dataflow never finishes (stop-vs-restart race and respawn-failure both hit it) #2936

Description

@phil-opp

This issue was created by a scheduled automated Claude check (an unattended code-review routine). The finding was verified by reading the full event chain across prepared.rs, lib.rs, and running_dataflow.rs, but has not been reproduced with a running dataflow — treat the trigger scenarios as read-from-code, and please sanity-check before acting.

Summary

RawSpawner::restart_loop emits DoraEvent::SpawnedNodeResult { restart, .. } to the daemon before the backoff sleep and the final disable_restart re-check. On the restart == true branch the daemon treats that event as "a respawn is coming" — it only notifies downstream (NodeRestarted) and deliberately does not remove the node from running_nodes or record a terminal result. It then relies on the restart_loop to follow up with either a ProcessHandleReplaced (successful respawn) or a later SpawnedNodeResult { restart:false } (gave up).

But there are break paths that exit restart_loop after SpawnedNodeResult{restart:true} has already been sent, and none of them send a corrective terminal event. The node is then stuck in running_nodes forever, and because it is non-dynamic, the dataflow can never satisfy the finish condition — dora stop hangs until an external timeout.

The event restart:true is sent, then the loop can break

binaries/daemon/src/spawn/prepared.rs, restart_loop:

// line 351-365: emitted unconditionally, restart may be `true`
let event = DoraEvent::SpawnedNodeResult {
    dataflow_id: self.dataflow_id,
    node_id: self.node.id.clone(),
    exit_status,
    dynamic_node: self.node.kind.dynamic(),
    restart,          // <-- true
    restart_count,
    pid: exited_pid,
}.into();
// ...
let _ = self.daemon_tx.clone().send(event).await;   // daemon now expects a follow-up

if restart {
    if let Some(base_delay) = config.restart_delay {
        // ... line 385:
        tokio::time::sleep(backoff).await;           // .await point — stop can race in here
    }

    // line 399-408: final re-check
    if disable_restart.load(atomic::Ordering::Acquire) {
        logger.log(/* "restart cancelled: inputs closed before respawn" */).await;
        break;                                       // (A) leaves WITHOUT any corrective event
    }

    restart_count += 1;
    // ...
    let result = self.clone().spawn_inner(&mut logger, op_rx_new, finished_tx).await;
    match result {
        Ok(NodeKind::Spawned { pid }) => { /* sends ProcessHandleReplaced */ }
        Ok(NodeKind::Dynamic) => { /* log */ break; }
        Err(err) => {
            logger.log(/* "failed to restart node: {err}" */).await;
            break;                                    // (B) also leaves WITHOUT any corrective event
        }
    }
}

The task is tokio::spawn'd and its JoinHandle is dropped (prepared.rs:206), so when the task ends via break nothing observes it. There is no post-loop code that emits a terminal SpawnedNodeResult.

Why the leak is permanent

Daemon handling in binaries/daemon/src/lib.rs (DoraEvent::SpawnedNodeResult, from :4839):

  • On restart == true (:5057) it only fans out NodeEvent::NodeRestarted to downstream subscribers. It does not call handle_node_stop, so the node stays in running_nodes.
  • Only the restart == false branch (:5146, ending at :5162) calls handle_node_stop(...), which is what removes the node and can trigger finish_dataflow.

handle_node_stop_inner (:4535) gates finishing on every remaining running node being dynamic:

let should_finish = !dataflow.pending_nodes.local_nodes_pending()
    && dataflow.running_nodes.iter().all(|(_id, n)| n.node_config.dynamic);

A leaked non-dynamic node keeps this false for every other node's stop, so finish_dataflow is never called.

stop_all (binaries/daemon/src/running_dataflow.rs:430) sets disable_restart on all nodes (:448-450) and stop_sent = true (:509), then should_finish_immediately (:514) returns WaitForNodes because the leaked node is non-dynamic. And the finish-straggler watchdog cannot rescue it — finish_stragglers (:745) early-returns an empty list whenever stop_sent is true:

pub(crate) fn finish_stragglers(&self, grace: Duration, now_millis: u64) -> Vec<NodeId> {
    if self.stop_sent || !self.open_external_mappings.is_empty() {
        return Vec::new();
    }
    // ...
}

So after a dora stop, nothing removes the leaked node and the dataflow hangs. The only terminal signal to the CLI (DataflowDaemonResult) is sent from finish_dataflow, which never runs.

Concrete triggers

(A) Stop-vs-restart race — needs restart_delay to widen the window:

  1. A node with restart_policy: Always (or OnFailure) and restart_delay set crashes.
  2. restart_loop sends SpawnedNodeResult{restart:true}, downstream gets NodeRestarted, and it enters the backoff sleep (prepared.rs:385).
  3. During that sleep the operator runs dora stopstop_all sets disable_restart on every node.
  4. The loop wakes, the re-check at :399 sees disable_restart == true, logs "restart cancelled: inputs closed before respawn", and breaks at :407.
  5. The node is never removed; dora stop blocks on DataflowDaemonResult until an external timeout.

(B) Respawn failure — does not even need the stop race:

  1. A node with restart_policy: Always/OnFailure crashes.
  2. restart_loop sends SpawnedNodeResult{restart:true}, passes the disable_restart re-check, and calls spawn_inner.
  3. spawn_inner returns Err(..) (e.g. the executable was removed/replaced, fork/exec fails under resource pressure, working dir gone), so the loop logs "failed to restart node" and breaks at :498.
  4. The daemon was told restart:true and never hears back; the node lingers in running_nodes and the dataflow can't finish.

Suggested direction

When restart_loop abandons a restart it has already announced as restart:true, it must deliver a corrective terminal signal so the daemon removes the node and can finish the dataflow — e.g. re-send SpawnedNodeResult { restart:false, .. } (with the same exit_status/pid) on the break paths at prepared.rs:407 and :498, or route those cases through the normal stop path. Note the interaction with the pid-staleness guard added in #2893 (lib.rs:4875): the corrective event should carry the original exited_pid so it isn't discarded as a stale exit.

Notes

Activity

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

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions