1use std::cell::RefCell;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37pub const SUBPROCESS_TERM_GRACE: Duration = Duration::from_secs(2);
42
43#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct ProcessCleanupReport {
46 pub root_pid: Option<u32>,
47 pub attempted_signals: Vec<i32>,
48 pub children: Vec<ProcessCleanupChild>,
49}
50
51impl ProcessCleanupReport {
52 pub fn for_signal(root_pid: Option<u32>, signal: i32) -> Self {
53 Self {
54 root_pid,
55 attempted_signals: vec![signal],
56 children: Vec::new(),
57 }
58 }
59
60 pub fn merge(&mut self, other: Self) {
61 if self.root_pid.is_none() {
62 self.root_pid = other.root_pid;
63 }
64 for signal in other.attempted_signals {
65 push_unique(&mut self.attempted_signals, signal);
66 }
67 for child in other.children {
68 self.merge_child(child);
69 }
70 }
71
72 pub fn refresh_survivor_status(&mut self) {
73 #[cfg(unix)]
74 {
75 for child in &mut self.children {
76 child.alive_after_cleanup = Some(process_exists(child.pid));
77 }
78 }
79 }
80
81 fn merge_child(&mut self, child: ProcessCleanupChild) {
82 if let Some(existing) = self
83 .children
84 .iter_mut()
85 .find(|entry| entry.pid == child.pid)
86 {
87 for signal in child.signals {
88 push_unique(&mut existing.signals, signal);
89 }
90 if existing.command_name.is_none() {
91 existing.command_name = child.command_name;
92 }
93 if child.alive_after_cleanup.is_some() {
94 existing.alive_after_cleanup = child.alive_after_cleanup;
95 }
96 return;
97 }
98 self.children.push(child);
99 self.children
100 .sort_by(|left, right| left.depth.cmp(&right.depth).then(left.pid.cmp(&right.pid)));
101 }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct ProcessCleanupChild {
107 pub pid: u32,
108 pub parent_pid: Option<u32>,
109 pub depth: u32,
110 pub command_name: Option<String>,
111 pub signals: Vec<i32>,
112 pub alive_after_cleanup: Option<bool>,
113}
114
115impl ProcessCleanupChild {
116 pub fn new(
117 pid: u32,
118 parent_pid: Option<u32>,
119 depth: u32,
120 command_name: Option<String>,
121 ) -> Self {
122 Self {
123 pid,
124 parent_pid,
125 depth,
126 command_name,
127 signals: Vec::new(),
128 alive_after_cleanup: None,
129 }
130 }
131
132 #[cfg(unix)]
133 fn with_signal(mut self, signal: i32) -> Self {
134 push_unique(&mut self.signals, signal);
135 self
136 }
137}
138
139fn push_unique<T: Copy + Eq>(values: &mut Vec<T>, value: T) {
140 if !values.contains(&value) {
141 values.push(value);
142 }
143}
144
145#[derive(Clone, Default)]
146struct OpInterrupt {
147 cancel: Option<Arc<AtomicBool>>,
148 deadline: Option<Instant>,
149}
150
151thread_local! {
152 static CURRENT: RefCell<Option<OpInterrupt>> = const { RefCell::new(None) };
153}
154
155pub struct OpInterruptGuard {
159 #[allow(clippy::option_option)]
162 prev: Option<Option<OpInterrupt>>,
163}
164
165impl Drop for OpInterruptGuard {
166 fn drop(&mut self) {
167 if let Some(prev) = self.prev.take() {
168 CURRENT.with(|slot| *slot.borrow_mut() = prev);
169 }
170 }
171}
172
173pub fn install(cancel: Option<Arc<AtomicBool>>, deadline: Option<Instant>) -> OpInterruptGuard {
178 let prev = CURRENT.with(|slot| slot.borrow_mut().replace(OpInterrupt { cancel, deadline }));
179 OpInterruptGuard { prev: Some(prev) }
180}
181
182pub fn installed() -> bool {
187 CURRENT.with(|slot| slot.borrow().is_some())
188}
189
190pub fn requested() -> bool {
194 CURRENT.with(|slot| {
195 let ctx = slot.borrow();
196 let Some(ctx) = ctx.as_ref() else {
197 return false;
198 };
199 if ctx
200 .cancel
201 .as_ref()
202 .is_some_and(|token| token.load(Ordering::SeqCst))
203 {
204 return true;
205 }
206 ctx.deadline
207 .is_some_and(|deadline| Instant::now() >= deadline)
208 })
209}
210
211pub fn configure_kill_group(command: &mut std::process::Command) {
216 #[cfg(unix)]
217 {
218 use std::os::unix::process::CommandExt;
219 command.process_group(0);
220 }
221 #[cfg(not(unix))]
222 {
223 let _ = command;
224 }
225}
226
227pub fn signal_pid_and_group(pid: u32, signal: i32) {
229 #[cfg(unix)]
230 {
231 extern "C" {
234 fn kill(pid: i32, sig: i32) -> i32;
235 }
236 unsafe {
237 kill(-(pid as i32), signal);
238 kill(pid as i32, signal);
239 }
240 }
241 #[cfg(not(unix))]
242 {
243 let _ = (pid, signal);
244 }
245}
246
247pub fn signal_pid_tree_and_group(pid: u32, signal: i32) {
252 let _ = signal_pid_tree_and_group_with_report(pid, signal);
253}
254
255pub fn signal_pid_tree_and_group_with_report(pid: u32, signal: i32) -> ProcessCleanupReport {
258 #[cfg(unix)]
259 {
260 let mut report = ProcessCleanupReport::for_signal(Some(pid), signal);
261 for child in descendant_processes(pid) {
262 signal_pid_and_group(child.pid, signal);
263 report.merge_child(child.with_signal(signal));
264 }
265 signal_pid_and_group(pid, signal);
266 report.refresh_survivor_status();
267 report
268 }
269 #[cfg(not(unix))]
270 {
271 ProcessCleanupReport::for_signal(Some(pid), signal)
272 }
273}
274
275#[cfg(unix)]
276fn descendant_processes(root: u32) -> Vec<ProcessCleanupChild> {
277 use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
278
279 let mut sys = System::new();
280 sys.refresh_processes_specifics(
281 ProcessesToUpdate::All,
282 false,
283 ProcessRefreshKind::everything(),
284 );
285 let rows = sys
286 .processes()
287 .iter()
288 .filter_map(|(pid, process)| {
289 Some((
290 pid.as_u32(),
291 process.parent()?.as_u32(),
292 command_name(process.cmd()),
293 ))
294 })
295 .collect::<Vec<_>>();
296 descendant_processes_from_parent_edges(root, &rows)
297}
298
299#[cfg(all(unix, test))]
300fn descendant_pids_from_parent_edges(root: u32, edges: &[(u32, u32)]) -> Vec<u32> {
301 let rows = edges
302 .iter()
303 .map(|(pid, parent)| (*pid, *parent, None))
304 .collect::<Vec<_>>();
305 descendant_processes_from_parent_edges(root, &rows)
306 .into_iter()
307 .map(|child| child.pid)
308 .collect()
309}
310
311#[cfg(unix)]
312fn descendant_processes_from_parent_edges(
313 root: u32,
314 rows: &[(u32, u32, Option<String>)],
315) -> Vec<ProcessCleanupChild> {
316 use std::collections::{HashMap, HashSet};
317
318 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
319 let mut metadata: HashMap<u32, (u32, Option<String>)> = HashMap::new();
320 for (pid, parent, command) in rows {
321 metadata.insert(*pid, (*parent, command.clone()));
322 children.entry(*parent).or_default().push(*pid);
323 }
324
325 let mut seen = HashSet::new();
326 let mut stack = vec![(root, 0usize)];
327 let mut descendants = Vec::new();
328 while let Some((pid, depth)) = stack.pop() {
329 if !seen.insert(pid) {
330 continue;
331 }
332 if pid != root {
333 descendants.push((pid, depth));
334 }
335 if let Some(kids) = children.get(&pid) {
336 for &child in kids {
337 stack.push((child, depth + 1));
338 }
339 }
340 }
341
342 descendants.sort_by(|(left_pid, left_depth), (right_pid, right_depth)| {
343 right_depth
344 .cmp(left_depth)
345 .then_with(|| left_pid.cmp(right_pid))
346 });
347 descendants
348 .into_iter()
349 .map(|(pid, depth)| {
350 let (parent_pid, command) = metadata.get(&pid).cloned().unwrap_or((root, None));
351 ProcessCleanupChild::new(pid, Some(parent_pid), depth as u32, command)
352 })
353 .collect()
354}
355
356#[cfg(unix)]
357fn command_name(command: &[std::ffi::OsString]) -> Option<String> {
358 if command.is_empty() {
359 return None;
360 }
361 std::path::Path::new(&command[0])
362 .file_name()
363 .map(|name| name.to_string_lossy().into_owned())
364 .filter(|name| !name.is_empty())
365}
366
367#[cfg(unix)]
368fn process_exists(pid: u32) -> bool {
369 extern "C" {
370 fn kill(pid: i32, sig: i32) -> i32;
371 }
372 unsafe { kill(pid as i32, 0) == 0 }
373}
374
375pub enum ChildWait {
377 Exited(std::process::ExitStatus),
379 TimedOut(ProcessCleanupReport),
381 Interrupted(Option<std::process::ExitStatus>, ProcessCleanupReport),
385}
386
387pub fn wait_child_interruptible(
396 child: &mut std::process::Child,
397 timeout: Option<Duration>,
398) -> std::io::Result<ChildWait> {
399 let deadline = timeout.map(|limit| Instant::now() + limit);
400 loop {
401 if let Some(status) = child.try_wait()? {
402 return Ok(ChildWait::Exited(status));
403 }
404 if requested() {
405 let (status, report) = terminate_child_group_with_report(child);
406 return Ok(ChildWait::Interrupted(status, report));
407 }
408 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
409 let mut report = child_pid(child)
411 .map(|pid| signal_pid_tree_and_group_with_report(pid, 9))
412 .unwrap_or_default();
413 let _ = child.kill();
414 let _ = child.wait();
415 report.refresh_survivor_status();
416 return Ok(ChildWait::TimedOut(report));
417 }
418 std::thread::sleep(Duration::from_millis(20));
419 }
420}
421
422pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
428 terminate_child_group_with_report(child).0
429}
430
431pub fn terminate_child_group_with_report(
434 child: &mut std::process::Child,
435) -> (Option<std::process::ExitStatus>, ProcessCleanupReport) {
436 let mut report = child_pid(child)
437 .map(|pid| ProcessCleanupReport::for_signal(Some(pid), 15))
438 .unwrap_or_default();
439 #[cfg(unix)]
440 {
441 if let Some(pid) = child_pid(child) {
442 const SIGTERM: i32 = 15;
443 report = signal_pid_tree_and_group_with_report(pid, SIGTERM);
444 let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
445 loop {
446 match child.try_wait() {
447 Ok(Some(status)) => {
448 report.merge(signal_pid_tree_and_group_with_report(pid, 9));
451 report.refresh_survivor_status();
452 return (Some(status), report);
453 }
454 Ok(None) => {
455 if Instant::now() >= grace_deadline {
456 break;
457 }
458 std::thread::sleep(Duration::from_millis(20));
459 }
460 Err(_) => break,
461 }
462 }
463 report.merge(signal_pid_tree_and_group_with_report(pid, 9));
464 }
465 }
466 let _ = child.kill();
467 let status = child.wait().ok();
468 report.refresh_survivor_status();
469 (status, report)
470}
471
472fn child_pid(child: &std::process::Child) -> Option<u32> {
473 let pid = child.id();
474 (pid > 0).then_some(pid)
475}
476
477pub(crate) fn drain_captured_pipe(
487 rx: &std::sync::mpsc::Receiver<Vec<u8>>,
488 killed: bool,
489 child_pid: u32,
490) -> Vec<u8> {
491 use std::sync::mpsc::RecvTimeoutError;
492 if killed {
493 return rx
494 .recv_timeout(Duration::from_millis(100))
495 .unwrap_or_default();
496 }
497 loop {
498 match rx.recv_timeout(Duration::from_millis(20)) {
499 Ok(buf) => return buf,
500 Err(RecvTimeoutError::Disconnected) => return Vec::new(),
501 Err(RecvTimeoutError::Timeout) => {
502 if requested() {
503 const SIGTERM: i32 = 15;
504 signal_pid_tree_and_group(child_pid, SIGTERM);
505 if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
506 signal_pid_tree_and_group(child_pid, 9);
507 return buf;
508 }
509 signal_pid_tree_and_group(child_pid, 9);
510 return rx
511 .recv_timeout(Duration::from_millis(100))
512 .unwrap_or_default();
513 }
514 }
515 }
516 }
517}
518
519pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
521 mut reader: R,
522) -> std::sync::mpsc::Receiver<Vec<u8>> {
523 let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
524 std::thread::spawn(move || {
525 let mut buf = Vec::new();
526 let _ = reader.read_to_end(&mut buf);
527 let _ = tx.send(buf);
528 });
529 rx
530}
531
532pub fn capture_output_interruptible(
539 command: &mut std::process::Command,
540) -> std::io::Result<std::process::Output> {
541 use std::process::Stdio;
542 command
543 .stdout(Stdio::piped())
544 .stderr(Stdio::piped())
545 .stdin(Stdio::null());
546 configure_kill_group(command);
547 let mut child = command.spawn()?;
548 let pid = child.id();
549 let rx_out = child.stdout.take().map(spawn_pipe_drain);
550 let rx_err = child.stderr.take().map(spawn_pipe_drain);
551
552 let (status, killed) = match wait_child_interruptible(&mut child, None)? {
553 ChildWait::Exited(status) => (status, false),
554 ChildWait::TimedOut(_) => (std::process::ExitStatus::default(), true),
556 ChildWait::Interrupted(status, _) => (status.unwrap_or_default(), true),
557 };
558 let stdout = rx_out
559 .map(|rx| drain_captured_pipe(&rx, killed, pid))
560 .unwrap_or_default();
561 let stderr = rx_err
562 .map(|rx| drain_captured_pipe(&rx, killed, pid))
563 .unwrap_or_default();
564 Ok(std::process::Output {
565 status,
566 stdout,
567 stderr,
568 })
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn requested_is_false_without_context() {
577 assert!(!requested());
578 }
579
580 #[test]
581 fn installed_tracks_guard_lifetime() {
582 assert!(!installed());
583 let guard = install(None, None);
584 assert!(installed());
585 drop(guard);
586 assert!(!installed());
587 }
588
589 #[test]
590 fn cancel_token_trips_requested_and_guard_restores() {
591 let token = Arc::new(AtomicBool::new(false));
592 let guard = install(Some(token.clone()), None);
593 assert!(!requested());
594 token.store(true, Ordering::SeqCst);
595 assert!(requested());
596 drop(guard);
597 assert!(!requested());
598 }
599
600 #[test]
601 fn deadline_trips_requested() {
602 let expired = Instant::now()
603 .checked_sub(Duration::from_millis(1))
604 .expect("monotonic clock supports a 1ms test lookback");
605 let _guard = install(None, Some(expired));
606 assert!(requested());
607 }
608
609 #[test]
610 fn nested_installs_restore_in_order() {
611 let outer_token = Arc::new(AtomicBool::new(true));
612 let _outer = install(Some(outer_token), None);
613 assert!(requested());
614 {
615 let _inner = install(None, None);
616 assert!(!requested());
617 }
618 assert!(requested());
619 }
620
621 #[cfg(unix)]
622 #[test]
623 fn descendant_pids_from_parent_edges_returns_deepest_first_tree_only() {
624 let edges = [
625 (20, 10),
626 (30, 20),
627 (40, 20),
628 (50, 30),
629 (60, 99),
630 (70, 60),
631 (80, 90),
633 (90, 80),
634 ];
635
636 assert_eq!(
637 descendant_pids_from_parent_edges(10, &edges),
638 vec![50, 30, 40, 20]
639 );
640 assert_eq!(descendant_pids_from_parent_edges(99, &edges), vec![70, 60]);
641 assert_eq!(
642 descendant_pids_from_parent_edges(123, &edges),
643 Vec::<u32>::new()
644 );
645 }
646
647 #[cfg(unix)]
648 #[test]
649 fn descendant_processes_preserve_metadata_and_depth_order() {
650 let rows = [
651 (20, 10, Some("worker".to_string())),
652 (30, 20, Some("grandchild".to_string())),
653 (40, 20, None),
654 (50, 30, Some("leaf".to_string())),
655 ];
656
657 let descendants = descendant_processes_from_parent_edges(10, &rows);
658 let pids = descendants
659 .iter()
660 .map(|child| {
661 (
662 child.pid,
663 child.parent_pid,
664 child.depth,
665 child.command_name.as_deref(),
666 )
667 })
668 .collect::<Vec<_>>();
669 assert_eq!(
670 pids,
671 vec![
672 (50, Some(30), 3, Some("leaf")),
673 (30, Some(20), 2, Some("grandchild")),
674 (40, Some(20), 2, None),
675 (20, Some(10), 1, Some("worker")),
676 ]
677 );
678 }
679
680 #[cfg(unix)]
681 #[test]
682 fn command_name_keeps_only_argv0_basename() {
683 let command = vec![
684 std::ffi::OsString::from("/usr/local/bin/tool"),
685 std::ffi::OsString::from("--api-key"),
686 std::ffi::OsString::from("secret-value"),
687 std::ffi::OsString::from("plain"),
688 ];
689
690 assert_eq!(command_name(&command).as_deref(), Some("tool"));
691 assert_eq!(command_name(&[]).as_deref(), None);
692 }
693
694 #[cfg(unix)]
695 #[test]
696 fn interrupted_wait_kills_process_group() {
697 let mut command = std::process::Command::new("sh");
699 command.args(["-c", "sleep 30 & wait"]);
700 configure_kill_group(&mut command);
701 let mut child = command.spawn().expect("spawn sh");
702 let pgid = child.id();
703
704 let cancel = Arc::new(AtomicBool::new(true));
705 let _guard = install(Some(cancel), None);
706 let started = Instant::now();
707 let outcome = wait_child_interruptible(&mut child, None).expect("wait");
708 assert!(matches!(outcome, ChildWait::Interrupted(_, _)));
709 assert!(started.elapsed() < Duration::from_secs(10));
710
711 extern "C" {
713 fn kill(pid: i32, sig: i32) -> i32;
714 }
715 let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
716 let deadline = Instant::now() + Duration::from_secs(5);
717 while !group_gone() && Instant::now() < deadline {
718 std::thread::sleep(Duration::from_millis(50));
719 }
720 assert!(group_gone(), "process group {pgid} survived interrupt");
721 }
722}