Skip to main content

ant_core/node/daemon/
supervisor.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use tokio::sync::{broadcast, RwLock};
7use tokio::time::MissedTickBehavior;
8use tokio_util::sync::CancellationToken;
9
10use crate::error::{Error, Result};
11use crate::node::binary::extract_version;
12use crate::node::daemon::disk;
13use crate::node::daemon::health::{DiskThresholds, FleetHealth};
14use crate::node::events::NodeEvent;
15use crate::node::process::spawn::spawn_node;
16use crate::node::registry::NodeRegistry;
17use crate::node::types::{
18    EvictionRecord, NodeConfig, NodeStarted, NodeStatus, NodeStopFailed, NodeStopped,
19    StopNodeResult,
20};
21
22/// Exit code ant-node uses to ask its service manager (this daemon) to restart it after replacing
23/// its own binary in place during an auto-upgrade. On Unix ant-node exits `0`; on Windows it exits
24/// with this code. Mirrors `RESTART_EXIT_CODE` in ant-node's `upgrade::apply`. Kept as a local const
25/// so this crate need not depend on that symbol.
26const RESTART_EXIT_CODE: i32 = 100;
27
28/// How often the low-disk monitor checks free space at node data directories and evicts a node if a
29/// partition has fallen to its eviction threshold.
30pub const EVICTION_POLL_INTERVAL: Duration = Duration::from_secs(30);
31
32/// Safety bound on how many nodes the monitor will evict within a single check, so a misconfigured
33/// threshold or a measurement glitch can never wipe a whole fleet in one tick.
34const MAX_EVICTIONS_PER_CYCLE: usize = 4;
35
36/// How often the liveness poll verifies that each Running node's OS process still exists.
37///
38/// Nodes the current daemon spawned are watched via their owned `Child` handle in
39/// `monitor_node`, so this poll exists purely to catch exits of nodes adopted across
40/// a daemon restart (whose `Child` handle died with the previous daemon). Five seconds
41/// is a rough trade-off: long enough that the syscall cost is negligible, short enough
42/// that a crashed adopted node still looks broken to the user within a few heartbeats.
43pub const LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5);
44
45/// Path of the pid file a running node writes to so a future daemon instance can
46/// adopt it across restarts. Lives alongside the node's other on-disk state.
47fn node_pid_file(data_dir: &Path) -> PathBuf {
48    data_dir.join("node.pid")
49}
50
51/// Persist the running node's PID to `<data_dir>/node.pid`. Best-effort: a failure
52/// here only costs us the ability to adopt the node after a daemon restart, so we
53/// warn and continue rather than aborting the start.
54fn write_node_pid(data_dir: &Path, pid: u32) {
55    let path = node_pid_file(data_dir);
56    if let Err(e) = std::fs::write(&path, pid.to_string()) {
57        tracing::warn!(
58            "Failed to write node pid file at {}: {e}. Node will still run, but a future \
59             daemon restart will not be able to adopt it.",
60            path.display()
61        );
62    }
63}
64
65/// Remove the pid file. Called on every terminal-exit path in `monitor_node` so the
66/// next daemon doesn't try to adopt a PID belonging to a process that's gone.
67fn remove_node_pid(data_dir: &Path) {
68    let _ = std::fs::remove_file(node_pid_file(data_dir));
69}
70
71/// Read the pid file without validating liveness. Returns `None` if the file is
72/// missing or its contents can't be parsed as a u32.
73fn read_node_pid(data_dir: &Path) -> Option<u32> {
74    std::fs::read_to_string(node_pid_file(data_dir))
75        .ok()
76        .and_then(|s| s.trim().parse().ok())
77}
78
79/// Scan the OS process table for a running node that matches `config`, as a
80/// fallback for when `<data_dir>/node.pid` is missing or stale.
81///
82/// Nodes spawned by a pre-adoption daemon never had a pid file written, so
83/// without this scan the first restart after installing the adoption fix
84/// would leave every previously-running node classified as Stopped. The scan
85/// matches on:
86///
87/// - executable path identical to `config.binary_path`, AND
88/// - command line containing `--root-dir` (as a standalone arg or
89///   `--root-dir=<path>`) whose value resolves to `config.data_dir`.
90///
91/// The double match keeps us safe when multiple nodes share the same binary
92/// on disk (common on installs where one copy services several data dirs).
93///
94/// Returns `None` if no running process matches.
95fn find_running_node_process(sys: &sysinfo::System, config: &NodeConfig) -> Option<u32> {
96    let target_data_dir = config.data_dir.as_path();
97    for (pid, process) in sys.processes() {
98        // On Linux, `sys.processes()` enumerates /proc/<pid>/task/<tid> too, so
99        // worker threads appear alongside their thread-group leader and share
100        // the same exe + cmdline. Skip threads — we want the TGID (the real
101        // process), which is the only PID safe to signal.
102        if process.thread_kind().is_some() {
103            continue;
104        }
105        let Some(exe) = process.exe() else {
106            continue;
107        };
108        if exe != config.binary_path.as_path() {
109            continue;
110        }
111
112        let cmd = process.cmd();
113        let matches_root_dir = cmd.iter().enumerate().any(|(i, arg)| {
114            let arg = arg.to_string_lossy();
115            if let Some(value) = arg.strip_prefix("--root-dir=") {
116                Path::new(value) == target_data_dir
117            } else if arg == "--root-dir" {
118                cmd.get(i + 1)
119                    .map(|v| Path::new(&*v.to_string_lossy()) == target_data_dir)
120                    .unwrap_or(false)
121            } else {
122                false
123            }
124        });
125
126        if matches_root_dir {
127            return Some(pid.as_u32());
128        }
129    }
130    None
131}
132
133/// Check whether `pid` refers to a live, non-thread process. On Linux,
134/// `kill(tid, 0)` returns success for any thread's TID, not just the
135/// thread-group leader — so liveness alone is not enough to trust a PID
136/// loaded from the pid file. Consulting sysinfo's `thread_kind()` tells us
137/// whether the entry is a userland thread (TID) vs. the actual process
138/// (TGID). A missing sysinfo entry with a live PID is still treated as a
139/// process, since older daemons could have written the PID before sysinfo
140/// saw it.
141fn pid_is_live_process(pid: u32, sys: &sysinfo::System) -> bool {
142    if !is_process_alive(pid) {
143        return false;
144    }
145    match sys.process(sysinfo::Pid::from_u32(pid)) {
146        Some(process) => process.thread_kind().is_none(),
147        None => true,
148    }
149}
150
151/// Determine the PID to adopt for a node, trying the pid file first and
152/// falling back to a process-table scan. On successful scan, writes the pid
153/// file so the next adoption takes the fast path.
154///
155/// Returns `None` if no live process can be attributed to this node.
156fn resolve_adopted_pid(config: &NodeConfig, sys: &sysinfo::System) -> Option<u32> {
157    if let Some(pid) = read_node_pid(&config.data_dir) {
158        if pid_is_live_process(pid, sys) {
159            return Some(pid);
160        }
161        // Pid file points at a dead process or a thread TID (legacy daemons
162        // could record a TID because the fallback scan saw threads). Don't
163        // leave it around to mislead the next adoption pass.
164        remove_node_pid(&config.data_dir);
165    }
166
167    let pid = find_running_node_process(sys, config)?;
168    write_node_pid(&config.data_dir, pid);
169    Some(pid)
170}
171
172/// Build an `Instant` that reports the real process start time when
173/// `.elapsed()` is called on it — so uptime survives daemon restarts
174/// accurately for adopted nodes.
175///
176/// `sysinfo::Process::start_time()` returns seconds since the UNIX epoch
177/// (wall clock). `Instant` is monotonic and can't be constructed from a
178/// wall-clock value directly, so we back-date `Instant::now()` by the
179/// process's age. Returns `None` if the PID isn't in the snapshot (the
180/// process exited between scan and this call), if the system clock looks
181/// broken, or if subtraction would overflow (unrealistically-old process
182/// start times).
183fn process_started_at(sys: &sysinfo::System, pid: u32) -> Option<Instant> {
184    let start_secs = sys.process(sysinfo::Pid::from_u32(pid))?.start_time();
185    let now_secs = std::time::SystemTime::now()
186        .duration_since(std::time::UNIX_EPOCH)
187        .ok()?
188        .as_secs();
189    let age = now_secs.saturating_sub(start_secs);
190    Instant::now().checked_sub(Duration::from_secs(age))
191}
192
193/// Maximum restart attempts before marking a node as errored.
194const MAX_CRASHES_BEFORE_ERRORED: u32 = 5;
195
196/// Window in which crashes are counted. If this many crashes happen within
197/// this duration, the node is marked errored.
198const CRASH_WINDOW: Duration = Duration::from_secs(300); // 5 minutes
199
200/// If a node runs for this long without crashing, reset the crash counter.
201const STABLE_DURATION: Duration = Duration::from_secs(300); // 5 minutes
202
203/// Maximum backoff delay between restarts.
204const MAX_BACKOFF: Duration = Duration::from_secs(60);
205
206/// Manages running node processes. Holds child process handles and runtime state.
207pub struct Supervisor {
208    event_tx: broadcast::Sender<NodeEvent>,
209    /// Runtime status of each node, keyed by node ID.
210    node_states: HashMap<u32, NodeRuntime>,
211    /// Nodes adopted from a previous daemon instance, which have no owning `monitor_node`
212    /// task (their `Child` handle died with the previous daemon). Exit detection and, on
213    /// auto-upgrade, respawn for these nodes happen in the liveness monitor instead. A node
214    /// leaves this set once this daemon (re)spawns it and owns a `monitor_node` for it.
215    adopted: HashSet<u32>,
216    /// Nodes currently being evicted (stop + data-dir delete in progress). Checked and set under
217    /// the supervisor write lock, so it atomically excludes a concurrent `start_node` for the whole
218    /// stop/delete window — before which the persisted eviction marker is not yet visible.
219    evicting: HashSet<u32>,
220}
221
222struct NodeRuntime {
223    status: NodeStatus,
224    pid: Option<u32>,
225    started_at: Option<Instant>,
226    restart_count: u32,
227    first_crash_at: Option<Instant>,
228}
229
230impl Supervisor {
231    pub fn new(event_tx: broadcast::Sender<NodeEvent>) -> Self {
232        Self {
233            event_tx,
234            node_states: HashMap::new(),
235            adopted: HashSet::new(),
236            evicting: HashSet::new(),
237        }
238    }
239
240    /// Whether `node_id` was adopted from a previous daemon instance and is therefore not
241    /// backed by an owning `monitor_node` task in this daemon.
242    pub fn is_adopted(&self, node_id: u32) -> bool {
243        self.adopted.contains(&node_id)
244    }
245
246    /// Mark a node as under eviction so `start_node` refuses it for the whole stop/delete window.
247    /// Both this and the `start_node` check run under the supervisor write lock, so a concurrent
248    /// start can never slip in and spawn the node while its data directory is being deleted.
249    fn begin_evicting(&mut self, node_id: u32) {
250        self.evicting.insert(node_id);
251    }
252
253    /// Clear the eviction-in-progress flag (the persisted `eviction` marker keeps the node
254    /// unstartable afterwards).
255    fn finish_evicting(&mut self, node_id: u32) {
256        self.evicting.remove(&node_id);
257    }
258
259    /// Mark a node as owned by this daemon (i.e. it now has a `monitor_node` task). Clears
260    /// any adopted flag so the liveness monitor leaves its exit handling to `monitor_node`.
261    fn mark_owned(&mut self, node_id: u32) {
262        self.adopted.remove(&node_id);
263    }
264
265    /// Start a node by spawning the actual process.
266    ///
267    /// Returns `NodeStarted` on success. Spawns a background monitoring task
268    /// that watches the child process and handles restart logic.
269    pub async fn start_node(
270        &mut self,
271        config: &NodeConfig,
272        supervisor_ref: Arc<RwLock<Supervisor>>,
273        registry_ref: Arc<RwLock<NodeRegistry>>,
274    ) -> Result<NodeStarted> {
275        let node_id = config.id;
276
277        // An evicted node's data directory has been deleted; it must not be restarted. Recovery is
278        // to dismiss it (remove from the registry) and add a fresh node. The persisted marker covers
279        // the settled case; the in-progress `evicting` set (set under this same lock) covers the
280        // window where the delete is still running and the marker's config may not be visible yet.
281        if config.eviction.is_some() || self.evicting.contains(&node_id) {
282            return Err(Error::NodeEvicted(node_id));
283        }
284
285        if let Some(state) = self.node_states.get(&node_id) {
286            if state.status == NodeStatus::Running {
287                return Err(Error::NodeAlreadyRunning(node_id));
288            }
289        }
290
291        let _ = self.event_tx.send(NodeEvent::NodeStarting { node_id });
292
293        let mut child = spawn_node_from_config(config).await?;
294        let pid = child
295            .id()
296            .ok_or_else(|| Error::ProcessSpawn("Failed to get PID from spawned process".into()))?;
297
298        // Brief health check: give the process a moment to start, then check if it
299        // exited immediately. This catches errors like invalid CLI arguments or missing
300        // shared libraries. We use timeout + wait() rather than try_wait() because
301        // tokio's child reaper requires the wait future to be polled.
302        match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
303            Ok(Ok(exit_status)) => {
304                // Process already exited — read stderr for details.
305                // spawn_node always redirects stderr to a file in the log dir
306                // (falling back to data_dir when no log dir is configured).
307                let spawn_log_dir = config.log_dir.as_deref().unwrap_or(&config.data_dir);
308                let stderr_path = spawn_log_dir.join("stderr.log");
309                let stderr_msg = std::fs::read_to_string(&stderr_path).unwrap_or_default();
310                let detail = if stderr_msg.trim().is_empty() {
311                    format!("exit code: {exit_status}")
312                } else {
313                    stderr_msg.trim().to_string()
314                };
315                self.node_states.insert(
316                    node_id,
317                    NodeRuntime {
318                        status: NodeStatus::Errored,
319                        pid: None,
320                        started_at: None,
321                        restart_count: 0,
322                        first_crash_at: None,
323                    },
324                );
325                return Err(Error::ProcessSpawn(format!(
326                    "Node {node_id} exited immediately: {detail}"
327                )));
328            }
329            Ok(Err(e)) => {
330                return Err(Error::ProcessSpawn(format!(
331                    "Failed to check node process status: {e}"
332                )));
333            }
334            Err(_) => {} // Timeout — process is still running after 1s, good
335        }
336
337        self.node_states.insert(
338            node_id,
339            NodeRuntime {
340                status: NodeStatus::Running,
341                pid: Some(pid),
342                started_at: Some(Instant::now()),
343                restart_count: 0,
344                first_crash_at: None,
345            },
346        );
347        // This daemon now owns the process and spawns a `monitor_node` for it below, so it is
348        // no longer (or never was) an adopted node the liveness monitor must respawn.
349        self.mark_owned(node_id);
350
351        let _ = self.event_tx.send(NodeEvent::NodeStarted { node_id, pid });
352
353        let result = NodeStarted {
354            node_id,
355            service_name: config.service_name.clone(),
356            pid,
357        };
358
359        // Spawn monitoring task
360        let event_tx = self.event_tx.clone();
361        let config = config.clone();
362        tokio::spawn(async move {
363            monitor_node(child, config, supervisor_ref, registry_ref, event_tx).await;
364        });
365
366        Ok(result)
367    }
368
369    /// Stop a node by gracefully terminating its process.
370    ///
371    /// Sends SIGTERM (Unix) or kills (Windows), waits up to 10 seconds for exit,
372    /// then sends SIGKILL if needed. The monitor task detects the Stopping status
373    /// and exits cleanly without attempting a restart.
374    pub async fn stop_node(&mut self, node_id: u32) -> Result<()> {
375        let state = self
376            .node_states
377            .get_mut(&node_id)
378            .ok_or(Error::NodeNotFound(node_id))?;
379
380        if state.status != NodeStatus::Running {
381            return Err(Error::NodeNotRunning(node_id));
382        }
383
384        let pid = state.pid;
385
386        let _ = self.event_tx.send(NodeEvent::NodeStopping { node_id });
387        state.status = NodeStatus::Stopping;
388
389        if let Some(pid) = pid {
390            graceful_kill(pid).await;
391        }
392
393        // Update state after kill
394        let state = self.node_states.get_mut(&node_id).unwrap();
395        state.status = NodeStatus::Stopped;
396        state.pid = None;
397        state.started_at = None;
398
399        let _ = self.event_tx.send(NodeEvent::NodeStopped { node_id });
400
401        Ok(())
402    }
403
404    /// Stop all running nodes, returning an aggregate result.
405    pub async fn stop_all_nodes(&mut self, configs: &[(u32, String)]) -> StopNodeResult {
406        let mut stopped = Vec::new();
407        let mut failed = Vec::new();
408        let mut already_stopped = Vec::new();
409
410        for (node_id, service_name) in configs {
411            let node_id = *node_id;
412            match self.node_status(node_id) {
413                Ok(NodeStatus::Running) => {}
414                Ok(_) => {
415                    already_stopped.push(node_id);
416                    continue;
417                }
418                Err(_) => {
419                    already_stopped.push(node_id);
420                    continue;
421                }
422            }
423
424            match self.stop_node(node_id).await {
425                Ok(()) => {
426                    stopped.push(NodeStopped {
427                        node_id,
428                        service_name: service_name.clone(),
429                    });
430                }
431                Err(Error::NodeNotRunning(_)) => {
432                    already_stopped.push(node_id);
433                }
434                Err(e) => {
435                    failed.push(NodeStopFailed {
436                        node_id,
437                        service_name: service_name.clone(),
438                        error: e.to_string(),
439                    });
440                }
441            }
442        }
443
444        StopNodeResult {
445            stopped,
446            failed,
447            already_stopped,
448        }
449    }
450
451    /// Get the status of a node.
452    pub fn node_status(&self, node_id: u32) -> Result<NodeStatus> {
453        self.node_states
454            .get(&node_id)
455            .map(|s| s.status)
456            .ok_or(Error::NodeNotFound(node_id))
457    }
458
459    /// Get the PID of a running node.
460    pub fn node_pid(&self, node_id: u32) -> Option<u32> {
461        self.node_states.get(&node_id).and_then(|s| s.pid)
462    }
463
464    /// Get the uptime of a running node in seconds.
465    pub fn node_uptime_secs(&self, node_id: u32) -> Option<u64> {
466        self.node_states
467            .get(&node_id)
468            .and_then(|s| s.started_at.map(|t| t.elapsed().as_secs()))
469    }
470
471    /// Check whether a node is running.
472    pub fn is_running(&self, node_id: u32) -> bool {
473        self.node_states
474            .get(&node_id)
475            .is_some_and(|s| s.status == NodeStatus::Running)
476    }
477
478    /// Get counts of nodes in each state: (running, stopped, errored).
479    pub fn node_counts(&self) -> (u32, u32, u32) {
480        let mut running = 0u32;
481        let mut stopped = 0u32;
482        let mut errored = 0u32;
483        for state in self.node_states.values() {
484            match state.status {
485                NodeStatus::Running | NodeStatus::Starting => running += 1,
486                // An evicted node is not running; count it alongside stopped for these totals.
487                NodeStatus::Stopped | NodeStatus::Stopping | NodeStatus::Evicted => stopped += 1,
488                NodeStatus::Errored => errored += 1,
489            }
490        }
491        (running, stopped, errored)
492    }
493
494    /// Update the runtime state for a node (used by the monitor task).
495    fn update_state(&mut self, node_id: u32, status: NodeStatus, pid: Option<u32>) {
496        if let Some(state) = self.node_states.get_mut(&node_id) {
497            state.status = status;
498            state.pid = pid;
499            if status == NodeStatus::Running {
500                state.started_at = Some(Instant::now());
501            } else {
502                // Clear uptime tracking for non-running states so status
503                // responses don't report a stale `uptime_secs` after the node
504                // exits (e.g. liveness monitor detecting an external kill).
505                state.started_at = None;
506            }
507        }
508    }
509
510    /// Restore running-node state from a previous daemon instance.
511    ///
512    /// For each registered node, determines the PID to adopt via
513    /// `resolve_adopted_pid`: try `<data_dir>/node.pid` first, and if it's
514    /// missing or stale, fall back to a process-table scan matching the
515    /// node's binary path and `--root-dir` argument. Live matches are
516    /// inserted into `node_states` as `Running`.
517    ///
518    /// The scan is what covers the upgrade path: nodes spawned by a
519    /// pre-adoption daemon never had a pid file written, so without the
520    /// fallback the first restart after installing this fix would still
521    /// leave every previously-running node classified as Stopped.
522    ///
523    /// Must be called before the HTTP server starts accepting requests —
524    /// the window between `Supervisor::new` and adoption is where the API
525    /// would otherwise report live nodes as Stopped. Adopted nodes have no
526    /// associated `monitor_node` task (the `tokio::process::Child` handle
527    /// belonged to the previous daemon, and `tokio::process::Child::wait`
528    /// only works for the process's actual parent). Their exits are
529    /// detected instead by the `spawn_liveness_monitor` polling task.
530    ///
531    /// Returns the list of node IDs that were adopted.
532    pub fn adopt_from_registry(&mut self, registry: &NodeRegistry) -> Vec<u32> {
533        // Populated upfront so every adopted node gets its real start time via
534        // `process_started_at`, not just those that went through the scan
535        // fallback. The extra ~50 ms at daemon startup is a one-time cost
536        // that's cheaper than users seeing uptime reset every time the daemon
537        // restarts.
538        let mut sys = sysinfo::System::new();
539        sys.refresh_processes_specifics(
540            sysinfo::ProcessesToUpdate::All,
541            true,
542            sysinfo::ProcessRefreshKind::everything(),
543        );
544
545        let mut adopted = Vec::new();
546        for config in registry.list() {
547            let Some(pid) = resolve_adopted_pid(config, &sys) else {
548                continue;
549            };
550            self.node_states.insert(
551                config.id,
552                NodeRuntime {
553                    status: NodeStatus::Running,
554                    pid: Some(pid),
555                    // Back-date to the real process start time so uptime
556                    // reported to the API is wall-clock accurate across
557                    // daemon restarts. Falls back to `Instant::now()` only
558                    // if sysinfo can't report the start time (PID raced out
559                    // of the snapshot, or a broken clock) — better to show
560                    // uptime counting from adoption than to claim the node
561                    // is Stopped.
562                    started_at: Some(process_started_at(&sys, pid).unwrap_or_else(Instant::now)),
563                    restart_count: 0,
564                    first_crash_at: None,
565                },
566            );
567            // No owning `monitor_node` exists for an adopted process (its `Child` died with the
568            // previous daemon), so flag it for the liveness monitor to handle its exit/respawn.
569            self.adopted.insert(config.id);
570            let _ = self.event_tx.send(NodeEvent::NodeStarted {
571                node_id: config.id,
572                pid,
573            });
574            adopted.push(config.id);
575        }
576        adopted
577    }
578
579    /// Record a crash and determine if the node should be restarted or marked errored.
580    /// Returns (should_restart, attempt_number, backoff_duration).
581    fn record_crash(&mut self, node_id: u32) -> (bool, u32, Duration) {
582        let state = match self.node_states.get_mut(&node_id) {
583            Some(s) => s,
584            None => return (false, 0, Duration::ZERO),
585        };
586
587        let now = Instant::now();
588
589        // Check if we were stable long enough to reset crash counter
590        if let Some(started_at) = state.started_at {
591            if started_at.elapsed() >= STABLE_DURATION {
592                state.restart_count = 0;
593                state.first_crash_at = None;
594            }
595        }
596
597        state.restart_count += 1;
598        let attempt = state.restart_count;
599
600        if state.first_crash_at.is_none() {
601            state.first_crash_at = Some(now);
602        }
603
604        // Check if too many crashes in the window
605        if let Some(first_crash) = state.first_crash_at {
606            if attempt >= MAX_CRASHES_BEFORE_ERRORED
607                && now.duration_since(first_crash) < CRASH_WINDOW
608            {
609                state.status = NodeStatus::Errored;
610                state.pid = None;
611                state.started_at = None;
612                return (false, attempt, Duration::ZERO);
613            }
614        }
615
616        // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s cap
617        let backoff_secs = 1u64 << (attempt - 1).min(5);
618        let backoff = Duration::from_secs(backoff_secs).min(MAX_BACKOFF);
619
620        (true, attempt, backoff)
621    }
622}
623
624/// Background task: monitor free disk space at each node's data directory and, when a partition
625/// falls to its eviction threshold, automatically evict a node to reclaim space.
626///
627/// Each tick it (1) measures every running node's data directory grouped by partition, (2) refreshes
628/// the shared [`FleetHealth`] snapshot so the CLI/GUI can show how close the fleet is to an
629/// eviction, and (3) evicts the selected candidate on any partition that is at/below the threshold
630/// *and* still has at least two nodes (so a node remains to benefit). Eviction stops the process,
631/// deletes the data directory, records a persisted [`EvictionRecord`], and emits an event. It
632/// re-measures and may evict again — bounded by `MAX_EVICTIONS_PER_CYCLE` — because a single
633/// eviction may not free enough on a heavily over-provisioned partition.
634///
635/// The task exits when `shutdown` is cancelled.
636pub fn spawn_eviction_monitor(
637    registry: Arc<RwLock<NodeRegistry>>,
638    supervisor: Arc<RwLock<Supervisor>>,
639    event_tx: broadcast::Sender<NodeEvent>,
640    health: Arc<RwLock<FleetHealth>>,
641    thresholds: DiskThresholds,
642    interval: Duration,
643    shutdown: CancellationToken,
644) {
645    tokio::spawn(async move {
646        let mut ticker = tokio::time::interval(interval);
647        ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
648        // Skip the immediate first tick so we don't evict while nodes are still starting up.
649        ticker.tick().await;
650
651        loop {
652            tokio::select! {
653                _ = shutdown.cancelled() => return,
654                _ = ticker.tick() => {},
655            }
656
657            run_eviction_cycle(&registry, &supervisor, &event_tx, &health, &thresholds).await;
658        }
659    });
660}
661
662/// Run one disk-pressure check: evict as needed (bounded), then refresh the health snapshot.
663async fn run_eviction_cycle(
664    registry: &Arc<RwLock<NodeRegistry>>,
665    supervisor: &Arc<RwLock<Supervisor>>,
666    event_tx: &broadcast::Sender<NodeEvent>,
667    health: &Arc<RwLock<FleetHealth>>,
668    thresholds: &DiskThresholds,
669) {
670    for _ in 0..MAX_EVICTIONS_PER_CYCLE {
671        let partitions = disk::partition_states(running_nodes(registry, supervisor).await);
672
673        // A partition needs an eviction when it is at/below the threshold and has a spare node to
674        // sacrifice (≥2 nodes, so one remains). The sole-node case is deliberately left for the
675        // health layer to surface as Critical rather than auto-evicting the only node.
676        let target = partitions
677            .iter()
678            .find(|p| p.available_bytes <= thresholds.eviction_bytes && p.nodes.len() >= 2);
679
680        let Some(partition) = target else {
681            // Nothing more to evict: publish the current health and finish this cycle.
682            publish_health(
683                health,
684                event_tx,
685                FleetHealth::from_partitions(&partitions, thresholds),
686            )
687            .await;
688            return;
689        };
690
691        let Some(candidate) = partition.eviction_candidate().cloned() else {
692            break;
693        };
694
695        evict_node(
696            registry,
697            supervisor,
698            event_tx,
699            &candidate,
700            partition.available_bytes,
701        )
702        .await;
703    }
704
705    // Reached the per-cycle eviction cap (or hit a candidate-less partition): refresh health so the
706    // snapshot reflects reality before the next tick.
707    let partitions = disk::partition_states(running_nodes(registry, supervisor).await);
708    publish_health(
709        health,
710        event_tx,
711        FleetHealth::from_partitions(&partitions, thresholds),
712    )
713    .await;
714}
715
716/// Snapshot of currently-running, non-evicted nodes as `(id, data_dir)` pairs.
717async fn running_nodes(
718    registry: &Arc<RwLock<NodeRegistry>>,
719    supervisor: &Arc<RwLock<Supervisor>>,
720) -> Vec<(u32, PathBuf)> {
721    let reg = registry.read().await;
722    let sup = supervisor.read().await;
723    reg.list()
724        .into_iter()
725        .filter(|config| config.eviction.is_none())
726        .filter(|config| matches!(sup.node_status(config.id), Ok(NodeStatus::Running)))
727        .map(|config| (config.id, config.data_dir.clone()))
728        .collect()
729}
730
731/// Delete a directory tree, retrying briefly to tolerate transient locks.
732///
733/// A node we just killed can hold its data files open for a short moment after exit — on Windows
734/// especially (its LMDB memory map and its own copied `ant-node` binary), and antivirus/indexers can
735/// grab transient handles — so `remove_dir_all` fails with "access denied / in use" until the OS
736/// releases them. A bounded exponential backoff gives it time; on Unix the first attempt almost
737/// always succeeds. Returns `Ok` on success or if the directory is already gone.
738async fn remove_dir_all_with_retry(path: &Path) -> std::io::Result<()> {
739    const MAX_ATTEMPTS: u32 = 8;
740    let mut delay = Duration::from_millis(100);
741    for attempt in 1..=MAX_ATTEMPTS {
742        match std::fs::remove_dir_all(path) {
743            Ok(()) => return Ok(()),
744            // Already gone — nothing left to reclaim; treat as success.
745            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
746            Err(e) if attempt == MAX_ATTEMPTS => return Err(e),
747            Err(_) => {
748                tokio::time::sleep(delay).await;
749                delay = (delay * 2).min(Duration::from_secs(1));
750            }
751        }
752    }
753    Ok(())
754}
755
756/// Write (or overwrite) a node's persisted eviction marker and save the registry.
757async fn persist_eviction_marker(
758    registry: &Arc<RwLock<NodeRegistry>>,
759    node_id: u32,
760    reason: &str,
761    evicted_at: u64,
762    reclaimed_bytes: u64,
763) {
764    let mut reg = registry.write().await;
765    if let Ok(config) = reg.get_mut(node_id) {
766        config.eviction = Some(EvictionRecord {
767            reason: reason.to_string(),
768            evicted_at,
769            reclaimed_bytes,
770        });
771    }
772    if let Err(e) = reg.save() {
773        tracing::error!("Eviction: failed to persist registry for node {node_id}: {e}");
774    }
775}
776
777/// Evict a single node: make it unstartable, stop it, delete its data directory, finalise the
778/// persisted marker, and emit an event.
779///
780/// Ordering matters for safety. The node is made unstartable *before* the stop/delete window — via
781/// the in-memory `evicting` flag (set under the supervisor lock, so it atomically excludes a
782/// concurrent `start_node`) and a persisted eviction marker (which also survives a daemon crash
783/// mid-delete). Only then do we stop and delete. Rollback is deliberately not attempted: once the
784/// process is stopped the node stays `Evicted` (terminal) whether or not the delete succeeds.
785async fn evict_node(
786    registry: &Arc<RwLock<NodeRegistry>>,
787    supervisor: &Arc<RwLock<Supervisor>>,
788    event_tx: &broadcast::Sender<NodeEvent>,
789    candidate: &disk::NodeDiskUsage,
790    available_before: u64,
791) {
792    let node_id = candidate.node_id;
793    let reclaimable = candidate.size_bytes;
794    let evicted_at = now_unix_secs();
795
796    // 1. Make the node unstartable up front (before we touch the process or its files).
797    supervisor.write().await.begin_evicting(node_id);
798    let pending_reason = format!(
799        "Automatically evicted to reclaim disk space: only {} free on its partition. \
800         Deleting its data directory to recover ~{}.",
801        fmt_bytes(available_before),
802        fmt_bytes(reclaimable),
803    );
804    persist_eviction_marker(registry, node_id, &pending_reason, evicted_at, reclaimable).await;
805
806    // 2. Stop the process (still marked Running, so this actually kills it). The monitor_node task
807    //    sees the Stopping/Stopped transition and will not respawn it.
808    if let Err(e) = supervisor.write().await.stop_node(node_id).await {
809        tracing::warn!("Eviction: failed to stop node {node_id} before deletion: {e}");
810    }
811
812    // 3. Delete the data directory — this reclaims the disk space. A just-killed node can briefly
813    //    hold its files open (LMDB memory map, its own copied binary); on Windows `remove_dir_all`
814    //    then fails until the OS releases the handles, so retry with backoff.
815    let deleted = match remove_dir_all_with_retry(&candidate.data_dir).await {
816        Ok(()) => true,
817        Err(e) => {
818            tracing::error!(
819                "Eviction: could not delete data dir {} for node {node_id} after retries: {e}. \
820                 Disk space was NOT reclaimed; manual cleanup may be required.",
821                candidate.data_dir.display()
822            );
823            false
824        }
825    };
826
827    // 4. Finalise the marker to reflect what actually happened, flip runtime state to Evicted, and
828    //    clear the in-progress flag (the persisted marker keeps the node unstartable from here).
829    let (reclaimed_bytes, reason) = if deleted {
830        (
831            reclaimable,
832            format!(
833                "Automatically evicted to reclaim disk space: only {} free on its partition. \
834                 Its data directory was deleted, recovering ~{}.",
835                fmt_bytes(available_before),
836                fmt_bytes(reclaimable),
837            ),
838        )
839    } else {
840        (
841            0,
842            format!(
843                "Automatically evicted due to low disk space (only {} free on its partition), but \
844                 its data directory could not be deleted, so space was not reclaimed. Manual \
845                 cleanup of {} may be needed.",
846                fmt_bytes(available_before),
847                candidate.data_dir.display(),
848            ),
849        )
850    };
851    persist_eviction_marker(registry, node_id, &reason, evicted_at, reclaimed_bytes).await;
852    {
853        let mut sup = supervisor.write().await;
854        sup.update_state(node_id, NodeStatus::Evicted, None);
855        sup.finish_evicting(node_id);
856    }
857
858    tracing::info!(
859        "Evicted node {node_id}, reclaimed ~{} ({reason})",
860        fmt_bytes(reclaimed_bytes)
861    );
862    let _ = event_tx.send(NodeEvent::NodeEvicted {
863        node_id,
864        reason,
865        reclaimed_bytes,
866    });
867}
868
869/// Store the new health snapshot, emitting a `FleetHealthChanged` event if the overall level moved.
870async fn publish_health(
871    health: &Arc<RwLock<FleetHealth>>,
872    event_tx: &broadcast::Sender<NodeEvent>,
873    next: FleetHealth,
874) {
875    let changed = {
876        let mut current = health.write().await;
877        let changed = current.overall != next.overall;
878        *current = next.clone();
879        changed
880    };
881    if changed {
882        let _ = event_tx.send(NodeEvent::FleetHealthChanged {
883            overall: serde_json::to_value(next.overall)
884                .ok()
885                .and_then(|v| v.as_str().map(str::to_owned))
886                .unwrap_or_default(),
887        });
888    }
889}
890
891/// Current Unix time in whole seconds. Falls back to 0 if the clock is before the epoch.
892fn now_unix_secs() -> u64 {
893    std::time::SystemTime::now()
894        .duration_since(std::time::UNIX_EPOCH)
895        .map(|d| d.as_secs())
896        .unwrap_or(0)
897}
898
899/// Format a byte count as a human-friendly string (GiB/MiB), matching the health layer's style.
900fn fmt_bytes(bytes: u64) -> String {
901    const MIB: f64 = 1024.0 * 1024.0;
902    const GIB: f64 = 1024.0 * MIB;
903    let b = bytes as f64;
904    if b >= GIB {
905        format!("{:.2} GiB", b / GIB)
906    } else {
907        format!("{:.0} MiB", b / MIB)
908    }
909}
910
911/// Build CLI arguments for the node binary from a NodeConfig.
912pub fn build_node_args(config: &NodeConfig) -> Vec<String> {
913    let mut args = vec![
914        "--rewards-address".to_string(),
915        config.rewards_address.clone(),
916        "--root-dir".to_string(),
917        config.data_dir.display().to_string(),
918    ];
919
920    if let Some(ref log_dir) = config.log_dir {
921        args.push("--enable-logging".to_string());
922        args.push("--log-dir".to_string());
923        args.push(log_dir.display().to_string());
924    }
925
926    if let Some(port) = config.node_port {
927        args.push("--port".to_string());
928        args.push(port.to_string());
929    }
930
931    for peer in &config.bootstrap_peers {
932        args.push("--bootstrap".to_string());
933        args.push(peer.clone());
934    }
935
936    if let Some(channel) = config.upgrade_channel {
937        args.push("--upgrade-channel".to_string());
938        args.push(channel.to_string());
939    }
940
941    // The daemon's supervisor is the service manager. Tell ant-node not to spawn its own
942    // replacement on auto-upgrade; instead, exit cleanly and let us respawn. Without this,
943    // ant-node's default spawn-grandchild-then-exit flow races for the node's port during
944    // the parent's graceful shutdown and the grandchild fails to bind.
945    args.push("--stop-on-upgrade".to_string());
946
947    // Always emit the EVM network so the node's payment network is explicit rather than relying on
948    // the binary's built-in default.
949    args.push("--evm-network".to_string());
950    args.push(config.evm_network.as_arg().to_string());
951
952    args
953}
954
955/// Whether `exit_code` is one ant-node uses to hand its restart to this daemon after replacing its
956/// own binary during an auto-upgrade: `0` on Unix, [`RESTART_EXIT_CODE`] on Windows (both under
957/// `--stop-on-upgrade`, which the daemon always sets). A matching code is necessary but not
958/// sufficient — the caller additionally confirms the on-disk binary version drifted before treating
959/// the exit as an upgrade rather than a crash.
960fn is_upgrade_restart_exit_code(exit_code: Option<i32>) -> bool {
961    matches!(exit_code, Some(0) | Some(RESTART_EXIT_CODE))
962}
963
964/// Spawn a node process from a NodeConfig.
965///
966/// Writes `<data_dir>/node.pid` on successful spawn so that a future daemon instance
967/// can adopt the running process via `Supervisor::adopt_from_registry`. The file is
968/// cleaned up by `monitor_node` on the node's terminal exit.
969async fn spawn_node_from_config(config: &NodeConfig) -> Result<tokio::process::Child> {
970    let args = build_node_args(config);
971    let env_vars: Vec<(String, String)> = config.env_variables.clone().into_iter().collect();
972
973    let log_dir = config
974        .log_dir
975        .as_deref()
976        .unwrap_or(config.data_dir.as_path());
977
978    let child = spawn_node(&config.binary_path, &args, &env_vars, log_dir).await?;
979    if let Some(pid) = child.id() {
980        write_node_pid(&config.data_dir, pid);
981    }
982    Ok(child)
983}
984
985/// Monitor a node process. On exit, handle restart logic. On permanent exit
986/// (user stop, crash limit, errored), cleans up the pid file so a subsequent
987/// daemon restart doesn't try to adopt a dead process.
988async fn monitor_node(
989    child: tokio::process::Child,
990    mut config: NodeConfig,
991    supervisor: Arc<RwLock<Supervisor>>,
992    registry: Arc<RwLock<NodeRegistry>>,
993    event_tx: broadcast::Sender<NodeEvent>,
994) {
995    monitor_node_inner(child, &mut config, supervisor, registry, event_tx).await;
996    remove_node_pid(&config.data_dir);
997}
998
999async fn monitor_node_inner(
1000    mut child: tokio::process::Child,
1001    config: &mut NodeConfig,
1002    supervisor: Arc<RwLock<Supervisor>>,
1003    registry: Arc<RwLock<NodeRegistry>>,
1004    event_tx: broadcast::Sender<NodeEvent>,
1005) {
1006    let node_id = config.id;
1007
1008    loop {
1009        // Wait for the process to exit
1010        let exit_status = child.wait().await;
1011
1012        // Intentional stops must not respawn. Stopped/Stopping are user-initiated; Evicted means
1013        // the daemon deleted the data dir to reclaim space.
1014        let status_at_exit = {
1015            let sup = supervisor.read().await;
1016            sup.node_status(node_id).ok()
1017        };
1018        if matches!(
1019            status_at_exit,
1020            Some(NodeStatus::Stopped) | Some(NodeStatus::Stopping) | Some(NodeStatus::Evicted)
1021        ) {
1022            // Logged because this return is indistinguishable from a healthy user-initiated stop
1023            // unless it says so. If anything else has parked the node in one of these states while
1024            // it was shutting down, an auto-upgrade restart is silently abandoned here.
1025            tracing::info!(
1026                "node {node_id}: process exited while marked {status_at_exit:?}; treating it as an \
1027                 intentional stop and not restarting"
1028            );
1029            return;
1030        }
1031
1032        let exit_code = exit_status.ok().and_then(|s| s.code());
1033
1034        // A process-reported exit that wasn't user-initiated (filtered above) is either an
1035        // auto-upgrade or a crash. In neither case should the node be parked in `Stopped` — that
1036        // state is reserved for intentional user stops.
1037        //
1038        // ant-node runs with `--stop-on-upgrade`: after replacing its own binary in place it exits
1039        // cleanly and relies on this daemon to restart it (`0` on Unix, `RESTART_EXIT_CODE` on
1040        // Windows). Distinguish an upgrade from a crash by whether the on-disk binary's version
1041        // drifted from the registry — the reliable signal, independent of platform exit code. On an
1042        // upgrade we respawn directly (no backoff, no crash counter) and refresh the recorded
1043        // version via `respawn_upgraded_node`.
1044        if is_upgrade_restart_exit_code(exit_code) {
1045            // Every outcome below is logged. This is the point where an auto-upgrade either lands
1046            // or is silently mistaken for a crash, and the two inputs to that decision -- the exit
1047            // code and the on-disk version -- exist nowhere else afterwards. `NodeEvent`s alone are
1048            // not enough: they go to a broadcast channel whose only consumer is the events stream,
1049            // so with nothing attached at the moment of the exit they leave no trace at all.
1050            match extract_version(&config.binary_path).await {
1051                Ok(disk_version) if disk_version != config.version => {
1052                    tracing::info!(
1053                        "node {node_id}: exited with code {exit_code:?} and its binary is now \
1054                         {disk_version} (registry has {}); treating this as an auto-upgrade restart",
1055                        config.version
1056                    );
1057                    match respawn_upgraded_node(config, &supervisor, &registry, &event_tx).await {
1058                        Ok(new_child) => {
1059                            child = new_child;
1060                            continue;
1061                        }
1062                        Err(e) => {
1063                            tracing::error!(
1064                                "node {node_id}: upgraded to {disk_version} but could not be \
1065                                 respawned: {e}"
1066                            );
1067                            let _ = event_tx.send(NodeEvent::NodeErrored {
1068                                node_id,
1069                                message: format!("Failed to respawn after upgrade: {e}"),
1070                            });
1071                            let mut sup = supervisor.write().await;
1072                            sup.update_state(node_id, NodeStatus::Errored, None);
1073                            return;
1074                        }
1075                    }
1076                }
1077                Ok(disk_version) => {
1078                    tracing::warn!(
1079                        "node {node_id}: exited with code {exit_code:?}, but its binary is still \
1080                         {disk_version}, so this is not an upgrade restart; treating it as a crash"
1081                    );
1082                }
1083                Err(e) => {
1084                    // Swallowing this is how an upgrade that did replace the binary gets recorded
1085                    // as a crash with no explanation anywhere.
1086                    tracing::warn!(
1087                        "node {node_id}: exited with code {exit_code:?}, but the version of {} \
1088                         could not be read: {e}. Treating it as a crash -- if an auto-upgrade had \
1089                         just replaced that binary, this is where it was missed",
1090                        config.binary_path.display()
1091                    );
1092                }
1093            }
1094            // Fall through to the crash / restart path. We report the crash with the exit code
1095            // preserved; the crash counter guards against infinite restart loops if the process
1096            // keeps exiting immediately.
1097        } else {
1098            tracing::warn!(
1099                "node {node_id}: exited with code {exit_code:?}, which is not an upgrade-restart \
1100                 code; treating it as a crash"
1101            );
1102        }
1103
1104        // Crash (or clean exit that wasn't an upgrade)
1105        let _ = event_tx.send(NodeEvent::NodeCrashed { node_id, exit_code });
1106
1107        let (should_restart, attempt, backoff) = {
1108            let mut sup = supervisor.write().await;
1109            sup.record_crash(node_id)
1110        };
1111
1112        if !should_restart {
1113            tracing::error!(
1114                "node {node_id}: crashed {} times within {} seconds; giving up and marking it \
1115                 errored",
1116                MAX_CRASHES_BEFORE_ERRORED,
1117                CRASH_WINDOW.as_secs()
1118            );
1119            let _ = event_tx.send(NodeEvent::NodeErrored {
1120                node_id,
1121                message: format!(
1122                    "Node crashed {} times within {} seconds, giving up",
1123                    MAX_CRASHES_BEFORE_ERRORED,
1124                    CRASH_WINDOW.as_secs()
1125                ),
1126            });
1127            return;
1128        }
1129
1130        tracing::info!(
1131            "node {node_id}: restarting after crash (attempt {attempt}) in {}s",
1132            backoff.as_secs()
1133        );
1134        let _ = event_tx.send(NodeEvent::NodeRestarting { node_id, attempt });
1135
1136        tokio::time::sleep(backoff).await;
1137
1138        // Try to restart
1139        match spawn_node_from_config(&*config).await {
1140            Ok(new_child) => {
1141                let pid = match new_child.id() {
1142                    Some(pid) => pid,
1143                    None => {
1144                        // Process exited before we could read its PID
1145                        tracing::error!(
1146                            "node {node_id}: restarted process exited before its PID could be read"
1147                        );
1148                        let _ = event_tx.send(NodeEvent::NodeErrored {
1149                            node_id,
1150                            message: "Restarted process exited before PID could be read"
1151                                .to_string(),
1152                        });
1153                        let mut sup = supervisor.write().await;
1154                        sup.update_state(node_id, NodeStatus::Errored, None);
1155                        return;
1156                    }
1157                };
1158                {
1159                    let mut sup = supervisor.write().await;
1160                    sup.update_state(node_id, NodeStatus::Running, Some(pid));
1161                }
1162                let _ = event_tx.send(NodeEvent::NodeStarted { node_id, pid });
1163                child = new_child;
1164            }
1165            Err(e) => {
1166                tracing::error!("node {node_id}: failed to restart after crash: {e}");
1167                let _ = event_tx.send(NodeEvent::NodeErrored {
1168                    node_id,
1169                    message: format!("Failed to restart node: {e}"),
1170                });
1171                let mut sup = supervisor.write().await;
1172                sup.update_state(node_id, NodeStatus::Errored, None);
1173                return;
1174            }
1175        }
1176    }
1177}
1178
1179/// Respawn a node that exited to apply an in-place auto-upgrade of its own binary.
1180///
1181/// On success: persists the new version to the registry, updates the in-memory config clone,
1182/// sets status back to Running, and fires `NodeUpgraded`.
1183async fn respawn_upgraded_node(
1184    config: &mut NodeConfig,
1185    supervisor: &Arc<RwLock<Supervisor>>,
1186    registry: &Arc<RwLock<NodeRegistry>>,
1187    event_tx: &broadcast::Sender<NodeEvent>,
1188) -> Result<tokio::process::Child> {
1189    let node_id = config.id;
1190    let old_version = config.version.clone();
1191
1192    let new_child = spawn_node_from_config(config).await?;
1193    let pid = new_child
1194        .id()
1195        .ok_or_else(|| Error::ProcessSpawn("Failed to get PID after upgrade respawn".into()))?;
1196
1197    // Read the new version from the replaced binary. If this fails we still consider the respawn
1198    // successful; we just don't refresh the recorded version this round.
1199    let new_version = extract_version(&config.binary_path).await.ok();
1200
1201    if let Some(ref version) = new_version {
1202        config.version = version.clone();
1203        let mut reg = registry.write().await;
1204        if let Ok(stored) = reg.get_mut(node_id) {
1205            stored.version = version.clone();
1206            let _ = reg.save();
1207        }
1208    }
1209
1210    {
1211        let mut sup = supervisor.write().await;
1212        if let Some(state) = sup.node_states.get_mut(&node_id) {
1213            state.status = NodeStatus::Running;
1214            state.pid = Some(pid);
1215            state.started_at = Some(Instant::now());
1216            state.restart_count = 0;
1217            state.first_crash_at = None;
1218        }
1219    }
1220
1221    let _ = event_tx.send(NodeEvent::NodeStarted { node_id, pid });
1222    if let Some(version) = new_version {
1223        let _ = event_tx.send(NodeEvent::NodeUpgraded {
1224            node_id,
1225            old_version,
1226            new_version: version,
1227        });
1228    }
1229
1230    Ok(new_child)
1231}
1232
1233/// Timeout for graceful shutdown before force-killing.
1234const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
1235
1236/// Send SIGTERM to a process, wait for it to exit, and SIGKILL if it doesn't.
1237async fn graceful_kill(pid: u32) {
1238    send_signal_term(pid);
1239
1240    // Poll for process exit
1241    let start = Instant::now();
1242    loop {
1243        if !is_process_alive(pid) {
1244            return;
1245        }
1246        if start.elapsed() >= GRACEFUL_SHUTDOWN_TIMEOUT {
1247            break;
1248        }
1249        tokio::time::sleep(Duration::from_millis(100)).await;
1250    }
1251
1252    // Force kill if still alive
1253    send_signal_kill(pid);
1254
1255    // Brief wait for force kill to take effect
1256    for _ in 0..10 {
1257        if !is_process_alive(pid) {
1258            return;
1259        }
1260        tokio::time::sleep(Duration::from_millis(50)).await;
1261    }
1262}
1263
1264/// Decide whether the liveness monitor should flip a node it found dead to `Stopped`.
1265///
1266/// `snapshot_pid` is the PID the sweep captured and then observed to be dead. `current_pid`
1267/// and `current_status` are the node's recorded state at the moment of the decision — which
1268/// may differ from the snapshot (e.g. an upgrade respawn replaced the PID with a live one
1269/// while leaving the status `Running`).
1270///
1271/// Three conditions, all necessary:
1272///
1273/// * **The node must be adopted.** A node this daemon spawned has a `monitor_node` task
1274///   awaiting its `Child`, and that task owns the exit transition: it distinguishes an
1275///   auto-upgrade restart from a crash and respawns accordingly. The sweep reaching the node
1276///   first and parking it in `Stopped` does not merely duplicate that work, it *prevents* it —
1277///   `monitor_node_inner` treats `Stopped` as a user-initiated stop and returns without
1278///   restarting. The node is then left down for good, on a binary it already replaced, with the
1279///   registry still naming the old version. That race is lost whenever shutdown outlasts a poll
1280///   interval, which a graceful P2P shutdown routinely does.
1281/// * **It must still be `Running`.** Anything else is already accounted for.
1282/// * **The recorded PID must still be the one observed dead.** Between the snapshot and now, an
1283///   upgrade (or crash) respawn can have replaced the dead `snapshot_pid` with a live
1284///   `current_pid` while keeping the status `Running`. Stopping there would clobber a healthy,
1285///   freshly respawned process (the "running node reported as stopped after an upgrade" bug).
1286fn liveness_should_stop(
1287    is_adopted: bool,
1288    snapshot_pid: u32,
1289    current_pid: Option<u32>,
1290    current_status: Option<NodeStatus>,
1291) -> bool {
1292    is_adopted && current_status == Some(NodeStatus::Running) && current_pid == Some(snapshot_pid)
1293}
1294
1295/// Poll each Running node's PID for OS liveness every `LIVENESS_POLL_INTERVAL`,
1296/// flipping dead ones to `Stopped` and emitting `NodeStopped`.
1297///
1298/// Exists to detect exits of nodes adopted across a daemon restart
1299/// (`Supervisor::adopt_from_registry`). Daemon-spawned nodes have a
1300/// `monitor_node` task awaiting on the owned `Child` handle, which detects
1301/// exit immediately — the poll is redundant-but-harmless for them. Adopted
1302/// nodes don't have a `Child` (it died with the previous daemon), so the poll
1303/// is the only way the supervisor learns that one has exited.
1304///
1305/// The task terminates when `shutdown` is cancelled.
1306pub fn spawn_liveness_monitor(
1307    registry: Arc<RwLock<NodeRegistry>>,
1308    supervisor: Arc<RwLock<Supervisor>>,
1309    event_tx: broadcast::Sender<NodeEvent>,
1310    interval: Duration,
1311    shutdown: CancellationToken,
1312) {
1313    tokio::spawn(async move {
1314        let mut ticker = tokio::time::interval(interval);
1315        // Don't burst-catchup after a Windows sleep/hibernate: a flood of liveness
1316        // probes serves no purpose, and uniform `Skip` policy across supervisor
1317        // monitors keeps post-wake behaviour predictable.
1318        ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
1319        loop {
1320            tokio::select! {
1321                _ = shutdown.cancelled() => return,
1322                _ = ticker.tick() => {}
1323            }
1324
1325            // Snapshot candidates to release locks before the per-process syscalls.
1326            let candidates: Vec<(u32, u32, PathBuf)> =
1327                {
1328                    let sup = supervisor.read().await;
1329                    let reg = registry.read().await;
1330                    reg.list()
1331                        .into_iter()
1332                        .filter_map(|config| {
1333                            let pid = sup.node_pid(config.id)?;
1334                            matches!(sup.node_status(config.id), Ok(NodeStatus::Running))
1335                                .then_some((config.id, pid, config.data_dir.clone()))
1336                        })
1337                        .collect()
1338                };
1339
1340            for (node_id, pid, data_dir) in candidates {
1341                if is_process_alive(pid) {
1342                    continue;
1343                }
1344
1345                // Adopted nodes have no owning `monitor_node`, so this poll is their only
1346                // supervisor. If such a node's process died and the on-disk binary version has
1347                // drifted from the registry, the exit was an auto-upgrade — `--stop-on-upgrade`
1348                // expects the service manager (us) to restart it. Respawn it on the new binary
1349                // and hand it a `monitor_node`, rather than leaving it dead and flagged Stopped.
1350                if supervisor.read().await.is_adopted(node_id) {
1351                    let config = {
1352                        let reg = registry.read().await;
1353                        reg.get(node_id).ok().cloned()
1354                    };
1355                    if let Some(mut config) = config {
1356                        let drifted = matches!(
1357                            extract_version(&config.binary_path).await,
1358                            Ok(disk_version) if disk_version != config.version
1359                        );
1360                        if drifted {
1361                            match respawn_upgraded_node(
1362                                &mut config,
1363                                &supervisor,
1364                                &registry,
1365                                &event_tx,
1366                            )
1367                            .await
1368                            {
1369                                Ok(child) => {
1370                                    // Now owned by this daemon: clear the adopted flag and give
1371                                    // it a monitor_node so future exits are handled there.
1372                                    supervisor.write().await.mark_owned(node_id);
1373                                    let sup_ref = Arc::clone(&supervisor);
1374                                    let reg_ref = Arc::clone(&registry);
1375                                    let ev = event_tx.clone();
1376                                    tokio::spawn(async move {
1377                                        monitor_node(child, config, sup_ref, reg_ref, ev).await;
1378                                    });
1379                                    continue;
1380                                }
1381                                Err(e) => {
1382                                    let _ = event_tx.send(NodeEvent::NodeErrored {
1383                                        node_id,
1384                                        message: format!(
1385                                            "Failed to respawn adopted node after upgrade: {e}"
1386                                        ),
1387                                    });
1388                                    let mut sup = supervisor.write().await;
1389                                    sup.update_state(node_id, NodeStatus::Errored, None);
1390                                    sup.mark_owned(node_id);
1391                                    remove_node_pid(&data_dir);
1392                                    continue;
1393                                }
1394                            }
1395                        }
1396                    }
1397                }
1398
1399                let mut sup = supervisor.write().await;
1400                // Re-check under the write lock to avoid racing with a concurrent
1401                // start/stop that flipped the state between the snapshot and now.
1402                if !liveness_should_stop(
1403                    sup.is_adopted(node_id),
1404                    pid,
1405                    sup.node_pid(node_id),
1406                    sup.node_status(node_id).ok(),
1407                ) {
1408                    continue;
1409                }
1410                tracing::info!("node {node_id}: adopted process {pid} is gone; marking it stopped");
1411                sup.update_state(node_id, NodeStatus::Stopped, None);
1412                let _ = event_tx.send(NodeEvent::NodeStopped { node_id });
1413                remove_node_pid(&data_dir);
1414            }
1415        }
1416    });
1417}
1418
1419#[cfg(unix)]
1420fn pid_to_i32(pid: u32) -> Option<i32> {
1421    i32::try_from(pid).ok().filter(|&p| p > 0)
1422}
1423
1424#[cfg(unix)]
1425fn send_signal_term(pid: u32) {
1426    if let Some(pid) = pid_to_i32(pid) {
1427        unsafe {
1428            libc::kill(pid, libc::SIGTERM);
1429        }
1430    }
1431}
1432
1433#[cfg(unix)]
1434fn send_signal_kill(pid: u32) {
1435    if let Some(pid) = pid_to_i32(pid) {
1436        unsafe {
1437            libc::kill(pid, libc::SIGKILL);
1438        }
1439    }
1440}
1441
1442#[cfg(unix)]
1443fn is_process_alive(pid: u32) -> bool {
1444    let Some(pid) = pid_to_i32(pid) else {
1445        return false;
1446    };
1447    let ret = unsafe { libc::kill(pid, 0) };
1448    if ret == 0 {
1449        return true;
1450    }
1451    // EPERM means the process exists but we lack permission to signal it
1452    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1453}
1454
1455#[cfg(windows)]
1456fn send_signal_term(pid: u32) {
1457    use windows_sys::Win32::System::Console::{
1458        AttachConsole, FreeConsole, GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, CTRL_C_EVENT,
1459    };
1460
1461    unsafe {
1462        // Detach from our own console (no-op if daemon has none, which is
1463        // typical since it's spawned with DETACHED_PROCESS).
1464        FreeConsole();
1465
1466        // Attach to the target process's console and send Ctrl+C
1467        if AttachConsole(pid) != 0 {
1468            // Disable Ctrl+C handling so GenerateConsoleCtrlEvent doesn't
1469            // terminate us while we're attached to the node's console.
1470            SetConsoleCtrlHandler(None, 1);
1471            GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0);
1472            // Detach from the node's console first — once detached, the
1473            // async Ctrl+C event can only reach the node, not us.
1474            FreeConsole();
1475            // Brief delay to let the event drain before re-enabling our
1476            // handler. Without this, the handler thread can process the
1477            // event between FreeConsole and SetConsoleCtrlHandler.
1478            std::thread::sleep(std::time::Duration::from_millis(50));
1479            // Restore Ctrl+C handling so `daemon run` (foreground mode)
1480            // can still be stopped via Ctrl+C / tokio::signal::ctrl_c().
1481            SetConsoleCtrlHandler(None, 0);
1482        }
1483    }
1484}
1485
1486#[cfg(windows)]
1487fn send_signal_kill(pid: u32) {
1488    use windows_sys::Win32::Foundation::CloseHandle;
1489    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
1490
1491    unsafe {
1492        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
1493        if !handle.is_null() {
1494            TerminateProcess(handle, 1);
1495            CloseHandle(handle);
1496        }
1497    }
1498}
1499
1500#[cfg(windows)]
1501fn is_process_alive(pid: u32) -> bool {
1502    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1503    use windows_sys::Win32::System::Threading::{
1504        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1505    };
1506
1507    unsafe {
1508        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1509        if handle.is_null() {
1510            return false;
1511        }
1512        let mut exit_code: u32 = 0;
1513        let success = GetExitCodeProcess(handle, &mut exit_code);
1514        CloseHandle(handle);
1515        success != 0 && exit_code == STILL_ACTIVE as u32
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522    use crate::node::types::{EvmNetwork, UpgradeChannel};
1523
1524    #[tokio::test]
1525    async fn remove_dir_all_with_retry_deletes_tree_and_tolerates_missing() {
1526        let tmp = tempfile::tempdir().unwrap();
1527        let dir = tmp.path().join("node-data");
1528        std::fs::create_dir_all(dir.join("sub")).unwrap();
1529        std::fs::write(dir.join("sub").join("data.mdb"), vec![0u8; 128]).unwrap();
1530
1531        // Deletes an existing tree.
1532        remove_dir_all_with_retry(&dir).await.unwrap();
1533        assert!(!dir.exists());
1534
1535        // Idempotent: an already-gone path is treated as success (no error).
1536        remove_dir_all_with_retry(&dir).await.unwrap();
1537    }
1538
1539    #[tokio::test]
1540    async fn start_node_rejects_a_node_being_evicted() {
1541        let (tx, _rx) = broadcast::channel(16);
1542        let sup = Arc::new(RwLock::new(Supervisor::new(tx)));
1543
1544        let tmp = tempfile::tempdir().unwrap();
1545        let reg = Arc::new(RwLock::new(
1546            NodeRegistry::load(&tmp.path().join("reg.json")).unwrap(),
1547        ));
1548
1549        let config = NodeConfig {
1550            id: 7,
1551            service_name: "node7".to_string(),
1552            rewards_address: "0xabc".to_string(),
1553            data_dir: tmp.path().join("node-7"),
1554            log_dir: None,
1555            node_port: None,
1556            binary_path: "/bin/node".into(),
1557            version: "0.1.0".to_string(),
1558            env_variables: HashMap::new(),
1559            bootstrap_peers: vec![],
1560            upgrade_channel: None,
1561            evm_network: EvmNetwork::default(),
1562            eviction: None,
1563        };
1564
1565        // Flagged as evicting -> start is refused before any spawn attempt, closing the race with
1566        // the concurrent stop/delete in evict_node.
1567        sup.write().await.begin_evicting(7);
1568        let res = sup
1569            .write()
1570            .await
1571            .start_node(&config, sup.clone(), reg.clone())
1572            .await;
1573        assert!(matches!(res, Err(Error::NodeEvicted(7))));
1574
1575        // Clearing the flag removes the in-progress guard (the persisted marker takes over).
1576        sup.write().await.finish_evicting(7);
1577        assert!(!sup.read().await.evicting.contains(&7));
1578    }
1579
1580    #[test]
1581    fn adopted_flag_lifecycle() {
1582        let (tx, _rx) = broadcast::channel(16);
1583        let mut sup = Supervisor::new(tx);
1584
1585        // Nodes are not adopted by default.
1586        assert!(!sup.is_adopted(1));
1587
1588        // adopt_from_registry flags nodes carried over from a previous daemon.
1589        sup.adopted.insert(1);
1590        assert!(sup.is_adopted(1));
1591
1592        // Once this daemon (re)spawns the node and owns a monitor_node for it, the flag
1593        // clears so the liveness monitor stops treating its exit as needing a respawn.
1594        sup.mark_owned(1);
1595        assert!(!sup.is_adopted(1));
1596    }
1597
1598    // Regression test for the "running node reported as stopped after an upgrade" bug.
1599    //
1600    // A daemon-spawned node was respawned by monitor_node after an upgrade, so the recorded
1601    // state is now Running with a live PID_new. A liveness sweep that snapshotted the old,
1602    // now-dead PID then acts: it must NOT mark the node Stopped, because the running process
1603    // is the new one. `liveness_should_stop` guards against this by also requiring the recorded
1604    // PID to still match the one the sweep observed dead.
1605    #[test]
1606    fn liveness_does_not_stop_node_respawned_under_it() {
1607        let dead_snapshot_pid = 1000; // PID the sweep captured and found dead
1608        let live_respawned_pid = Some(2000); // PID_new from the upgrade respawn (alive)
1609        assert!(
1610            !liveness_should_stop(
1611                true,
1612                dead_snapshot_pid,
1613                live_respawned_pid,
1614                Some(NodeStatus::Running)
1615            ),
1616            "liveness must not stop a node whose PID changed under it (respawned with a live PID)"
1617        );
1618    }
1619
1620    // Regression test for the "upgraded node never restarts and reports Stopped" bug.
1621    //
1622    // A node this daemon spawned exits to apply an auto-upgrade. Its `monitor_node` task is about
1623    // to read the exit and respawn it on the new binary. If the liveness sweep gets there first and
1624    // marks it Stopped, `monitor_node_inner` reads that status, concludes the stop was intentional
1625    // and returns — leaving the node down permanently with the registry still on the old version.
1626    // The sweep must therefore leave daemon-owned nodes alone entirely.
1627    #[test]
1628    fn liveness_does_not_stop_a_daemon_owned_node() {
1629        let dead_pid = 1000;
1630        assert!(
1631            !liveness_should_stop(false, dead_pid, Some(dead_pid), Some(NodeStatus::Running)),
1632            "liveness must not pre-empt monitor_node's exit handling for a node this daemon spawned"
1633        );
1634        assert!(
1635            liveness_should_stop(true, dead_pid, Some(dead_pid), Some(NodeStatus::Running)),
1636            "an adopted node has no monitor_node, so the sweep is still its only supervisor"
1637        );
1638    }
1639
1640    #[test]
1641    fn build_node_args_basic() {
1642        let config = NodeConfig {
1643            id: 1,
1644            service_name: "node1".to_string(),
1645            rewards_address: "0xabc123".to_string(),
1646            data_dir: "/data/node-1".into(),
1647            log_dir: Some("/logs/node-1".into()),
1648            node_port: Some(12000),
1649            binary_path: "/bin/node".into(),
1650            version: "0.1.0".to_string(),
1651            env_variables: HashMap::new(),
1652            bootstrap_peers: vec!["peer1".to_string(), "peer2".to_string()],
1653            upgrade_channel: None,
1654            evm_network: EvmNetwork::default(),
1655            eviction: None,
1656        };
1657
1658        let args = build_node_args(&config);
1659
1660        assert!(args.contains(&"--rewards-address".to_string()));
1661        assert!(args.contains(&"0xabc123".to_string()));
1662        assert!(args.contains(&"--root-dir".to_string()));
1663        assert!(args.contains(&"/data/node-1".to_string()));
1664        assert!(args.contains(&"--enable-logging".to_string()));
1665        assert!(args.contains(&"--log-dir".to_string()));
1666        assert!(args.contains(&"/logs/node-1".to_string()));
1667        assert!(args.contains(&"--port".to_string()));
1668        assert!(args.contains(&"12000".to_string()));
1669        assert!(args.contains(&"--bootstrap".to_string()));
1670        assert!(args.contains(&"peer1".to_string()));
1671        assert!(args.contains(&"peer2".to_string()));
1672        assert!(args.contains(&"--stop-on-upgrade".to_string()));
1673        // No upgrade channel configured -> no --upgrade-channel argument.
1674        assert!(!args.contains(&"--upgrade-channel".to_string()));
1675        // EVM network defaults to Arbitrum One, emitted as a --evm-network flag.
1676        assert_eq!(evm_network_arg(&args), Some("arbitrum-one"));
1677    }
1678
1679    /// Return the value following `--evm-network` in a built arg list, if present.
1680    fn evm_network_arg(args: &[String]) -> Option<&str> {
1681        let idx = args.iter().position(|a| a == "--evm-network")?;
1682        args.get(idx + 1).map(String::as_str)
1683    }
1684
1685    #[test]
1686    fn build_node_args_emits_evm_network_flag() {
1687        let mut config = NodeConfig {
1688            id: 1,
1689            service_name: "node1".to_string(),
1690            rewards_address: "0xabc".to_string(),
1691            data_dir: "/data/node-1".into(),
1692            log_dir: None,
1693            node_port: None,
1694            binary_path: "/bin/node".into(),
1695            version: "0.1.0".to_string(),
1696            env_variables: HashMap::new(),
1697            bootstrap_peers: vec![],
1698            upgrade_channel: None,
1699            evm_network: EvmNetwork::ArbitrumSepolia,
1700            eviction: None,
1701        };
1702
1703        let args = build_node_args(&config);
1704        assert_eq!(evm_network_arg(&args), Some("arbitrum-sepolia"));
1705
1706        config.evm_network = EvmNetwork::ArbitrumOne;
1707        let args = build_node_args(&config);
1708        assert_eq!(evm_network_arg(&args), Some("arbitrum-one"));
1709    }
1710
1711    #[test]
1712    fn build_node_args_includes_upgrade_channel() {
1713        let mut config = NodeConfig {
1714            id: 1,
1715            service_name: "node1".to_string(),
1716            rewards_address: "0xabc".to_string(),
1717            data_dir: "/data/node-1".into(),
1718            log_dir: None,
1719            node_port: None,
1720            binary_path: "/bin/node".into(),
1721            version: "0.1.0".to_string(),
1722            env_variables: HashMap::new(),
1723            bootstrap_peers: vec![],
1724            upgrade_channel: Some(UpgradeChannel::Beta),
1725            evm_network: EvmNetwork::default(),
1726            eviction: None,
1727        };
1728
1729        let args = build_node_args(&config);
1730        let idx = args
1731            .iter()
1732            .position(|a| a == "--upgrade-channel")
1733            .expect("--upgrade-channel should be present");
1734        assert_eq!(args[idx + 1], "beta");
1735
1736        config.upgrade_channel = Some(UpgradeChannel::Stable);
1737        let args = build_node_args(&config);
1738        let idx = args.iter().position(|a| a == "--upgrade-channel").unwrap();
1739        assert_eq!(args[idx + 1], "stable");
1740    }
1741
1742    #[test]
1743    fn build_node_args_minimal() {
1744        let config = NodeConfig {
1745            id: 1,
1746            service_name: "node1".to_string(),
1747            rewards_address: "0xabc".to_string(),
1748            data_dir: "/data/node-1".into(),
1749            log_dir: None,
1750            node_port: None,
1751            binary_path: "/bin/node".into(),
1752            version: "0.1.0".to_string(),
1753            env_variables: HashMap::new(),
1754            bootstrap_peers: vec![],
1755            upgrade_channel: None,
1756            evm_network: EvmNetwork::default(),
1757            eviction: None,
1758        };
1759
1760        let args = build_node_args(&config);
1761
1762        assert!(args.contains(&"--rewards-address".to_string()));
1763        assert!(args.contains(&"--root-dir".to_string()));
1764        assert!(!args.contains(&"--enable-logging".to_string()));
1765        assert!(!args.contains(&"--log-dir".to_string()));
1766        assert!(!args.contains(&"--port".to_string()));
1767        assert!(!args.contains(&"--bootstrap".to_string()));
1768        assert!(args.contains(&"--stop-on-upgrade".to_string()));
1769    }
1770
1771    #[test]
1772    fn record_crash_backoff_increases() {
1773        let (tx, _rx) = broadcast::channel(16);
1774        let mut sup = Supervisor::new(tx);
1775
1776        // Insert a running node
1777        sup.node_states.insert(
1778            1,
1779            NodeRuntime {
1780                status: NodeStatus::Running,
1781                pid: Some(100),
1782                started_at: Some(Instant::now()),
1783                restart_count: 0,
1784                first_crash_at: None,
1785            },
1786        );
1787
1788        let (should_restart, attempt, backoff) = sup.record_crash(1);
1789        assert!(should_restart);
1790        assert_eq!(attempt, 1);
1791        assert_eq!(backoff, Duration::from_secs(1));
1792
1793        let (should_restart, attempt, backoff) = sup.record_crash(1);
1794        assert!(should_restart);
1795        assert_eq!(attempt, 2);
1796        assert_eq!(backoff, Duration::from_secs(2));
1797
1798        let (should_restart, attempt, backoff) = sup.record_crash(1);
1799        assert!(should_restart);
1800        assert_eq!(attempt, 3);
1801        assert_eq!(backoff, Duration::from_secs(4));
1802
1803        let (should_restart, attempt, backoff) = sup.record_crash(1);
1804        assert!(should_restart);
1805        assert_eq!(attempt, 4);
1806        assert_eq!(backoff, Duration::from_secs(8));
1807
1808        // 5th crash within window → errored
1809        let (should_restart, attempt, _) = sup.record_crash(1);
1810        assert!(!should_restart);
1811        assert_eq!(attempt, 5);
1812        assert_eq!(sup.node_states[&1].status, NodeStatus::Errored);
1813    }
1814
1815    #[test]
1816    fn node_counts_tracks_states() {
1817        let (tx, _rx) = broadcast::channel(16);
1818        let mut sup = Supervisor::new(tx);
1819
1820        sup.node_states.insert(
1821            1,
1822            NodeRuntime {
1823                status: NodeStatus::Running,
1824                pid: Some(100),
1825                started_at: Some(Instant::now()),
1826                restart_count: 0,
1827                first_crash_at: None,
1828            },
1829        );
1830        sup.node_states.insert(
1831            2,
1832            NodeRuntime {
1833                status: NodeStatus::Stopped,
1834                pid: None,
1835                started_at: None,
1836                restart_count: 0,
1837                first_crash_at: None,
1838            },
1839        );
1840        sup.node_states.insert(
1841            3,
1842            NodeRuntime {
1843                status: NodeStatus::Errored,
1844                pid: None,
1845                started_at: None,
1846                restart_count: 5,
1847                first_crash_at: None,
1848            },
1849        );
1850
1851        let (running, stopped, errored) = sup.node_counts();
1852        assert_eq!(running, 1);
1853        assert_eq!(stopped, 1);
1854        assert_eq!(errored, 1);
1855    }
1856
1857    #[test]
1858    fn upgrade_restart_exit_code_covers_unix_and_windows() {
1859        // Unix upgrade exit and the Windows RESTART_EXIT_CODE both count as candidate upgrade
1860        // restarts; anything else (crash codes, signals with no code) does not. The version-drift
1861        // check in `monitor_node_inner` is what actually confirms an upgrade — this only gates it.
1862        assert!(is_upgrade_restart_exit_code(Some(0)));
1863        assert!(is_upgrade_restart_exit_code(Some(RESTART_EXIT_CODE)));
1864        assert!(!is_upgrade_restart_exit_code(Some(1)));
1865        assert!(!is_upgrade_restart_exit_code(Some(101)));
1866        assert!(!is_upgrade_restart_exit_code(None));
1867    }
1868
1869    #[tokio::test]
1870    async fn stop_node_not_found() {
1871        let (tx, _rx) = broadcast::channel(16);
1872        let mut sup = Supervisor::new(tx);
1873
1874        let result = sup.stop_node(999).await;
1875        assert!(matches!(result, Err(Error::NodeNotFound(999))));
1876    }
1877
1878    #[tokio::test]
1879    async fn stop_node_not_running() {
1880        let (tx, _rx) = broadcast::channel(16);
1881        let mut sup = Supervisor::new(tx);
1882
1883        sup.node_states.insert(
1884            1,
1885            NodeRuntime {
1886                status: NodeStatus::Stopped,
1887                pid: None,
1888                started_at: None,
1889                restart_count: 0,
1890                first_crash_at: None,
1891            },
1892        );
1893
1894        let result = sup.stop_node(1).await;
1895        assert!(matches!(result, Err(Error::NodeNotRunning(1))));
1896    }
1897
1898    #[tokio::test]
1899    async fn stop_all_nodes_mixed_states() {
1900        let (tx, _rx) = broadcast::channel(16);
1901        let mut sup = Supervisor::new(tx);
1902
1903        // Node 1: running (but with a fake PID that won't exist)
1904        sup.node_states.insert(
1905            1,
1906            NodeRuntime {
1907                status: NodeStatus::Running,
1908                pid: Some(999999),
1909                started_at: Some(Instant::now()),
1910                restart_count: 0,
1911                first_crash_at: None,
1912            },
1913        );
1914        // Node 2: already stopped
1915        sup.node_states.insert(
1916            2,
1917            NodeRuntime {
1918                status: NodeStatus::Stopped,
1919                pid: None,
1920                started_at: None,
1921                restart_count: 0,
1922                first_crash_at: None,
1923            },
1924        );
1925
1926        let configs = vec![(1, "node1".to_string()), (2, "node2".to_string())];
1927
1928        let result = sup.stop_all_nodes(&configs).await;
1929
1930        assert_eq!(result.stopped.len(), 1);
1931        assert_eq!(result.stopped[0].node_id, 1);
1932        assert_eq!(result.stopped[0].service_name, "node1");
1933        assert_eq!(result.already_stopped, vec![2]);
1934        assert!(result.failed.is_empty());
1935    }
1936}