Skip to main content

harn_vm/
op_interrupt.rs

1//! Cooperative interrupt observation for blocking sync builtins.
2//!
3//! Sync builtins (including every subprocess-spawning path: the hostlib
4//! `run_command` tool family and the VM-side `process.exec`/`exec_opts`
5//! builtins) execute inline on the VM's async task. While one of them
6//! blocks — typically waiting on a child process — the interpreter's
7//! `tokio::select!` cancel/deadline race in
8//! `vm/execution.rs::execute_op_with_scope_interrupts` cannot run: the op
9//! future never yields, so scope cancellation, `deadline` expiry, and host
10//! aborts used to wait for the child to exit on its own (orphaning it on
11//! task abort / VM drop).
12//!
13//! This module closes that gap cooperatively. Before invoking a sync
14//! builtin, the VM installs the *currently armed* interrupt sources — its
15//! host cancel token (`Arc<AtomicBool>`) and the innermost deadline — into
16//! a thread-local via [`install`]. Blocking wait loops poll [`requested`]
17//! (they already poll `try_wait` every ~20ms) and, when it fires,
18//! gracefully terminate their child process tree/group (SIGTERM, then SIGKILL
19//! after [`SUBPROCESS_TERM_GRACE`]) and return. The VM then surfaces the
20//! ordinary cancellation / deadline error at the next op boundary.
21//!
22//! Trigger coverage:
23//! - **Scope / `parallel` cancellation and VM drop**: spawned-task child
24//!   VMs share the `Arc<AtomicBool>` stored in their `VmTaskHandle`;
25//!   `Vm::cancel_spawned_tasks` (also called from `Drop for Vm`) sets it,
26//!   which the blocked wait loop observes.
27//! - **Host abort**: hosts cancel a VM by setting its cancel token — same
28//!   observation path.
29//! - **`deadline` expiry**: the deadline `Instant` is captured when the
30//!   builtin starts; the wait loop compares against `Instant::now()`.
31
32use 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
38/// Private environment marker inherited by subprocess descendants. Cleanup uses
39/// this token to rediscover escaped descendants that have reparented or moved to
40/// a different process group before the parent-edge scan runs.
41pub const PROCESS_CLEANUP_TOKEN_ENV: &str = "HARN_PROCESS_CLEANUP_TOKEN";
42
43/// How long a subprocess gets to exit after SIGTERM before the whole
44/// process group is SIGKILLed. Deliberately longer than the interpreter's
45/// 250ms async-op cancel grace (`CANCEL_GRACE_ASYNC_OP`): child processes
46/// often need to flush buffers / remove lock files on SIGTERM.
47pub 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/// Structural evidence collected when Harn kills a child process tree.
56#[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/// A descendant process Harn targeted during cleanup.
117#[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
178/// Registration guard for an asynchronously waited child process. The VM's
179/// sync process paths poll [`requested`] directly, but Tokio wait paths can be
180/// parked inside `wait_with_output()`/`child.wait()` and need an out-of-band
181/// cleanup hook when `harn run` is interrupted or reaches its run deadline.
182pub 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
219/// Signal every actively registered async child process tree. Prefer
220/// [`signal_active_process_cleanups_for_cancel_token`] or
221/// [`signal_ownerless_active_process_cleanups`] when the caller can avoid a
222/// process-global sweep.
223pub 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
294/// Guard returned by [`install`]. Restores the previously installed
295/// interrupt context on drop so nested builtin dispatch (child VMs running
296/// on the same thread) composes correctly.
297pub struct OpInterruptGuard {
298    // Outer Option = "guard owes a restore"; inner Option is the previous
299    // thread-local slot value (which can itself be None).
300    #[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
312/// Install the interrupt sources a blocking builtin on this thread should
313/// observe: an optional cooperative cancel token and an optional deadline.
314/// The VM calls this around sync builtin dispatch; tests use it to simulate
315/// scope cancellation without booting a full interpreter.
316pub 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
321/// Returns `true` when an interrupt context is installed on this thread.
322///
323/// This is separate from [`requested`] so blocking operations can decide
324/// whether to use a short heartbeat poll or a true indefinite wait.
325pub fn installed() -> bool {
326    CURRENT.with(|slot| slot.borrow().is_some())
327}
328
329/// Returns `true` when the interrupt context installed on this thread has
330/// fired: the cancel token is set, or the deadline has passed. Cheap enough
331/// to call from a ~20ms poll loop. Returns `false` when nothing is armed.
332pub 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
350/// Put the child in its own process group (`setpgid(0, 0)`) so a later
351/// group signal reaps ordinary grandchildren too. No-op on non-Unix targets — group
352/// semantics are Unix-first; Windows callers fall back to killing the
353/// direct child handle (`TerminateProcess` via `Child::kill`).
354pub 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
366/// Tokio-process variant of [`configure_kill_group`]. Tokio's command wrapper
367/// does not flow through `std::process::Command`, so async spawn paths must opt
368/// in separately before they rely on group/tree cleanup.
369pub 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
380/// Signal a pid and its process group. No-op on non-Unix targets.
381pub fn signal_pid_and_group(pid: u32, signal: i32) {
382    #[cfg(unix)]
383    {
384        // SAFETY: kill(2) takes a pid_t (i32 on all Unix targets) and a
385        // signal number; calling it with any valid signal is well-defined.
386        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
400/// Signal a pid, its process group, and every descendant process visible in
401/// the system process table. Descendants are signalled deepest-first so a
402/// child that escaped into its own process group (for example via `setsid`)
403/// cannot survive a timeout merely because it left the original group.
404pub fn signal_pid_tree_and_group(pid: u32, signal: i32) {
405    let _ = signal_pid_tree_and_group_with_report(pid, signal);
406}
407
408/// Signal a pid, its process group, and visible descendants, returning the
409/// structural targets observed before signaling.
410pub 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
414/// Signal a pid, its process group, visible descendants, and any same-token
415/// process that inherited Harn's cleanup marker. The token path closes the
416/// reparented-descendant hole in pure parent-edge scanning: a child can `setsid`
417/// and outlive its direct parent, but it keeps the inherited environment unless
418/// it deliberately scrubs it.
419pub 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/// Signal a process tree and cleanup-token cohort without signaling the
455/// `preserved_pgid`.
456///
457/// Owner-death guardians use this to kill every worker in their own group,
458/// reap adopted descendants, and only then terminate the now-empty group
459/// leader. Processes that escaped into another group still receive a group
460/// signal.
461#[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
648/// How an interruptible child wait ended.
649pub enum ChildWait {
650    /// The child exited on its own.
651    Exited(std::process::ExitStatus),
652    /// The caller-supplied timeout elapsed; the child tree/group was killed.
653    TimedOut(ProcessCleanupReport),
654    /// [`requested`] fired; the child tree/group was SIGTERMed and, after
655    /// [`SUBPROCESS_TERM_GRACE`], SIGKILLed. Carries the reaped status when
656    /// the OS reported one.
657    Interrupted(Option<std::process::ExitStatus>, ProcessCleanupReport),
658}
659
660/// Wait for `child` while polling [`requested`] and the optional timeout.
661///
662/// Used by the VM-side `process.*` builtins (`exec`, `shell`, `exec_opts`,
663/// `spawn_captured`). The hostlib `run_command` family implements the same
664/// protocol inside its `ProcessSpawner` abstraction. Callers should have
665/// spawned the child with [`configure_kill_group`] so group signals reach
666/// ordinary grandchildren; escaped descendants are reaped by process-tree
667/// scanning on Unix.
668pub 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            // Timeout keeps its historical semantics: immediate SIGKILL.
692            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
704/// Gracefully terminate `child` and its process tree/group: SIGTERM, wait up to
705/// [`SUBPROCESS_TERM_GRACE`], then SIGKILL. Reaps the child and returns its
706/// exit status when available. On non-Unix targets this is a best-effort
707/// direct `Child::kill` (`TerminateProcess`), which does not reach
708/// grandchildren.
709pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
710    terminate_child_group_with_report(child).0
711}
712
713/// Like [`terminate_child_group`], but also returns a structural cleanup
714/// report describing descendants observed and signalled.
715pub 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                        // The direct child is gone, but SIGTERM-immune
740                        // descendants may linger — sweep the group.
741                        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
776/// Collect one captured pipe from a drain thread that sends the full buffer
777/// on EOF.
778///
779/// `killed == true` (the child group was already signalled) keeps a 100ms
780/// best-effort window for partial output. Otherwise wait for EOF like
781/// `Command::output` would — but keep observing [`requested`], because a
782/// lingering grandchild that inherited the pipe can hold it open long after
783/// the direct child exited; on interrupt the group gets the same SIGTERM →
784/// grace → SIGKILL treatment.
785pub(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
818/// Spawn a drain thread that reads `reader` to EOF and sends the buffer.
819pub(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
831/// Interrupt-aware replacement for `Command::output()`: the child runs in
832/// its own kill group, stdout/stderr are captured in full, stdin is closed,
833/// and the wait polls [`requested`]. When an interrupt fires the whole
834/// group is gracefully terminated and the (signal-terminated) status is
835/// returned — the interpreter surfaces the pending cancellation / deadline
836/// error at the next op boundary.
837pub 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        // No timeout is armed here, but keep the arm total.
860        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            // A malformed process table cycle should not hang traversal.
979            (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        // Child spawns a grandchild; the whole group must die on interrupt.
1061        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        // kill(-pgid, 0) fails with ESRCH once every member is gone.
1075        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}