1use std::cell::RefCell;
33use std::collections::BTreeMap;
34use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
35use std::sync::{Arc, LazyLock, Mutex};
36use std::time::{Duration, Instant};
37
38pub const PROCESS_CLEANUP_TOKEN_ENV: &str = "HARN_PROCESS_CLEANUP_TOKEN";
42
43pub const SUBPROCESS_TERM_GRACE: Duration = Duration::from_secs(2);
48#[cfg(unix)]
49const SUBPROCESS_KILL_SETTLE: Duration = Duration::from_millis(250);
50
51pub fn new_process_cleanup_token() -> String {
52 format!("harn-cleanup-{}", uuid::Uuid::now_v7().simple())
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
57pub struct ProcessCleanupReport {
58 pub root_pid: Option<u32>,
59 pub attempted_signals: Vec<i32>,
60 pub children: Vec<ProcessCleanupChild>,
61}
62
63impl ProcessCleanupReport {
64 pub fn for_signal(root_pid: Option<u32>, signal: i32) -> Self {
65 Self {
66 root_pid,
67 attempted_signals: vec![signal],
68 children: Vec::new(),
69 }
70 }
71
72 pub fn merge(&mut self, other: Self) {
73 if self.root_pid.is_none() {
74 self.root_pid = other.root_pid;
75 }
76 for signal in other.attempted_signals {
77 push_unique(&mut self.attempted_signals, signal);
78 }
79 for child in other.children {
80 self.merge_child(child);
81 }
82 }
83
84 pub fn refresh_survivor_status(&mut self) {
85 #[cfg(unix)]
86 {
87 for child in &mut self.children {
88 child.alive_after_cleanup = Some(process_exists(child.pid));
89 }
90 }
91 }
92
93 fn merge_child(&mut self, child: ProcessCleanupChild) {
94 if let Some(existing) = self
95 .children
96 .iter_mut()
97 .find(|entry| entry.pid == child.pid)
98 {
99 for signal in child.signals {
100 push_unique(&mut existing.signals, signal);
101 }
102 if existing.command_name.is_none() {
103 existing.command_name = child.command_name;
104 }
105 if child.alive_after_cleanup.is_some() {
106 existing.alive_after_cleanup = child.alive_after_cleanup;
107 }
108 return;
109 }
110 self.children.push(child);
111 self.children
112 .sort_by(|left, right| left.depth.cmp(&right.depth).then(left.pid.cmp(&right.pid)));
113 }
114}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct ProcessCleanupChild {
119 pub pid: u32,
120 pub parent_pid: Option<u32>,
121 pub depth: u32,
122 pub command_name: Option<String>,
123 pub signals: Vec<i32>,
124 pub alive_after_cleanup: Option<bool>,
125}
126
127impl ProcessCleanupChild {
128 pub fn new(
129 pid: u32,
130 parent_pid: Option<u32>,
131 depth: u32,
132 command_name: Option<String>,
133 ) -> Self {
134 Self {
135 pid,
136 parent_pid,
137 depth,
138 command_name,
139 signals: Vec::new(),
140 alive_after_cleanup: None,
141 }
142 }
143
144 #[cfg(unix)]
145 fn with_signal(mut self, signal: i32) -> Self {
146 push_unique(&mut self.signals, signal);
147 self
148 }
149}
150
151fn push_unique<T: Copy + Eq>(values: &mut Vec<T>, value: T) {
152 if !values.contains(&value) {
153 values.push(value);
154 }
155}
156
157#[derive(Clone, Default)]
158struct OpInterrupt {
159 cancel: Option<Arc<AtomicBool>>,
160 deadline: Option<Instant>,
161}
162
163thread_local! {
164 static CURRENT: RefCell<Option<OpInterrupt>> = const { RefCell::new(None) };
165}
166
167#[derive(Clone, Debug)]
168struct ActiveProcessCleanup {
169 pid: Option<u32>,
170 cleanup_token: String,
171 owner_cancel_token: Option<Arc<AtomicBool>>,
172}
173
174static ACTIVE_PROCESS_CLEANUP_ID: AtomicU64 = AtomicU64::new(1);
175static ACTIVE_PROCESS_CLEANUPS: LazyLock<Mutex<BTreeMap<u64, ActiveProcessCleanup>>> =
176 LazyLock::new(|| Mutex::new(BTreeMap::new()));
177
178pub struct ActiveProcessCleanupGuard {
183 id: u64,
184}
185
186impl Drop for ActiveProcessCleanupGuard {
187 fn drop(&mut self) {
188 unregister_active_process_cleanup(self.id);
189 }
190}
191
192pub fn register_active_process_cleanup(
193 pid: Option<u32>,
194 cleanup_token: &str,
195 owner_cancel_token: Option<Arc<AtomicBool>>,
196) -> ActiveProcessCleanupGuard {
197 let id = ACTIVE_PROCESS_CLEANUP_ID.fetch_add(1, Ordering::SeqCst);
198 ACTIVE_PROCESS_CLEANUPS
199 .lock()
200 .expect("active process cleanup registry poisoned")
201 .insert(
202 id,
203 ActiveProcessCleanup {
204 pid,
205 cleanup_token: cleanup_token.to_string(),
206 owner_cancel_token,
207 },
208 );
209 ActiveProcessCleanupGuard { id }
210}
211
212fn unregister_active_process_cleanup(id: u64) {
213 ACTIVE_PROCESS_CLEANUPS
214 .lock()
215 .expect("active process cleanup registry poisoned")
216 .remove(&id);
217}
218
219pub fn signal_active_process_cleanups(signal: i32) -> ProcessCleanupReport {
224 signal_active_process_cleanups_matching(signal, |_| true)
225}
226
227pub fn signal_ownerless_active_process_cleanups(signal: i32) -> ProcessCleanupReport {
228 signal_active_process_cleanups_matching(signal, |entry| entry.owner_cancel_token.is_none())
229}
230
231pub fn signal_active_process_cleanups_for_cancel_token(
232 signal: i32,
233 cancel_token: &Arc<AtomicBool>,
234) -> ProcessCleanupReport {
235 signal_active_process_cleanups_matching(signal, |entry| {
236 entry
237 .owner_cancel_token
238 .as_ref()
239 .is_some_and(|owner| Arc::ptr_eq(owner, cancel_token))
240 })
241}
242
243#[cfg(test)]
244fn active_cleanup_tokens_for_cancel_token_for_test(cancel_token: &Arc<AtomicBool>) -> Vec<String> {
245 ACTIVE_PROCESS_CLEANUPS
246 .lock()
247 .expect("active process cleanup registry poisoned")
248 .values()
249 .filter(|entry| {
250 entry
251 .owner_cancel_token
252 .as_ref()
253 .is_some_and(|owner| Arc::ptr_eq(owner, cancel_token))
254 })
255 .map(|entry| entry.cleanup_token.clone())
256 .collect()
257}
258
259#[cfg(test)]
260fn ownerless_active_cleanup_tokens_for_test() -> Vec<String> {
261 ACTIVE_PROCESS_CLEANUPS
262 .lock()
263 .expect("active process cleanup registry poisoned")
264 .values()
265 .filter(|entry| entry.owner_cancel_token.is_none())
266 .map(|entry| entry.cleanup_token.clone())
267 .collect()
268}
269
270fn signal_active_process_cleanups_matching(
271 signal: i32,
272 matches_entry: impl Fn(&ActiveProcessCleanup) -> bool,
273) -> ProcessCleanupReport {
274 let entries = ACTIVE_PROCESS_CLEANUPS
275 .lock()
276 .expect("active process cleanup registry poisoned")
277 .values()
278 .filter(|entry| matches_entry(entry))
279 .cloned()
280 .collect::<Vec<_>>();
281 let mut report = ProcessCleanupReport::default();
282 for entry in entries {
283 if let Some(pid) = entry.pid {
284 report.merge(signal_pid_tree_group_and_token_with_report(
285 pid,
286 Some(&entry.cleanup_token),
287 signal,
288 ));
289 }
290 }
291 report
292}
293
294pub struct OpInterruptGuard {
298 #[allow(clippy::option_option)]
301 prev: Option<Option<OpInterrupt>>,
302}
303
304impl Drop for OpInterruptGuard {
305 fn drop(&mut self) {
306 if let Some(prev) = self.prev.take() {
307 CURRENT.with(|slot| *slot.borrow_mut() = prev);
308 }
309 }
310}
311
312pub fn install(cancel: Option<Arc<AtomicBool>>, deadline: Option<Instant>) -> OpInterruptGuard {
317 let prev = CURRENT.with(|slot| slot.borrow_mut().replace(OpInterrupt { cancel, deadline }));
318 OpInterruptGuard { prev: Some(prev) }
319}
320
321pub fn installed() -> bool {
326 CURRENT.with(|slot| slot.borrow().is_some())
327}
328
329pub fn requested() -> bool {
333 CURRENT.with(|slot| {
334 let ctx = slot.borrow();
335 let Some(ctx) = ctx.as_ref() else {
336 return false;
337 };
338 if ctx
339 .cancel
340 .as_ref()
341 .is_some_and(|token| token.load(Ordering::SeqCst))
342 {
343 return true;
344 }
345 ctx.deadline
346 .is_some_and(|deadline| Instant::now() >= deadline)
347 })
348}
349
350pub fn configure_kill_group(command: &mut std::process::Command) {
355 #[cfg(unix)]
356 {
357 use std::os::unix::process::CommandExt;
358 command.process_group(0);
359 }
360 #[cfg(not(unix))]
361 {
362 let _ = command;
363 }
364}
365
366pub fn configure_tokio_kill_group(command: &mut tokio::process::Command) {
370 #[cfg(unix)]
371 {
372 command.process_group(0);
373 }
374 #[cfg(not(unix))]
375 {
376 let _ = command;
377 }
378}
379
380pub fn signal_pid_and_group(pid: u32, signal: i32) {
382 #[cfg(unix)]
383 {
384 extern "C" {
387 fn kill(pid: i32, sig: i32) -> i32;
388 }
389 unsafe {
390 kill(-(pid as i32), signal);
391 kill(pid as i32, signal);
392 }
393 }
394 #[cfg(not(unix))]
395 {
396 let _ = (pid, signal);
397 }
398}
399
400pub fn signal_pid_tree_and_group(pid: u32, signal: i32) {
405 let _ = signal_pid_tree_and_group_with_report(pid, signal);
406}
407
408pub fn signal_pid_tree_and_group_with_report(pid: u32, signal: i32) -> ProcessCleanupReport {
411 signal_pid_tree_group_and_token_with_report(pid, None, signal)
412}
413
414pub fn signal_pid_tree_group_and_token_with_report(
420 pid: u32,
421 cleanup_token: Option<&str>,
422 signal: i32,
423) -> ProcessCleanupReport {
424 #[cfg(unix)]
425 {
426 let mut report = ProcessCleanupReport::for_signal(Some(pid), signal);
427 for child in descendant_processes(pid) {
428 signal_pid_and_group(child.pid, signal);
429 report.merge_child(child.with_signal(signal));
430 }
431 if let Some(cleanup_token) = cleanup_token.filter(|token| !token.is_empty()) {
432 for child in cleanup_token_processes(cleanup_token) {
433 if child.pid == pid {
434 continue;
435 }
436 signal_pid_and_group(child.pid, signal);
437 report.merge_child(child.with_signal(signal));
438 }
439 }
440 signal_pid_and_group(pid, signal);
441 if signal == 9 {
442 wait_for_report_children_to_exit(&report, SUBPROCESS_KILL_SETTLE);
443 }
444 report.refresh_survivor_status();
445 report
446 }
447 #[cfg(not(unix))]
448 {
449 let _ = cleanup_token;
450 ProcessCleanupReport::for_signal(Some(pid), signal)
451 }
452}
453
454#[cfg(unix)]
462pub fn signal_pid_tree_and_token_preserving_group_with_report(
463 pid: u32,
464 cleanup_token: Option<&str>,
465 preserved_pgid: u32,
466 signal: i32,
467) -> ProcessCleanupReport {
468 let preserved_pgid = preserved_pgid as i32;
469 let mut report = ProcessCleanupReport::for_signal(Some(pid), signal);
470 for child in descendant_processes(pid) {
471 signal_pid_preserving_group(child.pid, preserved_pgid, signal);
472 report.merge_child(child.with_signal(signal));
473 }
474 if let Some(cleanup_token) = cleanup_token.filter(|token| !token.is_empty()) {
475 for child in cleanup_token_processes(cleanup_token) {
476 if child.pid == pid {
477 continue;
478 }
479 signal_pid_preserving_group(child.pid, preserved_pgid, signal);
480 report.merge_child(child.with_signal(signal));
481 }
482 }
483 signal_pid_preserving_group(pid, preserved_pgid, signal);
484 report
485}
486
487#[cfg(unix)]
488fn signal_pid_preserving_group(pid: u32, preserved_pgid: i32, signal: i32) {
489 let pid = pid as i32;
490 let pgid = unsafe { libc::getpgid(pid) };
491 unsafe {
492 if pgid > 0 && pgid != preserved_pgid {
493 libc::kill(-pgid, signal);
494 }
495 libc::kill(pid, signal);
496 }
497}
498
499#[cfg(unix)]
500fn descendant_processes(root: u32) -> Vec<ProcessCleanupChild> {
501 use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
502
503 let mut sys = System::new();
504 sys.refresh_processes_specifics(
505 ProcessesToUpdate::All,
506 false,
507 ProcessRefreshKind::everything(),
508 );
509 let rows = sys
510 .processes()
511 .iter()
512 .filter_map(|(pid, process)| {
513 Some((
514 pid.as_u32(),
515 process.parent()?.as_u32(),
516 command_name(process.cmd()),
517 ))
518 })
519 .collect::<Vec<_>>();
520 descendant_processes_from_parent_edges(root, &rows)
521}
522
523#[cfg(unix)]
524fn cleanup_token_processes(token: &str) -> Vec<ProcessCleanupChild> {
525 use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
526
527 let mut sys = System::new();
528 sys.refresh_processes_specifics(
529 ProcessesToUpdate::All,
530 false,
531 ProcessRefreshKind::nothing()
532 .with_environ(UpdateKind::Always)
533 .with_cmd(UpdateKind::Always),
534 );
535 let mut children = sys
536 .processes()
537 .iter()
538 .filter(|(_, process)| process_has_cleanup_token(process.environ(), token))
539 .map(|(pid, process)| {
540 ProcessCleanupChild::new(
541 pid.as_u32(),
542 process.parent().map(|parent| parent.as_u32()),
543 1,
544 command_name(process.cmd()),
545 )
546 })
547 .collect::<Vec<_>>();
548 children.sort_by_key(|child| child.pid);
549 children
550}
551
552#[cfg(unix)]
553fn process_has_cleanup_token(environ: &[std::ffi::OsString], token: &str) -> bool {
554 let expected = format!("{PROCESS_CLEANUP_TOKEN_ENV}={token}");
555 environ
556 .iter()
557 .any(|entry| entry.to_string_lossy() == expected)
558}
559
560#[cfg(all(unix, test))]
561fn descendant_pids_from_parent_edges(root: u32, edges: &[(u32, u32)]) -> Vec<u32> {
562 let rows = edges
563 .iter()
564 .map(|(pid, parent)| (*pid, *parent, None))
565 .collect::<Vec<_>>();
566 descendant_processes_from_parent_edges(root, &rows)
567 .into_iter()
568 .map(|child| child.pid)
569 .collect()
570}
571
572#[cfg(unix)]
573fn descendant_processes_from_parent_edges(
574 root: u32,
575 rows: &[(u32, u32, Option<String>)],
576) -> Vec<ProcessCleanupChild> {
577 use std::collections::{HashMap, HashSet};
578
579 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
580 let mut metadata: HashMap<u32, (u32, Option<String>)> = HashMap::new();
581 for (pid, parent, command) in rows {
582 metadata.insert(*pid, (*parent, command.clone()));
583 children.entry(*parent).or_default().push(*pid);
584 }
585
586 let mut seen = HashSet::new();
587 let mut stack = vec![(root, 0usize)];
588 let mut descendants = Vec::new();
589 while let Some((pid, depth)) = stack.pop() {
590 if !seen.insert(pid) {
591 continue;
592 }
593 if pid != root {
594 descendants.push((pid, depth));
595 }
596 if let Some(kids) = children.get(&pid) {
597 for &child in kids {
598 stack.push((child, depth + 1));
599 }
600 }
601 }
602
603 descendants.sort_by(|(left_pid, left_depth), (right_pid, right_depth)| {
604 right_depth
605 .cmp(left_depth)
606 .then_with(|| left_pid.cmp(right_pid))
607 });
608 descendants
609 .into_iter()
610 .map(|(pid, depth)| {
611 let (parent_pid, command) = metadata.get(&pid).cloned().unwrap_or((root, None));
612 ProcessCleanupChild::new(pid, Some(parent_pid), depth as u32, command)
613 })
614 .collect()
615}
616
617#[cfg(unix)]
618fn command_name(command: &[std::ffi::OsString]) -> Option<String> {
619 if command.is_empty() {
620 return None;
621 }
622 std::path::Path::new(&command[0])
623 .file_name()
624 .map(|name| name.to_string_lossy().into_owned())
625 .filter(|name| !name.is_empty())
626}
627
628#[cfg(unix)]
629fn process_exists(pid: u32) -> bool {
630 unsafe { libc::kill(pid as i32, 0) == 0 }
631}
632
633#[cfg(unix)]
634fn wait_for_report_children_to_exit(report: &ProcessCleanupReport, timeout: Duration) {
635 let deadline = Instant::now() + timeout;
636 while Instant::now() < deadline {
637 if report
638 .children
639 .iter()
640 .all(|child| !process_exists(child.pid))
641 {
642 return;
643 }
644 std::thread::sleep(Duration::from_millis(10));
645 }
646}
647
648pub enum ChildWait {
650 Exited(std::process::ExitStatus),
652 TimedOut(ProcessCleanupReport),
654 Interrupted(Option<std::process::ExitStatus>, ProcessCleanupReport),
658}
659
660pub fn wait_child_interruptible(
669 child: &mut std::process::Child,
670 timeout: Option<Duration>,
671) -> std::io::Result<ChildWait> {
672 wait_child_interruptible_with_cleanup_token(child, timeout, None)
673}
674
675pub fn wait_child_interruptible_with_cleanup_token(
676 child: &mut std::process::Child,
677 timeout: Option<Duration>,
678 cleanup_token: Option<&str>,
679) -> std::io::Result<ChildWait> {
680 let deadline = timeout.map(|limit| Instant::now() + limit);
681 loop {
682 if let Some(status) = child.try_wait()? {
683 return Ok(ChildWait::Exited(status));
684 }
685 if requested() {
686 let (status, report) =
687 terminate_child_group_with_cleanup_token_report(child, cleanup_token);
688 return Ok(ChildWait::Interrupted(status, report));
689 }
690 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
691 let mut report = child_pid(child)
693 .map(|pid| signal_pid_tree_group_and_token_with_report(pid, cleanup_token, 9))
694 .unwrap_or_default();
695 let _ = child.kill();
696 let _ = child.wait();
697 report.refresh_survivor_status();
698 return Ok(ChildWait::TimedOut(report));
699 }
700 std::thread::sleep(Duration::from_millis(20));
701 }
702}
703
704pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
710 terminate_child_group_with_report(child).0
711}
712
713pub fn terminate_child_group_with_report(
716 child: &mut std::process::Child,
717) -> (Option<std::process::ExitStatus>, ProcessCleanupReport) {
718 terminate_child_group_with_cleanup_token_report(child, None)
719}
720
721pub fn terminate_child_group_with_cleanup_token_report(
722 child: &mut std::process::Child,
723 cleanup_token: Option<&str>,
724) -> (Option<std::process::ExitStatus>, ProcessCleanupReport) {
725 let mut report = child_pid(child)
726 .map(|pid| ProcessCleanupReport::for_signal(Some(pid), 15))
727 .unwrap_or_default();
728 #[cfg(not(unix))]
729 let _ = cleanup_token;
730 #[cfg(unix)]
731 {
732 if let Some(pid) = child_pid(child) {
733 const SIGTERM: i32 = 15;
734 report = signal_pid_tree_group_and_token_with_report(pid, cleanup_token, SIGTERM);
735 let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
736 loop {
737 match child.try_wait() {
738 Ok(Some(status)) => {
739 report.merge(signal_pid_tree_group_and_token_with_report(
742 pid,
743 cleanup_token,
744 9,
745 ));
746 report.refresh_survivor_status();
747 return (Some(status), report);
748 }
749 Ok(None) => {
750 if Instant::now() >= grace_deadline {
751 break;
752 }
753 std::thread::sleep(Duration::from_millis(20));
754 }
755 Err(_) => break,
756 }
757 }
758 report.merge(signal_pid_tree_group_and_token_with_report(
759 pid,
760 cleanup_token,
761 9,
762 ));
763 }
764 }
765 let _ = child.kill();
766 let status = child.wait().ok();
767 report.refresh_survivor_status();
768 (status, report)
769}
770
771fn child_pid(child: &std::process::Child) -> Option<u32> {
772 let pid = child.id();
773 (pid > 0).then_some(pid)
774}
775
776pub(crate) fn drain_captured_pipe(
786 rx: &std::sync::mpsc::Receiver<Vec<u8>>,
787 killed: bool,
788 child_pid: u32,
789) -> Vec<u8> {
790 use std::sync::mpsc::RecvTimeoutError;
791 if killed {
792 return rx
793 .recv_timeout(Duration::from_millis(100))
794 .unwrap_or_default();
795 }
796 loop {
797 match rx.recv_timeout(Duration::from_millis(20)) {
798 Ok(buf) => return buf,
799 Err(RecvTimeoutError::Disconnected) => return Vec::new(),
800 Err(RecvTimeoutError::Timeout) => {
801 if requested() {
802 const SIGTERM: i32 = 15;
803 signal_pid_tree_and_group(child_pid, SIGTERM);
804 if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
805 signal_pid_tree_and_group(child_pid, 9);
806 return buf;
807 }
808 signal_pid_tree_and_group(child_pid, 9);
809 return rx
810 .recv_timeout(Duration::from_millis(100))
811 .unwrap_or_default();
812 }
813 }
814 }
815 }
816}
817
818pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
820 mut reader: R,
821) -> std::sync::mpsc::Receiver<Vec<u8>> {
822 let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
823 std::thread::spawn(move || {
824 let mut buf = Vec::new();
825 let _ = reader.read_to_end(&mut buf);
826 let _ = tx.send(buf);
827 });
828 rx
829}
830
831pub fn capture_output_interruptible(
838 command: &mut std::process::Command,
839) -> std::io::Result<std::process::Output> {
840 use std::process::Stdio;
841 command
842 .stdout(Stdio::piped())
843 .stderr(Stdio::piped())
844 .stdin(Stdio::null());
845 configure_kill_group(command);
846 let cleanup_token = new_process_cleanup_token();
847 command.env(PROCESS_CLEANUP_TOKEN_ENV, &cleanup_token);
848 let mut child = command.spawn()?;
849 let pid = child.id();
850 let rx_out = child.stdout.take().map(spawn_pipe_drain);
851 let rx_err = child.stderr.take().map(spawn_pipe_drain);
852
853 let (status, killed) = match wait_child_interruptible_with_cleanup_token(
854 &mut child,
855 None,
856 Some(&cleanup_token),
857 )? {
858 ChildWait::Exited(status) => (status, false),
859 ChildWait::TimedOut(_) => (std::process::ExitStatus::default(), true),
861 ChildWait::Interrupted(status, _) => (status.unwrap_or_default(), true),
862 };
863 let stdout = rx_out
864 .map(|rx| drain_captured_pipe(&rx, killed, pid))
865 .unwrap_or_default();
866 let stderr = rx_err
867 .map(|rx| drain_captured_pipe(&rx, killed, pid))
868 .unwrap_or_default();
869 Ok(std::process::Output {
870 status,
871 stdout,
872 stderr,
873 })
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn requested_is_false_without_context() {
882 assert!(!requested());
883 }
884
885 #[test]
886 fn installed_tracks_guard_lifetime() {
887 assert!(!installed());
888 let guard = install(None, None);
889 assert!(installed());
890 drop(guard);
891 assert!(!installed());
892 }
893
894 #[test]
895 fn cancel_token_trips_requested_and_guard_restores() {
896 let token = Arc::new(AtomicBool::new(false));
897 let guard = install(Some(token.clone()), None);
898 assert!(!requested());
899 token.store(true, Ordering::SeqCst);
900 assert!(requested());
901 drop(guard);
902 assert!(!requested());
903 }
904
905 #[test]
906 fn deadline_trips_requested() {
907 let expired = Instant::now()
908 .checked_sub(Duration::from_millis(1))
909 .expect("monotonic clock supports a 1ms test lookback");
910 let _guard = install(None, Some(expired));
911 assert!(requested());
912 }
913
914 #[test]
915 fn nested_installs_restore_in_order() {
916 let outer_token = Arc::new(AtomicBool::new(true));
917 let _outer = install(Some(outer_token), None);
918 assert!(requested());
919 {
920 let _inner = install(None, None);
921 assert!(!requested());
922 }
923 assert!(requested());
924 }
925
926 #[test]
927 fn active_cleanup_owner_scopes_are_disjoint() {
928 let owner = Arc::new(AtomicBool::new(false));
929 let _owned =
930 register_active_process_cleanup(None, "owned-scope-test", Some(Arc::clone(&owner)));
931 let _ownerless = register_active_process_cleanup(None, "ownerless-scope-test", None);
932
933 assert_eq!(
934 active_cleanup_tokens_for_cancel_token_for_test(&owner),
935 vec!["owned-scope-test".to_string()]
936 );
937 assert!(
938 ownerless_active_cleanup_tokens_for_test()
939 .iter()
940 .any(|token| token == "ownerless-scope-test"),
941 "explicit ownerless fallback should remain separately discoverable"
942 );
943 }
944
945 #[test]
946 fn active_cleanup_guard_unregisters_on_drop() {
947 let owner = Arc::new(AtomicBool::new(false));
948 let token = "guard-lifetime-test";
949 let guard = register_active_process_cleanup(None, token, Some(Arc::clone(&owner)));
950
951 assert!(
952 active_cleanup_tokens_for_cancel_token_for_test(&owner)
953 .iter()
954 .any(|entry| entry == token),
955 "active cleanup must remain registered while its guard is alive"
956 );
957
958 drop(guard);
959
960 assert!(
961 !active_cleanup_tokens_for_cancel_token_for_test(&owner)
962 .iter()
963 .any(|entry| entry == token),
964 "dropping the guard must unregister the cleanup token"
965 );
966 }
967
968 #[cfg(unix)]
969 #[test]
970 fn descendant_pids_from_parent_edges_returns_deepest_first_tree_only() {
971 let edges = [
972 (20, 10),
973 (30, 20),
974 (40, 20),
975 (50, 30),
976 (60, 99),
977 (70, 60),
978 (80, 90),
980 (90, 80),
981 ];
982
983 assert_eq!(
984 descendant_pids_from_parent_edges(10, &edges),
985 vec![50, 30, 40, 20]
986 );
987 assert_eq!(descendant_pids_from_parent_edges(99, &edges), vec![70, 60]);
988 assert_eq!(
989 descendant_pids_from_parent_edges(123, &edges),
990 Vec::<u32>::new()
991 );
992 }
993
994 #[cfg(unix)]
995 #[test]
996 fn descendant_processes_preserve_metadata_and_depth_order() {
997 let rows = [
998 (20, 10, Some("worker".to_string())),
999 (30, 20, Some("grandchild".to_string())),
1000 (40, 20, None),
1001 (50, 30, Some("leaf".to_string())),
1002 ];
1003
1004 let descendants = descendant_processes_from_parent_edges(10, &rows);
1005 let pids = descendants
1006 .iter()
1007 .map(|child| {
1008 (
1009 child.pid,
1010 child.parent_pid,
1011 child.depth,
1012 child.command_name.as_deref(),
1013 )
1014 })
1015 .collect::<Vec<_>>();
1016 assert_eq!(
1017 pids,
1018 vec![
1019 (50, Some(30), 3, Some("leaf")),
1020 (30, Some(20), 2, Some("grandchild")),
1021 (40, Some(20), 2, None),
1022 (20, Some(10), 1, Some("worker")),
1023 ]
1024 );
1025 }
1026
1027 #[cfg(unix)]
1028 #[test]
1029 fn command_name_keeps_only_argv0_basename() {
1030 let command = vec![
1031 std::ffi::OsString::from("/usr/local/bin/tool"),
1032 std::ffi::OsString::from("--api-key"),
1033 std::ffi::OsString::from("secret-value"),
1034 std::ffi::OsString::from("plain"),
1035 ];
1036
1037 assert_eq!(command_name(&command).as_deref(), Some("tool"));
1038 assert_eq!(command_name(&[]).as_deref(), None);
1039 }
1040
1041 #[cfg(unix)]
1042 #[test]
1043 fn process_has_cleanup_token_requires_exact_marker_entry() {
1044 let token = "tok-123";
1045 let env = vec![
1046 std::ffi::OsString::from("PATH=/usr/bin"),
1047 std::ffi::OsString::from(format!("{PROCESS_CLEANUP_TOKEN_ENV}={token}")),
1048 ];
1049 assert!(process_has_cleanup_token(&env, token));
1050 assert!(!process_has_cleanup_token(&env, "tok"));
1051 assert!(!process_has_cleanup_token(
1052 &[std::ffi::OsString::from("OTHER=tok-123")],
1053 token
1054 ));
1055 }
1056
1057 #[cfg(unix)]
1058 #[test]
1059 fn interrupted_wait_kills_process_group() {
1060 let mut command = std::process::Command::new("sh");
1062 command.args(["-c", "sleep 30 & wait"]);
1063 configure_kill_group(&mut command);
1064 let mut child = command.spawn().expect("spawn sh");
1065 let pgid = child.id();
1066
1067 let cancel = Arc::new(AtomicBool::new(true));
1068 let _guard = install(Some(cancel), None);
1069 let started = Instant::now();
1070 let outcome = wait_child_interruptible(&mut child, None).expect("wait");
1071 assert!(matches!(outcome, ChildWait::Interrupted(_, _)));
1072 assert!(started.elapsed() < Duration::from_secs(10));
1073
1074 extern "C" {
1076 fn kill(pid: i32, sig: i32) -> i32;
1077 }
1078 let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
1079 let deadline = Instant::now() + Duration::from_secs(5);
1080 while !group_gone() && Instant::now() < deadline {
1081 std::thread::sleep(Duration::from_millis(50));
1082 }
1083 assert!(group_gone(), "process group {pgid} survived interrupt");
1084 }
1085}