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)]
455fn descendant_processes(root: u32) -> Vec<ProcessCleanupChild> {
456 use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
457
458 let mut sys = System::new();
459 sys.refresh_processes_specifics(
460 ProcessesToUpdate::All,
461 false,
462 ProcessRefreshKind::everything(),
463 );
464 let rows = sys
465 .processes()
466 .iter()
467 .filter_map(|(pid, process)| {
468 Some((
469 pid.as_u32(),
470 process.parent()?.as_u32(),
471 command_name(process.cmd()),
472 ))
473 })
474 .collect::<Vec<_>>();
475 descendant_processes_from_parent_edges(root, &rows)
476}
477
478#[cfg(unix)]
479fn cleanup_token_processes(token: &str) -> Vec<ProcessCleanupChild> {
480 use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
481
482 let mut sys = System::new();
483 sys.refresh_processes_specifics(
484 ProcessesToUpdate::All,
485 false,
486 ProcessRefreshKind::nothing()
487 .with_environ(UpdateKind::Always)
488 .with_cmd(UpdateKind::Always),
489 );
490 let mut children = sys
491 .processes()
492 .iter()
493 .filter(|(_, process)| process_has_cleanup_token(process.environ(), token))
494 .map(|(pid, process)| {
495 ProcessCleanupChild::new(
496 pid.as_u32(),
497 process.parent().map(|parent| parent.as_u32()),
498 1,
499 command_name(process.cmd()),
500 )
501 })
502 .collect::<Vec<_>>();
503 children.sort_by_key(|child| child.pid);
504 children
505}
506
507#[cfg(unix)]
508fn process_has_cleanup_token(environ: &[std::ffi::OsString], token: &str) -> bool {
509 let expected = format!("{PROCESS_CLEANUP_TOKEN_ENV}={token}");
510 environ
511 .iter()
512 .any(|entry| entry.to_string_lossy() == expected)
513}
514
515#[cfg(all(unix, test))]
516fn descendant_pids_from_parent_edges(root: u32, edges: &[(u32, u32)]) -> Vec<u32> {
517 let rows = edges
518 .iter()
519 .map(|(pid, parent)| (*pid, *parent, None))
520 .collect::<Vec<_>>();
521 descendant_processes_from_parent_edges(root, &rows)
522 .into_iter()
523 .map(|child| child.pid)
524 .collect()
525}
526
527#[cfg(unix)]
528fn descendant_processes_from_parent_edges(
529 root: u32,
530 rows: &[(u32, u32, Option<String>)],
531) -> Vec<ProcessCleanupChild> {
532 use std::collections::{HashMap, HashSet};
533
534 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
535 let mut metadata: HashMap<u32, (u32, Option<String>)> = HashMap::new();
536 for (pid, parent, command) in rows {
537 metadata.insert(*pid, (*parent, command.clone()));
538 children.entry(*parent).or_default().push(*pid);
539 }
540
541 let mut seen = HashSet::new();
542 let mut stack = vec![(root, 0usize)];
543 let mut descendants = Vec::new();
544 while let Some((pid, depth)) = stack.pop() {
545 if !seen.insert(pid) {
546 continue;
547 }
548 if pid != root {
549 descendants.push((pid, depth));
550 }
551 if let Some(kids) = children.get(&pid) {
552 for &child in kids {
553 stack.push((child, depth + 1));
554 }
555 }
556 }
557
558 descendants.sort_by(|(left_pid, left_depth), (right_pid, right_depth)| {
559 right_depth
560 .cmp(left_depth)
561 .then_with(|| left_pid.cmp(right_pid))
562 });
563 descendants
564 .into_iter()
565 .map(|(pid, depth)| {
566 let (parent_pid, command) = metadata.get(&pid).cloned().unwrap_or((root, None));
567 ProcessCleanupChild::new(pid, Some(parent_pid), depth as u32, command)
568 })
569 .collect()
570}
571
572#[cfg(unix)]
573fn command_name(command: &[std::ffi::OsString]) -> Option<String> {
574 if command.is_empty() {
575 return None;
576 }
577 std::path::Path::new(&command[0])
578 .file_name()
579 .map(|name| name.to_string_lossy().into_owned())
580 .filter(|name| !name.is_empty())
581}
582
583#[cfg(unix)]
584fn process_exists(pid: u32) -> bool {
585 unsafe { libc::kill(pid as i32, 0) == 0 }
586}
587
588#[cfg(unix)]
589fn wait_for_report_children_to_exit(report: &ProcessCleanupReport, timeout: Duration) {
590 let deadline = Instant::now() + timeout;
591 while Instant::now() < deadline {
592 if report
593 .children
594 .iter()
595 .all(|child| !process_exists(child.pid))
596 {
597 return;
598 }
599 std::thread::sleep(Duration::from_millis(10));
600 }
601}
602
603pub enum ChildWait {
605 Exited(std::process::ExitStatus),
607 TimedOut(ProcessCleanupReport),
609 Interrupted(Option<std::process::ExitStatus>, ProcessCleanupReport),
613}
614
615pub fn wait_child_interruptible(
624 child: &mut std::process::Child,
625 timeout: Option<Duration>,
626) -> std::io::Result<ChildWait> {
627 wait_child_interruptible_with_cleanup_token(child, timeout, None)
628}
629
630pub fn wait_child_interruptible_with_cleanup_token(
631 child: &mut std::process::Child,
632 timeout: Option<Duration>,
633 cleanup_token: Option<&str>,
634) -> std::io::Result<ChildWait> {
635 let deadline = timeout.map(|limit| Instant::now() + limit);
636 loop {
637 if let Some(status) = child.try_wait()? {
638 return Ok(ChildWait::Exited(status));
639 }
640 if requested() {
641 let (status, report) =
642 terminate_child_group_with_cleanup_token_report(child, cleanup_token);
643 return Ok(ChildWait::Interrupted(status, report));
644 }
645 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
646 let mut report = child_pid(child)
648 .map(|pid| signal_pid_tree_group_and_token_with_report(pid, cleanup_token, 9))
649 .unwrap_or_default();
650 let _ = child.kill();
651 let _ = child.wait();
652 report.refresh_survivor_status();
653 return Ok(ChildWait::TimedOut(report));
654 }
655 std::thread::sleep(Duration::from_millis(20));
656 }
657}
658
659pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
665 terminate_child_group_with_report(child).0
666}
667
668pub fn terminate_child_group_with_report(
671 child: &mut std::process::Child,
672) -> (Option<std::process::ExitStatus>, ProcessCleanupReport) {
673 terminate_child_group_with_cleanup_token_report(child, None)
674}
675
676pub fn terminate_child_group_with_cleanup_token_report(
677 child: &mut std::process::Child,
678 cleanup_token: Option<&str>,
679) -> (Option<std::process::ExitStatus>, ProcessCleanupReport) {
680 let mut report = child_pid(child)
681 .map(|pid| ProcessCleanupReport::for_signal(Some(pid), 15))
682 .unwrap_or_default();
683 #[cfg(not(unix))]
684 let _ = cleanup_token;
685 #[cfg(unix)]
686 {
687 if let Some(pid) = child_pid(child) {
688 const SIGTERM: i32 = 15;
689 report = signal_pid_tree_group_and_token_with_report(pid, cleanup_token, SIGTERM);
690 let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
691 loop {
692 match child.try_wait() {
693 Ok(Some(status)) => {
694 report.merge(signal_pid_tree_group_and_token_with_report(
697 pid,
698 cleanup_token,
699 9,
700 ));
701 report.refresh_survivor_status();
702 return (Some(status), report);
703 }
704 Ok(None) => {
705 if Instant::now() >= grace_deadline {
706 break;
707 }
708 std::thread::sleep(Duration::from_millis(20));
709 }
710 Err(_) => break,
711 }
712 }
713 report.merge(signal_pid_tree_group_and_token_with_report(
714 pid,
715 cleanup_token,
716 9,
717 ));
718 }
719 }
720 let _ = child.kill();
721 let status = child.wait().ok();
722 report.refresh_survivor_status();
723 (status, report)
724}
725
726fn child_pid(child: &std::process::Child) -> Option<u32> {
727 let pid = child.id();
728 (pid > 0).then_some(pid)
729}
730
731pub(crate) fn drain_captured_pipe(
741 rx: &std::sync::mpsc::Receiver<Vec<u8>>,
742 killed: bool,
743 child_pid: u32,
744) -> Vec<u8> {
745 use std::sync::mpsc::RecvTimeoutError;
746 if killed {
747 return rx
748 .recv_timeout(Duration::from_millis(100))
749 .unwrap_or_default();
750 }
751 loop {
752 match rx.recv_timeout(Duration::from_millis(20)) {
753 Ok(buf) => return buf,
754 Err(RecvTimeoutError::Disconnected) => return Vec::new(),
755 Err(RecvTimeoutError::Timeout) => {
756 if requested() {
757 const SIGTERM: i32 = 15;
758 signal_pid_tree_and_group(child_pid, SIGTERM);
759 if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
760 signal_pid_tree_and_group(child_pid, 9);
761 return buf;
762 }
763 signal_pid_tree_and_group(child_pid, 9);
764 return rx
765 .recv_timeout(Duration::from_millis(100))
766 .unwrap_or_default();
767 }
768 }
769 }
770 }
771}
772
773pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
775 mut reader: R,
776) -> std::sync::mpsc::Receiver<Vec<u8>> {
777 let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
778 std::thread::spawn(move || {
779 let mut buf = Vec::new();
780 let _ = reader.read_to_end(&mut buf);
781 let _ = tx.send(buf);
782 });
783 rx
784}
785
786pub fn capture_output_interruptible(
793 command: &mut std::process::Command,
794) -> std::io::Result<std::process::Output> {
795 use std::process::Stdio;
796 command
797 .stdout(Stdio::piped())
798 .stderr(Stdio::piped())
799 .stdin(Stdio::null());
800 configure_kill_group(command);
801 let cleanup_token = new_process_cleanup_token();
802 command.env(PROCESS_CLEANUP_TOKEN_ENV, &cleanup_token);
803 let mut child = command.spawn()?;
804 let pid = child.id();
805 let rx_out = child.stdout.take().map(spawn_pipe_drain);
806 let rx_err = child.stderr.take().map(spawn_pipe_drain);
807
808 let (status, killed) = match wait_child_interruptible_with_cleanup_token(
809 &mut child,
810 None,
811 Some(&cleanup_token),
812 )? {
813 ChildWait::Exited(status) => (status, false),
814 ChildWait::TimedOut(_) => (std::process::ExitStatus::default(), true),
816 ChildWait::Interrupted(status, _) => (status.unwrap_or_default(), true),
817 };
818 let stdout = rx_out
819 .map(|rx| drain_captured_pipe(&rx, killed, pid))
820 .unwrap_or_default();
821 let stderr = rx_err
822 .map(|rx| drain_captured_pipe(&rx, killed, pid))
823 .unwrap_or_default();
824 Ok(std::process::Output {
825 status,
826 stdout,
827 stderr,
828 })
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn requested_is_false_without_context() {
837 assert!(!requested());
838 }
839
840 #[test]
841 fn installed_tracks_guard_lifetime() {
842 assert!(!installed());
843 let guard = install(None, None);
844 assert!(installed());
845 drop(guard);
846 assert!(!installed());
847 }
848
849 #[test]
850 fn cancel_token_trips_requested_and_guard_restores() {
851 let token = Arc::new(AtomicBool::new(false));
852 let guard = install(Some(token.clone()), None);
853 assert!(!requested());
854 token.store(true, Ordering::SeqCst);
855 assert!(requested());
856 drop(guard);
857 assert!(!requested());
858 }
859
860 #[test]
861 fn deadline_trips_requested() {
862 let expired = Instant::now()
863 .checked_sub(Duration::from_millis(1))
864 .expect("monotonic clock supports a 1ms test lookback");
865 let _guard = install(None, Some(expired));
866 assert!(requested());
867 }
868
869 #[test]
870 fn nested_installs_restore_in_order() {
871 let outer_token = Arc::new(AtomicBool::new(true));
872 let _outer = install(Some(outer_token), None);
873 assert!(requested());
874 {
875 let _inner = install(None, None);
876 assert!(!requested());
877 }
878 assert!(requested());
879 }
880
881 #[test]
882 fn active_cleanup_owner_scopes_are_disjoint() {
883 let owner = Arc::new(AtomicBool::new(false));
884 let _owned =
885 register_active_process_cleanup(None, "owned-scope-test", Some(Arc::clone(&owner)));
886 let _ownerless = register_active_process_cleanup(None, "ownerless-scope-test", None);
887
888 assert_eq!(
889 active_cleanup_tokens_for_cancel_token_for_test(&owner),
890 vec!["owned-scope-test".to_string()]
891 );
892 assert!(
893 ownerless_active_cleanup_tokens_for_test()
894 .iter()
895 .any(|token| token == "ownerless-scope-test"),
896 "explicit ownerless fallback should remain separately discoverable"
897 );
898 }
899
900 #[test]
901 fn active_cleanup_guard_unregisters_on_drop() {
902 let owner = Arc::new(AtomicBool::new(false));
903 let token = "guard-lifetime-test";
904 let guard = register_active_process_cleanup(None, token, Some(Arc::clone(&owner)));
905
906 assert!(
907 active_cleanup_tokens_for_cancel_token_for_test(&owner)
908 .iter()
909 .any(|entry| entry == token),
910 "active cleanup must remain registered while its guard is alive"
911 );
912
913 drop(guard);
914
915 assert!(
916 !active_cleanup_tokens_for_cancel_token_for_test(&owner)
917 .iter()
918 .any(|entry| entry == token),
919 "dropping the guard must unregister the cleanup token"
920 );
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn descendant_pids_from_parent_edges_returns_deepest_first_tree_only() {
926 let edges = [
927 (20, 10),
928 (30, 20),
929 (40, 20),
930 (50, 30),
931 (60, 99),
932 (70, 60),
933 (80, 90),
935 (90, 80),
936 ];
937
938 assert_eq!(
939 descendant_pids_from_parent_edges(10, &edges),
940 vec![50, 30, 40, 20]
941 );
942 assert_eq!(descendant_pids_from_parent_edges(99, &edges), vec![70, 60]);
943 assert_eq!(
944 descendant_pids_from_parent_edges(123, &edges),
945 Vec::<u32>::new()
946 );
947 }
948
949 #[cfg(unix)]
950 #[test]
951 fn descendant_processes_preserve_metadata_and_depth_order() {
952 let rows = [
953 (20, 10, Some("worker".to_string())),
954 (30, 20, Some("grandchild".to_string())),
955 (40, 20, None),
956 (50, 30, Some("leaf".to_string())),
957 ];
958
959 let descendants = descendant_processes_from_parent_edges(10, &rows);
960 let pids = descendants
961 .iter()
962 .map(|child| {
963 (
964 child.pid,
965 child.parent_pid,
966 child.depth,
967 child.command_name.as_deref(),
968 )
969 })
970 .collect::<Vec<_>>();
971 assert_eq!(
972 pids,
973 vec![
974 (50, Some(30), 3, Some("leaf")),
975 (30, Some(20), 2, Some("grandchild")),
976 (40, Some(20), 2, None),
977 (20, Some(10), 1, Some("worker")),
978 ]
979 );
980 }
981
982 #[cfg(unix)]
983 #[test]
984 fn command_name_keeps_only_argv0_basename() {
985 let command = vec![
986 std::ffi::OsString::from("/usr/local/bin/tool"),
987 std::ffi::OsString::from("--api-key"),
988 std::ffi::OsString::from("secret-value"),
989 std::ffi::OsString::from("plain"),
990 ];
991
992 assert_eq!(command_name(&command).as_deref(), Some("tool"));
993 assert_eq!(command_name(&[]).as_deref(), None);
994 }
995
996 #[cfg(unix)]
997 #[test]
998 fn process_has_cleanup_token_requires_exact_marker_entry() {
999 let token = "tok-123";
1000 let env = vec![
1001 std::ffi::OsString::from("PATH=/usr/bin"),
1002 std::ffi::OsString::from(format!("{PROCESS_CLEANUP_TOKEN_ENV}={token}")),
1003 ];
1004 assert!(process_has_cleanup_token(&env, token));
1005 assert!(!process_has_cleanup_token(&env, "tok"));
1006 assert!(!process_has_cleanup_token(
1007 &[std::ffi::OsString::from("OTHER=tok-123")],
1008 token
1009 ));
1010 }
1011
1012 #[cfg(unix)]
1013 #[test]
1014 fn interrupted_wait_kills_process_group() {
1015 let mut command = std::process::Command::new("sh");
1017 command.args(["-c", "sleep 30 & wait"]);
1018 configure_kill_group(&mut command);
1019 let mut child = command.spawn().expect("spawn sh");
1020 let pgid = child.id();
1021
1022 let cancel = Arc::new(AtomicBool::new(true));
1023 let _guard = install(Some(cancel), None);
1024 let started = Instant::now();
1025 let outcome = wait_child_interruptible(&mut child, None).expect("wait");
1026 assert!(matches!(outcome, ChildWait::Interrupted(_, _)));
1027 assert!(started.elapsed() < Duration::from_secs(10));
1028
1029 extern "C" {
1031 fn kill(pid: i32, sig: i32) -> i32;
1032 }
1033 let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
1034 let deadline = Instant::now() + Duration::from_secs(5);
1035 while !group_gone() && Instant::now() < deadline {
1036 std::thread::sleep(Duration::from_millis(50));
1037 }
1038 assert!(group_gone(), "process group {pgid} survived interrupt");
1039 }
1040}