Skip to main content

mecha10_runtime/
process.rs

1//! External process management for node lifecycle
2//!
3//! This module provides process management for external node processes (spawned as child processes),
4//! complementing the Supervisor which manages in-process NodeRunner implementations.
5
6use crate::shutdown::ShutdownHandle;
7use anyhow::Result;
8use std::collections::HashMap;
9use std::process::Child;
10use std::time::Duration;
11use tracing::{debug, error, info, warn};
12
13/// Information about a tracked process
14#[derive(Debug, Clone)]
15pub struct ProcessInfo {
16    /// Process name
17    pub name: String,
18    /// Process ID
19    pub pid: u32,
20    /// Current status
21    pub status: ProcessStatus,
22}
23
24/// Process status
25#[derive(Debug, Clone, PartialEq)]
26pub enum ProcessStatus {
27    /// Process is running
28    Running,
29    /// Process exited with code
30    Exited(i32),
31    /// Process encountered an error
32    Error,
33}
34
35/// Manages external child processes with graceful shutdown and dependency ordering
36///
37/// ProcessManager tracks and manages child processes spawned as external binaries.
38/// It provides:
39/// - Process tracking with PIDs
40/// - Dependency-based shutdown ordering
41/// - Graceful shutdown with configurable timeout
42/// - Force kill fallback
43/// - Integration with runtime's ShutdownHandle
44pub struct ProcessManager {
45    /// Tracked child processes
46    processes: HashMap<String, Child>,
47    /// Dependency graph: node -> dependencies
48    dependency_graph: HashMap<String, Vec<String>>,
49    /// Shutdown handle for coordinating graceful shutdown
50    shutdown_handle: Option<ShutdownHandle>,
51    /// Default shutdown timeout
52    shutdown_timeout: Duration,
53}
54
55impl ProcessManager {
56    /// Create a new process manager
57    pub fn new() -> Self {
58        Self {
59            processes: HashMap::new(),
60            dependency_graph: HashMap::new(),
61            shutdown_handle: None,
62            shutdown_timeout: Duration::from_secs(10),
63        }
64    }
65
66    /// Create a new process manager with shutdown handle
67    pub fn with_shutdown_handle(shutdown_handle: ShutdownHandle) -> Self {
68        Self {
69            processes: HashMap::new(),
70            dependency_graph: HashMap::new(),
71            shutdown_handle: Some(shutdown_handle),
72            shutdown_timeout: Duration::from_secs(10),
73        }
74    }
75
76    /// Add a process to track
77    ///
78    /// # Arguments
79    ///
80    /// * `name` - Process name/identifier
81    /// * `child` - Child process handle
82    pub fn track(&mut self, name: String, child: Child) {
83        let pid = child.id();
84        debug!("Tracking process: {} (PID: {})", name, pid);
85        self.processes.insert(name, child);
86    }
87
88    /// Remove and return a process by name
89    pub fn remove(&mut self, name: &str) -> Option<Child> {
90        self.processes.remove(name)
91    }
92
93    /// Add a dependency relationship
94    ///
95    /// Specifies that `node` depends on `dependency` and should be stopped before it.
96    ///
97    /// # Arguments
98    ///
99    /// * `node` - The node that depends on another
100    /// * `dependency` - The node that is depended upon
101    pub fn add_dependency(&mut self, node: String, dependency: String) {
102        self.dependency_graph.entry(node).or_default().push(dependency);
103    }
104
105    /// Get list of all tracked process names
106    pub fn list(&self) -> Vec<String> {
107        self.processes.keys().cloned().collect()
108    }
109
110    /// Get the number of tracked processes
111    pub fn len(&self) -> usize {
112        self.processes.len()
113    }
114
115    /// Check if any processes are being tracked
116    pub fn is_empty(&self) -> bool {
117        self.processes.is_empty()
118    }
119
120    /// Get status of all processes
121    pub fn status_all(&mut self) -> HashMap<String, ProcessStatus> {
122        let mut status_map = HashMap::new();
123        let mut to_remove = Vec::new();
124
125        for (name, child) in self.processes.iter_mut() {
126            let status = match child.try_wait() {
127                Ok(Some(exit_status)) => {
128                    to_remove.push(name.clone());
129                    ProcessStatus::Exited(exit_status.code().unwrap_or(-1))
130                }
131                Ok(None) => ProcessStatus::Running,
132                Err(_) => {
133                    to_remove.push(name.clone());
134                    ProcessStatus::Error
135                }
136            };
137            status_map.insert(name.clone(), status);
138        }
139
140        // Remove exited/errored processes
141        for name in to_remove {
142            self.processes.remove(&name);
143        }
144
145        status_map
146    }
147
148    /// Get information about all tracked processes
149    pub fn processes(&mut self) -> Vec<ProcessInfo> {
150        let mut processes = Vec::new();
151        let mut to_remove = Vec::new();
152
153        for (name, child) in self.processes.iter_mut() {
154            let pid = child.id();
155            let status = match child.try_wait() {
156                Ok(Some(exit_status)) => {
157                    to_remove.push(name.clone());
158                    ProcessStatus::Exited(exit_status.code().unwrap_or(-1))
159                }
160                Ok(None) => ProcessStatus::Running,
161                Err(_) => {
162                    to_remove.push(name.clone());
163                    ProcessStatus::Error
164                }
165            };
166
167            processes.push(ProcessInfo {
168                name: name.clone(),
169                pid,
170                status,
171            });
172        }
173
174        // Remove exited/errored processes
175        for name in to_remove {
176            self.processes.remove(&name);
177        }
178
179        processes
180    }
181
182    /// Calculate shutdown order based on dependency graph
183    ///
184    /// Returns nodes in the order they should be stopped (dependents before dependencies).
185    pub fn shutdown_order(&self) -> Vec<String> {
186        let mut order = Vec::new();
187        let mut visited = std::collections::HashSet::new();
188
189        // Build reverse graph (dependents)
190        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
191        for (node, deps) in &self.dependency_graph {
192            for dep in deps {
193                dependents.entry(dep.clone()).or_default().push(node.clone());
194            }
195        }
196
197        // Get all tracked nodes
198        let all_nodes: Vec<String> = self.list();
199
200        // Visit each node (prioritize nodes with dependents)
201        for node in &all_nodes {
202            if !visited.contains(node) {
203                visit_for_shutdown(node, &dependents, &mut visited, &mut order);
204            }
205        }
206
207        order
208    }
209
210    /// Stop a specific process gracefully with timeout
211    ///
212    /// # Arguments
213    ///
214    /// * `name` - Name of the process to stop
215    /// * `timeout` - How long to wait for graceful shutdown before force killing
216    ///
217    /// # Returns
218    ///
219    /// Ok(()) if stopped successfully (graceful or force), Err if not found or error
220    pub fn stop_graceful(&mut self, name: &str, timeout: Duration) -> Result<()> {
221        use std::thread;
222        use std::time::Instant;
223
224        if let Some(mut child) = self.remove(name) {
225            let pid = child.id();
226            debug!("Stopping process: {} (PID: {})", name, pid);
227
228            // Try graceful shutdown (SIGTERM on Unix)
229            #[cfg(unix)]
230            {
231                use std::process::Command;
232                // Send SIGTERM to the process group (negative PID kills the group)
233                // This ensures we kill the node-runner AND all its child nodes
234                let _ = Command::new("kill").arg("--").arg(format!("-{}", pid)).output();
235
236                // Also send to the process itself as fallback
237                let _ = Command::new("kill").arg(pid.to_string()).output();
238            }
239
240            #[cfg(not(unix))]
241            {
242                // Windows: terminate immediately
243                child.kill()?;
244            }
245
246            // Wait with timeout
247            let start = Instant::now();
248            loop {
249                match child.try_wait() {
250                    Ok(Some(_)) => {
251                        // Process exited gracefully
252                        return Ok(());
253                    }
254                    Ok(None) => {
255                        // Still running
256                        if start.elapsed() >= timeout {
257                            // Timeout - force kill the process group
258                            warn!("Process {} did not stop within {:?}, force killing", name, timeout);
259
260                            #[cfg(unix)]
261                            {
262                                use std::process::Command;
263                                // Force kill the entire process group
264                                let _ = Command::new("kill")
265                                    .arg("-9")
266                                    .arg("--")
267                                    .arg(format!("-{}", pid))
268                                    .output();
269                            }
270
271                            child.kill()?;
272                            let _ = child.wait();
273                            return Ok(());
274                        }
275                        thread::sleep(Duration::from_millis(100));
276                    }
277                    Err(e) => {
278                        // Error checking status - try to kill anyway
279                        error!("Error checking process {} status: {}", name, e);
280
281                        #[cfg(unix)]
282                        {
283                            use std::process::Command;
284                            let _ = Command::new("kill")
285                                .arg("-9")
286                                .arg("--")
287                                .arg(format!("-{}", pid))
288                                .output();
289                        }
290
291                        child.kill()?;
292                        let _ = child.wait();
293                        return Ok(());
294                    }
295                }
296            }
297        } else {
298            Err(anyhow::anyhow!("Process '{}' not found", name))
299        }
300    }
301
302    /// Force kill a process immediately
303    pub fn force_kill(&mut self, name: &str) -> Result<()> {
304        if let Some(mut child) = self.remove(name) {
305            let pid = child.id();
306
307            #[cfg(unix)]
308            {
309                use std::process::Command;
310                // Force kill the entire process group
311                let _ = Command::new("kill")
312                    .arg("-9")
313                    .arg("--")
314                    .arg(format!("-{}", pid))
315                    .output();
316            }
317
318            child.kill()?;
319            let _ = child.wait();
320            info!("Process {} force killed", name);
321            Ok(())
322        } else {
323            Err(anyhow::anyhow!("Process '{}' not found", name))
324        }
325    }
326
327    /// Stop all processes in dependency order with graceful shutdown
328    pub fn shutdown_all(&mut self) {
329        if self.is_empty() {
330            debug!("No processes to shut down");
331            return;
332        }
333
334        info!("Shutting down all processes in dependency order");
335
336        // Trigger shutdown signal if available
337        if let Some(ref shutdown_handle) = self.shutdown_handle {
338            shutdown_handle.shutdown();
339        }
340
341        let shutdown_order = self.shutdown_order();
342
343        for node in shutdown_order {
344            info!("Stopping {} with {:?} timeout", node, self.shutdown_timeout);
345
346            if let Err(e) = self.stop_graceful(&node, self.shutdown_timeout) {
347                warn!("Failed to stop {}: {}, attempting force kill", node, e);
348                if let Err(kill_err) = self.force_kill(&node) {
349                    error!("Failed to force kill {}: {}", node, kill_err);
350                }
351            }
352        }
353
354        info!("All processes stopped");
355    }
356
357    /// Check if a specific process is running
358    pub fn is_running(&mut self, name: &str) -> bool {
359        if let Some(child) = self.processes.get_mut(name) {
360            matches!(child.try_wait(), Ok(None))
361        } else {
362            false
363        }
364    }
365}
366
367impl Default for ProcessManager {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373impl Drop for ProcessManager {
374    fn drop(&mut self) {
375        // Ensure all processes are cleaned up
376        self.shutdown_all();
377    }
378}
379
380/// Recursively visit nodes for shutdown ordering
381///
382/// Helper function for topological sort (DFS-based)
383fn visit_for_shutdown(
384    node: &str,
385    dependents: &HashMap<String, Vec<String>>,
386    visited: &mut std::collections::HashSet<String>,
387    order: &mut Vec<String>,
388) {
389    visited.insert(node.to_string());
390
391    // Visit dependents first (they must be stopped before this node)
392    if let Some(deps) = dependents.get(node) {
393        for dep in deps {
394            if !visited.contains(dep) {
395                visit_for_shutdown(dep, dependents, visited, order);
396            }
397        }
398    }
399
400    // Add this node to the order
401    order.push(node.to_string());
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_dependency_ordering() {
410        let mut pm = ProcessManager::new();
411
412        // Setup: planner -> motor, camera -> planner
413        pm.add_dependency("planner".to_string(), "motor".to_string());
414        pm.add_dependency("camera".to_string(), "planner".to_string());
415
416        // Simulate tracking processes
417        pm.processes.insert("motor".to_string(), mock_child());
418        pm.processes.insert("planner".to_string(), mock_child());
419        pm.processes.insert("camera".to_string(), mock_child());
420
421        let order = pm.shutdown_order();
422
423        // Camera should stop before planner, planner before motor
424        let camera_idx = order.iter().position(|n| n == "camera").unwrap();
425        let planner_idx = order.iter().position(|n| n == "planner").unwrap();
426        let motor_idx = order.iter().position(|n| n == "motor").unwrap();
427
428        assert!(camera_idx < planner_idx);
429        assert!(planner_idx < motor_idx);
430    }
431
432    #[cfg(unix)]
433    fn mock_child() -> Child {
434        use std::process::{Command, Stdio};
435        Command::new("sleep")
436            .arg("1000")
437            .stdin(Stdio::null())
438            .stdout(Stdio::null())
439            .stderr(Stdio::null())
440            .spawn()
441            .unwrap()
442    }
443
444    #[cfg(not(unix))]
445    fn mock_child() -> Child {
446        use std::process::{Command, Stdio};
447        Command::new("timeout")
448            .arg("/t")
449            .arg("1000")
450            .stdin(Stdio::null())
451            .stdout(Stdio::null())
452            .stderr(Stdio::null())
453            .spawn()
454            .unwrap()
455    }
456}