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 return;
1023 }
1024
1025 let exit_code = exit_status.ok().and_then(|s| s.code());
1026
1027 if is_upgrade_restart_exit_code(exit_code) {
1038 if let Ok(disk_version) = extract_version(&config.binary_path).await {
1039 if disk_version != config.version {
1040 match respawn_upgraded_node(config, &supervisor, ®istry, &event_tx).await {
1041 Ok(new_child) => {
1042 child = new_child;
1043 continue;
1044 }
1045 Err(e) => {
1046 let _ = event_tx.send(NodeEvent::NodeErrored {
1047 node_id,
1048 message: format!("Failed to respawn after upgrade: {e}"),
1049 });
1050 let mut sup = supervisor.write().await;
1051 sup.update_state(node_id, NodeStatus::Errored, None);
1052 return;
1053 }
1054 }
1055 }
1056 }
1057 }
1061
1062 let _ = event_tx.send(NodeEvent::NodeCrashed { node_id, exit_code });
1064
1065 let (should_restart, attempt, backoff) = {
1066 let mut sup = supervisor.write().await;
1067 sup.record_crash(node_id)
1068 };
1069
1070 if !should_restart {
1071 let _ = event_tx.send(NodeEvent::NodeErrored {
1072 node_id,
1073 message: format!(
1074 "Node crashed {} times within {} seconds, giving up",
1075 MAX_CRASHES_BEFORE_ERRORED,
1076 CRASH_WINDOW.as_secs()
1077 ),
1078 });
1079 return;
1080 }
1081
1082 let _ = event_tx.send(NodeEvent::NodeRestarting { node_id, attempt });
1083
1084 tokio::time::sleep(backoff).await;
1085
1086 match spawn_node_from_config(&*config).await {
1088 Ok(new_child) => {
1089 let pid = match new_child.id() {
1090 Some(pid) => pid,
1091 None => {
1092 let _ = event_tx.send(NodeEvent::NodeErrored {
1094 node_id,
1095 message: "Restarted process exited before PID could be read"
1096 .to_string(),
1097 });
1098 let mut sup = supervisor.write().await;
1099 sup.update_state(node_id, NodeStatus::Errored, None);
1100 return;
1101 }
1102 };
1103 {
1104 let mut sup = supervisor.write().await;
1105 sup.update_state(node_id, NodeStatus::Running, Some(pid));
1106 }
1107 let _ = event_tx.send(NodeEvent::NodeStarted { node_id, pid });
1108 child = new_child;
1109 }
1110 Err(e) => {
1111 let _ = event_tx.send(NodeEvent::NodeErrored {
1112 node_id,
1113 message: format!("Failed to restart node: {e}"),
1114 });
1115 let mut sup = supervisor.write().await;
1116 sup.update_state(node_id, NodeStatus::Errored, None);
1117 return;
1118 }
1119 }
1120 }
1121}
1122
1123async fn respawn_upgraded_node(
1128 config: &mut NodeConfig,
1129 supervisor: &Arc<RwLock<Supervisor>>,
1130 registry: &Arc<RwLock<NodeRegistry>>,
1131 event_tx: &broadcast::Sender<NodeEvent>,
1132) -> Result<tokio::process::Child> {
1133 let node_id = config.id;
1134 let old_version = config.version.clone();
1135
1136 let new_child = spawn_node_from_config(config).await?;
1137 let pid = new_child
1138 .id()
1139 .ok_or_else(|| Error::ProcessSpawn("Failed to get PID after upgrade respawn".into()))?;
1140
1141 let new_version = extract_version(&config.binary_path).await.ok();
1144
1145 if let Some(ref version) = new_version {
1146 config.version = version.clone();
1147 let mut reg = registry.write().await;
1148 if let Ok(stored) = reg.get_mut(node_id) {
1149 stored.version = version.clone();
1150 let _ = reg.save();
1151 }
1152 }
1153
1154 {
1155 let mut sup = supervisor.write().await;
1156 if let Some(state) = sup.node_states.get_mut(&node_id) {
1157 state.status = NodeStatus::Running;
1158 state.pid = Some(pid);
1159 state.started_at = Some(Instant::now());
1160 state.restart_count = 0;
1161 state.first_crash_at = None;
1162 }
1163 }
1164
1165 let _ = event_tx.send(NodeEvent::NodeStarted { node_id, pid });
1166 if let Some(version) = new_version {
1167 let _ = event_tx.send(NodeEvent::NodeUpgraded {
1168 node_id,
1169 old_version,
1170 new_version: version,
1171 });
1172 }
1173
1174 Ok(new_child)
1175}
1176
1177const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
1179
1180async fn graceful_kill(pid: u32) {
1182 send_signal_term(pid);
1183
1184 let start = Instant::now();
1186 loop {
1187 if !is_process_alive(pid) {
1188 return;
1189 }
1190 if start.elapsed() >= GRACEFUL_SHUTDOWN_TIMEOUT {
1191 break;
1192 }
1193 tokio::time::sleep(Duration::from_millis(100)).await;
1194 }
1195
1196 send_signal_kill(pid);
1198
1199 for _ in 0..10 {
1201 if !is_process_alive(pid) {
1202 return;
1203 }
1204 tokio::time::sleep(Duration::from_millis(50)).await;
1205 }
1206}
1207
1208fn liveness_should_stop(
1221 snapshot_pid: u32,
1222 current_pid: Option<u32>,
1223 current_status: Option<NodeStatus>,
1224) -> bool {
1225 current_status == Some(NodeStatus::Running) && current_pid == Some(snapshot_pid)
1226}
1227
1228pub fn spawn_liveness_monitor(
1240 registry: Arc<RwLock<NodeRegistry>>,
1241 supervisor: Arc<RwLock<Supervisor>>,
1242 event_tx: broadcast::Sender<NodeEvent>,
1243 interval: Duration,
1244 shutdown: CancellationToken,
1245) {
1246 tokio::spawn(async move {
1247 let mut ticker = tokio::time::interval(interval);
1248 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
1252 loop {
1253 tokio::select! {
1254 _ = shutdown.cancelled() => return,
1255 _ = ticker.tick() => {}
1256 }
1257
1258 let candidates: Vec<(u32, u32, PathBuf)> =
1260 {
1261 let sup = supervisor.read().await;
1262 let reg = registry.read().await;
1263 reg.list()
1264 .into_iter()
1265 .filter_map(|config| {
1266 let pid = sup.node_pid(config.id)?;
1267 matches!(sup.node_status(config.id), Ok(NodeStatus::Running))
1268 .then_some((config.id, pid, config.data_dir.clone()))
1269 })
1270 .collect()
1271 };
1272
1273 for (node_id, pid, data_dir) in candidates {
1274 if is_process_alive(pid) {
1275 continue;
1276 }
1277
1278 if supervisor.read().await.is_adopted(node_id) {
1284 let config = {
1285 let reg = registry.read().await;
1286 reg.get(node_id).ok().cloned()
1287 };
1288 if let Some(mut config) = config {
1289 let drifted = matches!(
1290 extract_version(&config.binary_path).await,
1291 Ok(disk_version) if disk_version != config.version
1292 );
1293 if drifted {
1294 match respawn_upgraded_node(
1295 &mut config,
1296 &supervisor,
1297 ®istry,
1298 &event_tx,
1299 )
1300 .await
1301 {
1302 Ok(child) => {
1303 supervisor.write().await.mark_owned(node_id);
1306 let sup_ref = Arc::clone(&supervisor);
1307 let reg_ref = Arc::clone(®istry);
1308 let ev = event_tx.clone();
1309 tokio::spawn(async move {
1310 monitor_node(child, config, sup_ref, reg_ref, ev).await;
1311 });
1312 continue;
1313 }
1314 Err(e) => {
1315 let _ = event_tx.send(NodeEvent::NodeErrored {
1316 node_id,
1317 message: format!(
1318 "Failed to respawn adopted node after upgrade: {e}"
1319 ),
1320 });
1321 let mut sup = supervisor.write().await;
1322 sup.update_state(node_id, NodeStatus::Errored, None);
1323 sup.mark_owned(node_id);
1324 remove_node_pid(&data_dir);
1325 continue;
1326 }
1327 }
1328 }
1329 }
1330 }
1331
1332 let mut sup = supervisor.write().await;
1333 if !liveness_should_stop(pid, sup.node_pid(node_id), sup.node_status(node_id).ok())
1336 {
1337 continue;
1338 }
1339 sup.update_state(node_id, NodeStatus::Stopped, None);
1340 let _ = event_tx.send(NodeEvent::NodeStopped { node_id });
1341 remove_node_pid(&data_dir);
1342 }
1343 }
1344 });
1345}
1346
1347#[cfg(unix)]
1348fn pid_to_i32(pid: u32) -> Option<i32> {
1349 i32::try_from(pid).ok().filter(|&p| p > 0)
1350}
1351
1352#[cfg(unix)]
1353fn send_signal_term(pid: u32) {
1354 if let Some(pid) = pid_to_i32(pid) {
1355 unsafe {
1356 libc::kill(pid, libc::SIGTERM);
1357 }
1358 }
1359}
1360
1361#[cfg(unix)]
1362fn send_signal_kill(pid: u32) {
1363 if let Some(pid) = pid_to_i32(pid) {
1364 unsafe {
1365 libc::kill(pid, libc::SIGKILL);
1366 }
1367 }
1368}
1369
1370#[cfg(unix)]
1371fn is_process_alive(pid: u32) -> bool {
1372 let Some(pid) = pid_to_i32(pid) else {
1373 return false;
1374 };
1375 let ret = unsafe { libc::kill(pid, 0) };
1376 if ret == 0 {
1377 return true;
1378 }
1379 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1381}
1382
1383#[cfg(windows)]
1384fn send_signal_term(pid: u32) {
1385 use windows_sys::Win32::System::Console::{
1386 AttachConsole, FreeConsole, GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, CTRL_C_EVENT,
1387 };
1388
1389 unsafe {
1390 FreeConsole();
1393
1394 if AttachConsole(pid) != 0 {
1396 SetConsoleCtrlHandler(None, 1);
1399 GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0);
1400 FreeConsole();
1403 std::thread::sleep(std::time::Duration::from_millis(50));
1407 SetConsoleCtrlHandler(None, 0);
1410 }
1411 }
1412}
1413
1414#[cfg(windows)]
1415fn send_signal_kill(pid: u32) {
1416 use windows_sys::Win32::Foundation::CloseHandle;
1417 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
1418
1419 unsafe {
1420 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
1421 if !handle.is_null() {
1422 TerminateProcess(handle, 1);
1423 CloseHandle(handle);
1424 }
1425 }
1426}
1427
1428#[cfg(windows)]
1429fn is_process_alive(pid: u32) -> bool {
1430 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1431 use windows_sys::Win32::System::Threading::{
1432 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1433 };
1434
1435 unsafe {
1436 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1437 if handle.is_null() {
1438 return false;
1439 }
1440 let mut exit_code: u32 = 0;
1441 let success = GetExitCodeProcess(handle, &mut exit_code);
1442 CloseHandle(handle);
1443 success != 0 && exit_code == STILL_ACTIVE as u32
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 use crate::node::types::{EvmNetwork, UpgradeChannel};
1451
1452 #[tokio::test]
1453 async fn remove_dir_all_with_retry_deletes_tree_and_tolerates_missing() {
1454 let tmp = tempfile::tempdir().unwrap();
1455 let dir = tmp.path().join("node-data");
1456 std::fs::create_dir_all(dir.join("sub")).unwrap();
1457 std::fs::write(dir.join("sub").join("data.mdb"), vec![0u8; 128]).unwrap();
1458
1459 remove_dir_all_with_retry(&dir).await.unwrap();
1461 assert!(!dir.exists());
1462
1463 remove_dir_all_with_retry(&dir).await.unwrap();
1465 }
1466
1467 #[tokio::test]
1468 async fn start_node_rejects_a_node_being_evicted() {
1469 let (tx, _rx) = broadcast::channel(16);
1470 let sup = Arc::new(RwLock::new(Supervisor::new(tx)));
1471
1472 let tmp = tempfile::tempdir().unwrap();
1473 let reg = Arc::new(RwLock::new(
1474 NodeRegistry::load(&tmp.path().join("reg.json")).unwrap(),
1475 ));
1476
1477 let config = NodeConfig {
1478 id: 7,
1479 service_name: "node7".to_string(),
1480 rewards_address: "0xabc".to_string(),
1481 data_dir: tmp.path().join("node-7"),
1482 log_dir: None,
1483 node_port: None,
1484 binary_path: "/bin/node".into(),
1485 version: "0.1.0".to_string(),
1486 env_variables: HashMap::new(),
1487 bootstrap_peers: vec![],
1488 upgrade_channel: None,
1489 evm_network: EvmNetwork::default(),
1490 eviction: None,
1491 };
1492
1493 sup.write().await.begin_evicting(7);
1496 let res = sup
1497 .write()
1498 .await
1499 .start_node(&config, sup.clone(), reg.clone())
1500 .await;
1501 assert!(matches!(res, Err(Error::NodeEvicted(7))));
1502
1503 sup.write().await.finish_evicting(7);
1505 assert!(!sup.read().await.evicting.contains(&7));
1506 }
1507
1508 #[test]
1509 fn adopted_flag_lifecycle() {
1510 let (tx, _rx) = broadcast::channel(16);
1511 let mut sup = Supervisor::new(tx);
1512
1513 assert!(!sup.is_adopted(1));
1515
1516 sup.adopted.insert(1);
1518 assert!(sup.is_adopted(1));
1519
1520 sup.mark_owned(1);
1523 assert!(!sup.is_adopted(1));
1524 }
1525
1526 #[test]
1534 fn liveness_does_not_stop_node_respawned_under_it() {
1535 let dead_snapshot_pid = 1000; let live_respawned_pid = Some(2000); assert!(
1538 !liveness_should_stop(
1539 dead_snapshot_pid,
1540 live_respawned_pid,
1541 Some(NodeStatus::Running)
1542 ),
1543 "liveness must not stop a node whose PID changed under it (respawned with a live PID)"
1544 );
1545 }
1546
1547 #[test]
1548 fn build_node_args_basic() {
1549 let config = NodeConfig {
1550 id: 1,
1551 service_name: "node1".to_string(),
1552 rewards_address: "0xabc123".to_string(),
1553 data_dir: "/data/node-1".into(),
1554 log_dir: Some("/logs/node-1".into()),
1555 node_port: Some(12000),
1556 binary_path: "/bin/node".into(),
1557 version: "0.1.0".to_string(),
1558 env_variables: HashMap::new(),
1559 bootstrap_peers: vec!["peer1".to_string(), "peer2".to_string()],
1560 upgrade_channel: None,
1561 evm_network: EvmNetwork::default(),
1562 eviction: None,
1563 };
1564
1565 let args = build_node_args(&config);
1566
1567 assert!(args.contains(&"--rewards-address".to_string()));
1568 assert!(args.contains(&"0xabc123".to_string()));
1569 assert!(args.contains(&"--root-dir".to_string()));
1570 assert!(args.contains(&"/data/node-1".to_string()));
1571 assert!(args.contains(&"--enable-logging".to_string()));
1572 assert!(args.contains(&"--log-dir".to_string()));
1573 assert!(args.contains(&"/logs/node-1".to_string()));
1574 assert!(args.contains(&"--port".to_string()));
1575 assert!(args.contains(&"12000".to_string()));
1576 assert!(args.contains(&"--bootstrap".to_string()));
1577 assert!(args.contains(&"peer1".to_string()));
1578 assert!(args.contains(&"peer2".to_string()));
1579 assert!(args.contains(&"--stop-on-upgrade".to_string()));
1580 assert!(!args.contains(&"--upgrade-channel".to_string()));
1582 assert_eq!(evm_network_arg(&args), Some("arbitrum-one"));
1584 }
1585
1586 fn evm_network_arg(args: &[String]) -> Option<&str> {
1588 let idx = args.iter().position(|a| a == "--evm-network")?;
1589 args.get(idx + 1).map(String::as_str)
1590 }
1591
1592 #[test]
1593 fn build_node_args_emits_evm_network_flag() {
1594 let mut config = NodeConfig {
1595 id: 1,
1596 service_name: "node1".to_string(),
1597 rewards_address: "0xabc".to_string(),
1598 data_dir: "/data/node-1".into(),
1599 log_dir: None,
1600 node_port: None,
1601 binary_path: "/bin/node".into(),
1602 version: "0.1.0".to_string(),
1603 env_variables: HashMap::new(),
1604 bootstrap_peers: vec![],
1605 upgrade_channel: None,
1606 evm_network: EvmNetwork::ArbitrumSepolia,
1607 eviction: None,
1608 };
1609
1610 let args = build_node_args(&config);
1611 assert_eq!(evm_network_arg(&args), Some("arbitrum-sepolia"));
1612
1613 config.evm_network = EvmNetwork::ArbitrumOne;
1614 let args = build_node_args(&config);
1615 assert_eq!(evm_network_arg(&args), Some("arbitrum-one"));
1616 }
1617
1618 #[test]
1619 fn build_node_args_includes_upgrade_channel() {
1620 let mut config = NodeConfig {
1621 id: 1,
1622 service_name: "node1".to_string(),
1623 rewards_address: "0xabc".to_string(),
1624 data_dir: "/data/node-1".into(),
1625 log_dir: None,
1626 node_port: None,
1627 binary_path: "/bin/node".into(),
1628 version: "0.1.0".to_string(),
1629 env_variables: HashMap::new(),
1630 bootstrap_peers: vec![],
1631 upgrade_channel: Some(UpgradeChannel::Beta),
1632 evm_network: EvmNetwork::default(),
1633 eviction: None,
1634 };
1635
1636 let args = build_node_args(&config);
1637 let idx = args
1638 .iter()
1639 .position(|a| a == "--upgrade-channel")
1640 .expect("--upgrade-channel should be present");
1641 assert_eq!(args[idx + 1], "beta");
1642
1643 config.upgrade_channel = Some(UpgradeChannel::Stable);
1644 let args = build_node_args(&config);
1645 let idx = args.iter().position(|a| a == "--upgrade-channel").unwrap();
1646 assert_eq!(args[idx + 1], "stable");
1647 }
1648
1649 #[test]
1650 fn build_node_args_minimal() {
1651 let config = NodeConfig {
1652 id: 1,
1653 service_name: "node1".to_string(),
1654 rewards_address: "0xabc".to_string(),
1655 data_dir: "/data/node-1".into(),
1656 log_dir: None,
1657 node_port: None,
1658 binary_path: "/bin/node".into(),
1659 version: "0.1.0".to_string(),
1660 env_variables: HashMap::new(),
1661 bootstrap_peers: vec![],
1662 upgrade_channel: None,
1663 evm_network: EvmNetwork::default(),
1664 eviction: None,
1665 };
1666
1667 let args = build_node_args(&config);
1668
1669 assert!(args.contains(&"--rewards-address".to_string()));
1670 assert!(args.contains(&"--root-dir".to_string()));
1671 assert!(!args.contains(&"--enable-logging".to_string()));
1672 assert!(!args.contains(&"--log-dir".to_string()));
1673 assert!(!args.contains(&"--port".to_string()));
1674 assert!(!args.contains(&"--bootstrap".to_string()));
1675 assert!(args.contains(&"--stop-on-upgrade".to_string()));
1676 }
1677
1678 #[test]
1679 fn record_crash_backoff_increases() {
1680 let (tx, _rx) = broadcast::channel(16);
1681 let mut sup = Supervisor::new(tx);
1682
1683 sup.node_states.insert(
1685 1,
1686 NodeRuntime {
1687 status: NodeStatus::Running,
1688 pid: Some(100),
1689 started_at: Some(Instant::now()),
1690 restart_count: 0,
1691 first_crash_at: None,
1692 },
1693 );
1694
1695 let (should_restart, attempt, backoff) = sup.record_crash(1);
1696 assert!(should_restart);
1697 assert_eq!(attempt, 1);
1698 assert_eq!(backoff, Duration::from_secs(1));
1699
1700 let (should_restart, attempt, backoff) = sup.record_crash(1);
1701 assert!(should_restart);
1702 assert_eq!(attempt, 2);
1703 assert_eq!(backoff, Duration::from_secs(2));
1704
1705 let (should_restart, attempt, backoff) = sup.record_crash(1);
1706 assert!(should_restart);
1707 assert_eq!(attempt, 3);
1708 assert_eq!(backoff, Duration::from_secs(4));
1709
1710 let (should_restart, attempt, backoff) = sup.record_crash(1);
1711 assert!(should_restart);
1712 assert_eq!(attempt, 4);
1713 assert_eq!(backoff, Duration::from_secs(8));
1714
1715 let (should_restart, attempt, _) = sup.record_crash(1);
1717 assert!(!should_restart);
1718 assert_eq!(attempt, 5);
1719 assert_eq!(sup.node_states[&1].status, NodeStatus::Errored);
1720 }
1721
1722 #[test]
1723 fn node_counts_tracks_states() {
1724 let (tx, _rx) = broadcast::channel(16);
1725 let mut sup = Supervisor::new(tx);
1726
1727 sup.node_states.insert(
1728 1,
1729 NodeRuntime {
1730 status: NodeStatus::Running,
1731 pid: Some(100),
1732 started_at: Some(Instant::now()),
1733 restart_count: 0,
1734 first_crash_at: None,
1735 },
1736 );
1737 sup.node_states.insert(
1738 2,
1739 NodeRuntime {
1740 status: NodeStatus::Stopped,
1741 pid: None,
1742 started_at: None,
1743 restart_count: 0,
1744 first_crash_at: None,
1745 },
1746 );
1747 sup.node_states.insert(
1748 3,
1749 NodeRuntime {
1750 status: NodeStatus::Errored,
1751 pid: None,
1752 started_at: None,
1753 restart_count: 5,
1754 first_crash_at: None,
1755 },
1756 );
1757
1758 let (running, stopped, errored) = sup.node_counts();
1759 assert_eq!(running, 1);
1760 assert_eq!(stopped, 1);
1761 assert_eq!(errored, 1);
1762 }
1763
1764 #[test]
1765 fn upgrade_restart_exit_code_covers_unix_and_windows() {
1766 assert!(is_upgrade_restart_exit_code(Some(0)));
1770 assert!(is_upgrade_restart_exit_code(Some(RESTART_EXIT_CODE)));
1771 assert!(!is_upgrade_restart_exit_code(Some(1)));
1772 assert!(!is_upgrade_restart_exit_code(Some(101)));
1773 assert!(!is_upgrade_restart_exit_code(None));
1774 }
1775
1776 #[tokio::test]
1777 async fn stop_node_not_found() {
1778 let (tx, _rx) = broadcast::channel(16);
1779 let mut sup = Supervisor::new(tx);
1780
1781 let result = sup.stop_node(999).await;
1782 assert!(matches!(result, Err(Error::NodeNotFound(999))));
1783 }
1784
1785 #[tokio::test]
1786 async fn stop_node_not_running() {
1787 let (tx, _rx) = broadcast::channel(16);
1788 let mut sup = Supervisor::new(tx);
1789
1790 sup.node_states.insert(
1791 1,
1792 NodeRuntime {
1793 status: NodeStatus::Stopped,
1794 pid: None,
1795 started_at: None,
1796 restart_count: 0,
1797 first_crash_at: None,
1798 },
1799 );
1800
1801 let result = sup.stop_node(1).await;
1802 assert!(matches!(result, Err(Error::NodeNotRunning(1))));
1803 }
1804
1805 #[tokio::test]
1806 async fn stop_all_nodes_mixed_states() {
1807 let (tx, _rx) = broadcast::channel(16);
1808 let mut sup = Supervisor::new(tx);
1809
1810 sup.node_states.insert(
1812 1,
1813 NodeRuntime {
1814 status: NodeStatus::Running,
1815 pid: Some(999999),
1816 started_at: Some(Instant::now()),
1817 restart_count: 0,
1818 first_crash_at: None,
1819 },
1820 );
1821 sup.node_states.insert(
1823 2,
1824 NodeRuntime {
1825 status: NodeStatus::Stopped,
1826 pid: None,
1827 started_at: None,
1828 restart_count: 0,
1829 first_crash_at: None,
1830 },
1831 );
1832
1833 let configs = vec![(1, "node1".to_string()), (2, "node2".to_string())];
1834
1835 let result = sup.stop_all_nodes(&configs).await;
1836
1837 assert_eq!(result.stopped.len(), 1);
1838 assert_eq!(result.stopped[0].node_id, 1);
1839 assert_eq!(result.stopped[0].service_name, "node1");
1840 assert_eq!(result.already_stopped, vec![2]);
1841 assert!(result.failed.is_empty());
1842 }
1843}