1use 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#[derive(Debug, Clone)]
15pub struct ProcessInfo {
16 pub name: String,
18 pub pid: u32,
20 pub status: ProcessStatus,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum ProcessStatus {
27 Running,
29 Exited(i32),
31 Error,
33}
34
35pub struct ProcessManager {
45 processes: HashMap<String, Child>,
47 dependency_graph: HashMap<String, Vec<String>>,
49 shutdown_handle: Option<ShutdownHandle>,
51 shutdown_timeout: Duration,
53}
54
55impl ProcessManager {
56 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 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 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 pub fn remove(&mut self, name: &str) -> Option<Child> {
90 self.processes.remove(name)
91 }
92
93 pub fn add_dependency(&mut self, node: String, dependency: String) {
102 self.dependency_graph.entry(node).or_default().push(dependency);
103 }
104
105 pub fn list(&self) -> Vec<String> {
107 self.processes.keys().cloned().collect()
108 }
109
110 pub fn len(&self) -> usize {
112 self.processes.len()
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.processes.is_empty()
118 }
119
120 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 for name in to_remove {
142 self.processes.remove(&name);
143 }
144
145 status_map
146 }
147
148 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 for name in to_remove {
176 self.processes.remove(&name);
177 }
178
179 processes
180 }
181
182 pub fn shutdown_order(&self) -> Vec<String> {
186 let mut order = Vec::new();
187 let mut visited = std::collections::HashSet::new();
188
189 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 let all_nodes: Vec<String> = self.list();
199
200 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 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 #[cfg(unix)]
230 {
231 use std::process::Command;
232 let _ = Command::new("kill").arg("--").arg(format!("-{}", pid)).output();
235
236 let _ = Command::new("kill").arg(pid.to_string()).output();
238 }
239
240 #[cfg(not(unix))]
241 {
242 child.kill()?;
244 }
245
246 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 }
265 Err(e) => {
266 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 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 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 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 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 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 self.shutdown_all();
384 }
385}
386
387#[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 Err(_) => true,
405 }
406}
407
408#[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
429fn 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 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 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 pm.add_dependency("planner".to_string(), "motor".to_string());
463 pm.add_dependency("camera".to_string(), "planner".to_string());
464
465 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 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 #[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 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 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}