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
22const RESTART_EXIT_CODE: i32 = 100;
27
28pub const EVICTION_POLL_INTERVAL: Duration = Duration::from_secs(30);
31
32const MAX_EVICTIONS_PER_CYCLE: usize = 4;
35
36pub const LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5);
44
45fn node_pid_file(data_dir: &Path) -> PathBuf {
48 data_dir.join("node.pid")
49}
50
51fn 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
65fn remove_node_pid(data_dir: &Path) {
68 let _ = std::fs::remove_file(node_pid_file(data_dir));
69}
70
71fn 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
79fn 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 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
133fn 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
151fn 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 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
172fn 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
193const MAX_CRASHES_BEFORE_ERRORED: u32 = 5;
195
196const CRASH_WINDOW: Duration = Duration::from_secs(300); const STABLE_DURATION: Duration = Duration::from_secs(300); const MAX_BACKOFF: Duration = Duration::from_secs(60);
205
206pub struct Supervisor {
208 event_tx: broadcast::Sender<NodeEvent>,
209 node_states: HashMap<u32, NodeRuntime>,
211 adopted: HashSet<u32>,
216 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 pub fn is_adopted(&self, node_id: u32) -> bool {
243 self.adopted.contains(&node_id)
244 }
245
246 fn begin_evicting(&mut self, node_id: u32) {
250 self.evicting.insert(node_id);
251 }
252
253 fn finish_evicting(&mut self, node_id: u32) {
256 self.evicting.remove(&node_id);
257 }
258
259 fn mark_owned(&mut self, node_id: u32) {
262 self.adopted.remove(&node_id);
263 }
264
265 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 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 match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
303 Ok(Ok(exit_status)) => {
304 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(_) => {} }
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 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 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 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 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 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 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 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 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 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 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 NodeStatus::Stopped | NodeStatus::Stopping | NodeStatus::Evicted => stopped += 1,
488 NodeStatus::Errored => errored += 1,
489 }
490 }
491 (running, stopped, errored)
492 }
493
494 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 state.started_at = None;
506 }
507 }
508 }
509
510 pub fn adopt_from_registry(&mut self, registry: &NodeRegistry) -> Vec<u32> {
533 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 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 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 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 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 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 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
624pub 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 ticker.tick().await;
650
651 loop {
652 tokio::select! {
653 _ = shutdown.cancelled() => return,
654 _ = ticker.tick() => {},
655 }
656
657 run_eviction_cycle(®istry, &supervisor, &event_tx, &health, &thresholds).await;
658 }
659 });
660}
661
662async 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 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 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 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
716async 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
731async 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 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
756async 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
777async 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 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 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 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 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
869async 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
891fn 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
899fn 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
911pub 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 args.push("--stop-on-upgrade".to_string());
946
947 args.push("--evm-network".to_string());
950 args.push(config.evm_network.as_arg().to_string());
951
952 args
953}
954
955fn is_upgrade_restart_exit_code(exit_code: Option<i32>) -> bool {
961 matches!(exit_code, Some(0) | Some(RESTART_EXIT_CODE))
962}
963
964async 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
985async 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 let exit_status = child.wait().await;
1011
1012 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 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 if is_upgrade_restart_exit_code(exit_code) {
1045 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, ®istry, &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 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 } 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 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 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 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
1179async 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 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
1233const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
1235
1236async fn graceful_kill(pid: u32) {
1238 send_signal_term(pid);
1239
1240 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 send_signal_kill(pid);
1254
1255 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
1264fn 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
1295pub 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 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
1319 loop {
1320 tokio::select! {
1321 _ = shutdown.cancelled() => return,
1322 _ = ticker.tick() => {}
1323 }
1324
1325 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 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 ®istry,
1365 &event_tx,
1366 )
1367 .await
1368 {
1369 Ok(child) => {
1370 supervisor.write().await.mark_owned(node_id);
1373 let sup_ref = Arc::clone(&supervisor);
1374 let reg_ref = Arc::clone(®istry);
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 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 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 FreeConsole();
1465
1466 if AttachConsole(pid) != 0 {
1468 SetConsoleCtrlHandler(None, 1);
1471 GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0);
1472 FreeConsole();
1475 std::thread::sleep(std::time::Duration::from_millis(50));
1479 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 remove_dir_all_with_retry(&dir).await.unwrap();
1533 assert!(!dir.exists());
1534
1535 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 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 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 assert!(!sup.is_adopted(1));
1587
1588 sup.adopted.insert(1);
1590 assert!(sup.is_adopted(1));
1591
1592 sup.mark_owned(1);
1595 assert!(!sup.is_adopted(1));
1596 }
1597
1598 #[test]
1606 fn liveness_does_not_stop_node_respawned_under_it() {
1607 let dead_snapshot_pid = 1000; let live_respawned_pid = Some(2000); 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 #[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 assert!(!args.contains(&"--upgrade-channel".to_string()));
1675 assert_eq!(evm_network_arg(&args), Some("arbitrum-one"));
1677 }
1678
1679 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 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 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 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 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 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}