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. Once the directly-tracked child (e.g. node-runner) has
247            // exited, `child.try_wait()` will keep reporting `Ok(Some(_))` since the child is
248            // already reaped - so from that point on we can't use it to tell whether we're
249            // still waiting on anything. Instead, once the tracked child has exited, switch to
250            // polling whether the whole process *group* is empty: node-runner may exit near-
251            // instantly while a grandchild it was supervising (e.g. simulator's Python MuJoCo
252            // subprocess) is still alive in the same group with nothing left to signal it.
253            let start = Instant::now();
254            let mut tracked_child_exited = false;
255
256            loop {
257                if !tracked_child_exited {
258                    match child.try_wait() {
259                        Ok(Some(_)) => {
260                            tracked_child_exited = true;
261                        }
262                        Ok(None) => {
263                            // Still running.
264                        }
265                        Err(e) => {
266                            // Error checking status - try to kill anyway
267                            error!("Error checking process {} status: {}", name, e);
268                            return kill_group_and_reap(pid, &mut child);
269                        }
270                    }
271                }
272
273                let done = if tracked_child_exited {
274                    #[cfg(unix)]
275                    {
276                        process_group_is_empty(pid)
277                    }
278                    #[cfg(not(unix))]
279                    {
280                        true
281                    }
282                } else {
283                    false
284                };
285
286                if done {
287                    return Ok(());
288                }
289
290                if start.elapsed() >= timeout {
291                    if tracked_child_exited {
292                        warn!(
293                            "Process {} exited but its process group did not stop within {:?}, force killing",
294                            name, timeout
295                        );
296                    } else {
297                        warn!("Process {} did not stop within {:?}, force killing", name, timeout);
298                    }
299                    return kill_group_and_reap(pid, &mut child);
300                }
301
302                thread::sleep(Duration::from_millis(100));
303            }
304        } else {
305            Err(anyhow::anyhow!("Process '{}' not found", name))
306        }
307    }
308
309    /// Force kill a process immediately
310    pub fn force_kill(&mut self, name: &str) -> Result<()> {
311        if let Some(mut child) = self.remove(name) {
312            let pid = child.id();
313
314            #[cfg(unix)]
315            {
316                use std::process::Command;
317                // Force kill the entire process group
318                let _ = Command::new("kill")
319                    .arg("-9")
320                    .arg("--")
321                    .arg(format!("-{}", pid))
322                    .output();
323            }
324
325            child.kill()?;
326            let _ = child.wait();
327            info!("Process {} force killed", name);
328            Ok(())
329        } else {
330            Err(anyhow::anyhow!("Process '{}' not found", name))
331        }
332    }
333
334    /// Stop all processes in dependency order with graceful shutdown
335    pub fn shutdown_all(&mut self) {
336        if self.is_empty() {
337            debug!("No processes to shut down");
338            return;
339        }
340
341        info!("Shutting down all processes in dependency order");
342
343        // Trigger shutdown signal if available
344        if let Some(ref shutdown_handle) = self.shutdown_handle {
345            shutdown_handle.shutdown();
346        }
347
348        let shutdown_order = self.shutdown_order();
349
350        for node in shutdown_order {
351            info!("Stopping {} with {:?} timeout", node, self.shutdown_timeout);
352
353            if let Err(e) = self.stop_graceful(&node, self.shutdown_timeout) {
354                warn!("Failed to stop {}: {}, attempting force kill", node, e);
355                if let Err(kill_err) = self.force_kill(&node) {
356                    error!("Failed to force kill {}: {}", node, kill_err);
357                }
358            }
359        }
360
361        info!("All processes stopped");
362    }
363
364    /// Check if a specific process is running
365    pub fn is_running(&mut self, name: &str) -> bool {
366        if let Some(child) = self.processes.get_mut(name) {
367            matches!(child.try_wait(), Ok(None))
368        } else {
369            false
370        }
371    }
372}
373
374impl Default for ProcessManager {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380impl Drop for ProcessManager {
381    fn drop(&mut self) {
382        // Ensure all processes are cleaned up
383        self.shutdown_all();
384    }
385}
386
387/// Check whether a process group is empty (no processes remain in it).
388///
389/// Sends signal 0 to the process group (`kill -0 -- -{pid}`), which performs no actual
390/// signaling but reports via its exit status whether any process in that group still exists.
391/// A successful exit status means at least one process in the group is still alive; any
392/// failure (notably ESRCH - "no such process") means the group is empty.
393#[cfg(unix)]
394fn process_group_is_empty(pid: u32) -> bool {
395    use std::process::Command;
396    match Command::new("kill")
397        .arg("-0")
398        .arg("--")
399        .arg(format!("-{}", pid))
400        .output()
401    {
402        Ok(output) => !output.status.success(),
403        // If we can't even run `kill` to check, don't block shutdown on it - treat as empty.
404        Err(_) => true,
405    }
406}
407
408/// Force kill the process group and reap the directly-tracked child, returning `Ok(())`.
409///
410/// Used by `stop_graceful`'s timeout/error paths - mirrors `force_kill`'s unconditional
411/// "kill -9 the group, then kill+wait the tracked child" behavior.
412#[allow(unused_variables)]
413fn kill_group_and_reap(pid: u32, child: &mut Child) -> Result<()> {
414    #[cfg(unix)]
415    {
416        use std::process::Command;
417        let _ = Command::new("kill")
418            .arg("-9")
419            .arg("--")
420            .arg(format!("-{}", pid))
421            .output();
422    }
423
424    child.kill()?;
425    let _ = child.wait();
426    Ok(())
427}
428
429/// Recursively visit nodes for shutdown ordering
430///
431/// Helper function for topological sort (DFS-based)
432fn visit_for_shutdown(
433    node: &str,
434    dependents: &HashMap<String, Vec<String>>,
435    visited: &mut std::collections::HashSet<String>,
436    order: &mut Vec<String>,
437) {
438    visited.insert(node.to_string());
439
440    // Visit dependents first (they must be stopped before this node)
441    if let Some(deps) = dependents.get(node) {
442        for dep in deps {
443            if !visited.contains(dep) {
444                visit_for_shutdown(dep, dependents, visited, order);
445            }
446        }
447    }
448
449    // Add this node to the order
450    order.push(node.to_string());
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_dependency_ordering() {
459        let mut pm = ProcessManager::new();
460
461        // Setup: planner -> motor, camera -> planner
462        pm.add_dependency("planner".to_string(), "motor".to_string());
463        pm.add_dependency("camera".to_string(), "planner".to_string());
464
465        // Simulate tracking processes
466        pm.processes.insert("motor".to_string(), mock_child());
467        pm.processes.insert("planner".to_string(), mock_child());
468        pm.processes.insert("camera".to_string(), mock_child());
469
470        let order = pm.shutdown_order();
471
472        // Camera should stop before planner, planner before motor
473        let camera_idx = order.iter().position(|n| n == "camera").unwrap();
474        let planner_idx = order.iter().position(|n| n == "planner").unwrap();
475        let motor_idx = order.iter().position(|n| n == "motor").unwrap();
476
477        assert!(camera_idx < planner_idx);
478        assert!(planner_idx < motor_idx);
479    }
480
481    #[cfg(unix)]
482    fn mock_child() -> Child {
483        use std::process::{Command, Stdio};
484        Command::new("sleep")
485            .arg("1000")
486            .stdin(Stdio::null())
487            .stdout(Stdio::null())
488            .stderr(Stdio::null())
489            .spawn()
490            .unwrap()
491    }
492
493    #[cfg(not(unix))]
494    fn mock_child() -> Child {
495        use std::process::{Command, Stdio};
496        Command::new("timeout")
497            .arg("/t")
498            .arg("1000")
499            .stdin(Stdio::null())
500            .stdout(Stdio::null())
501            .stderr(Stdio::null())
502            .spawn()
503            .unwrap()
504    }
505
506    /// Regression test for the bug where `stop_graceful` declared success the instant the
507    /// directly-tracked process exited, without checking whether the rest of its process
508    /// group (e.g. a supervised grandchild subprocess, like simulator's Python MuJoCo
509    /// process) was actually gone yet.
510    ///
511    /// Spawns a "parent" process as the leader of its own process group (mirroring how
512    /// node-runner is spawned with `process_group(0)`), which itself forks a background
513    /// child that ignores SIGTERM and stays alive for a bit before exiting on its own -
514    /// simulating a supervised subprocess that's slower to shut down than its supervisor.
515    /// `stop_graceful` must not return until *both* have exited, not just the tracked parent.
516    #[cfg(unix)]
517    #[test]
518    fn stop_graceful_waits_for_entire_process_group() {
519        use std::os::unix::process::CommandExt;
520        use std::process::{Command, Stdio};
521        use std::time::Instant;
522
523        let mut pm = ProcessManager::new();
524
525        // The background grandchild traps SIGTERM (ignores it) and sleeps for ~300ms before
526        // exiting on its own - standing in for a subprocess whose own shutdown (e.g. Redis
527        // disconnect, GL context teardown) takes longer than its supervisor's exit. The
528        // parent shell backgrounds it and then exits immediately, so the tracked process
529        // (the parent) exits almost instantly while the grandchild lingers in the same group.
530        // The outer shell also ignores SIGTERM so that it reliably forks the background
531        // grandchild and exits normally, regardless of exactly when `stop_graceful` delivers
532        // the signal (avoiding a race where SIGTERM kills the outer shell before it gets a
533        // chance to spawn the grandchild at all).
534        let child = Command::new("sh")
535            .arg("-c")
536            .arg(r#"trap "" TERM; sh -c 'trap "" TERM; sleep 0.3' & exit 0"#)
537            .process_group(0)
538            .stdin(Stdio::null())
539            .stdout(Stdio::null())
540            .stderr(Stdio::null())
541            .spawn()
542            .expect("failed to spawn test process group leader");
543
544        pm.track("grandchild-test".to_string(), child);
545
546        let start = Instant::now();
547        let result = pm.stop_graceful("grandchild-test", Duration::from_secs(5));
548        let elapsed = start.elapsed();
549
550        assert!(result.is_ok(), "stop_graceful should succeed: {:?}", result);
551        assert!(
552            elapsed >= Duration::from_millis(250),
553            "stop_graceful returned after {:?}, before the grandchild (~300ms) could have \
554             exited - it must have declared success as soon as the tracked parent exited \
555             without checking the rest of the process group",
556            elapsed
557        );
558        // Should still be comfortably under the 5s timeout - i.e. it noticed the group
559        // emptied on its own rather than waiting for a force-kill timeout.
560        assert!(
561            elapsed < Duration::from_secs(2),
562            "stop_graceful took {:?}, suggesting it fell through to the timeout/force-kill \
563             path instead of detecting the group became empty",
564            elapsed
565        );
566    }
567}