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::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37/// How long a subprocess gets to exit after SIGTERM before the whole
38/// process group is SIGKILLed. Deliberately longer than the interpreter's
39/// 250ms async-op cancel grace (`CANCEL_GRACE_ASYNC_OP`): child processes
40/// often need to flush buffers / remove lock files on SIGTERM.
41pub const SUBPROCESS_TERM_GRACE: Duration = Duration::from_secs(2);
42
43/// Structural evidence collected when Harn kills a child process tree.
44#[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/// A descendant process Harn targeted during cleanup.
105#[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
155/// Guard returned by [`install`]. Restores the previously installed
156/// interrupt context on drop so nested builtin dispatch (child VMs running
157/// on the same thread) composes correctly.
158pub struct OpInterruptGuard {
159    // Outer Option = "guard owes a restore"; inner Option is the previous
160    // thread-local slot value (which can itself be None).
161    #[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
173/// Install the interrupt sources a blocking builtin on this thread should
174/// observe: an optional cooperative cancel token and an optional deadline.
175/// The VM calls this around sync builtin dispatch; tests use it to simulate
176/// scope cancellation without booting a full interpreter.
177pub 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
182/// Returns `true` when an interrupt context is installed on this thread.
183///
184/// This is separate from [`requested`] so blocking operations can decide
185/// whether to use a short heartbeat poll or a true indefinite wait.
186pub fn installed() -> bool {
187    CURRENT.with(|slot| slot.borrow().is_some())
188}
189
190/// Returns `true` when the interrupt context installed on this thread has
191/// fired: the cancel token is set, or the deadline has passed. Cheap enough
192/// to call from a ~20ms poll loop. Returns `false` when nothing is armed.
193pub 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
211/// Put the child in its own process group (`setpgid(0, 0)`) so a later
212/// group signal reaps ordinary grandchildren too. No-op on non-Unix targets — group
213/// semantics are Unix-first; Windows callers fall back to killing the
214/// direct child handle (`TerminateProcess` via `Child::kill`).
215pub 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
227/// Signal a pid and its process group. No-op on non-Unix targets.
228pub fn signal_pid_and_group(pid: u32, signal: i32) {
229    #[cfg(unix)]
230    {
231        // SAFETY: kill(2) takes a pid_t (i32 on all Unix targets) and a
232        // signal number; calling it with any valid signal is well-defined.
233        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
247/// Signal a pid, its process group, and every descendant process visible in
248/// the system process table. Descendants are signalled deepest-first so a
249/// child that escaped into its own process group (for example via `setsid`)
250/// cannot survive a timeout merely because it left the original group.
251pub fn signal_pid_tree_and_group(pid: u32, signal: i32) {
252    let _ = signal_pid_tree_and_group_with_report(pid, signal);
253}
254
255/// Signal a pid, its process group, and visible descendants, returning the
256/// structural targets observed before signaling.
257pub 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
375/// How an interruptible child wait ended.
376pub enum ChildWait {
377    /// The child exited on its own.
378    Exited(std::process::ExitStatus),
379    /// The caller-supplied timeout elapsed; the child tree/group was killed.
380    TimedOut(ProcessCleanupReport),
381    /// [`requested`] fired; the child tree/group was SIGTERMed and, after
382    /// [`SUBPROCESS_TERM_GRACE`], SIGKILLed. Carries the reaped status when
383    /// the OS reported one.
384    Interrupted(Option<std::process::ExitStatus>, ProcessCleanupReport),
385}
386
387/// Wait for `child` while polling [`requested`] and the optional timeout.
388///
389/// Used by the VM-side `process.*` builtins (`exec`, `shell`, `exec_opts`,
390/// `spawn_captured`). The hostlib `run_command` family implements the same
391/// protocol inside its `ProcessSpawner` abstraction. Callers should have
392/// spawned the child with [`configure_kill_group`] so group signals reach
393/// ordinary grandchildren; escaped descendants are reaped by process-tree
394/// scanning on Unix.
395pub 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            // Timeout keeps its historical semantics: immediate SIGKILL.
410            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
422/// Gracefully terminate `child` and its process tree/group: SIGTERM, wait up to
423/// [`SUBPROCESS_TERM_GRACE`], then SIGKILL. Reaps the child and returns its
424/// exit status when available. On non-Unix targets this is a best-effort
425/// direct `Child::kill` (`TerminateProcess`), which does not reach
426/// grandchildren.
427pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
428    terminate_child_group_with_report(child).0
429}
430
431/// Like [`terminate_child_group`], but also returns a structural cleanup
432/// report describing descendants observed and signalled.
433pub 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                        // The direct child is gone, but SIGTERM-immune
449                        // descendants may linger — sweep the group.
450                        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
477/// Collect one captured pipe from a drain thread that sends the full buffer
478/// on EOF.
479///
480/// `killed == true` (the child group was already signalled) keeps a 100ms
481/// best-effort window for partial output. Otherwise wait for EOF like
482/// `Command::output` would — but keep observing [`requested`], because a
483/// lingering grandchild that inherited the pipe can hold it open long after
484/// the direct child exited; on interrupt the group gets the same SIGTERM →
485/// grace → SIGKILL treatment.
486pub(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
519/// Spawn a drain thread that reads `reader` to EOF and sends the buffer.
520pub(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
532/// Interrupt-aware replacement for `Command::output()`: the child runs in
533/// its own kill group, stdout/stderr are captured in full, stdin is closed,
534/// and the wait polls [`requested`]. When an interrupt fires the whole
535/// group is gracefully terminated and the (signal-terminated) status is
536/// returned — the interpreter surfaces the pending cancellation / deadline
537/// error at the next op boundary.
538pub 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        // No timeout is armed here, but keep the arm total.
555        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            // A malformed process table cycle should not hang traversal.
632            (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        // Child spawns a grandchild; the whole group must die on interrupt.
698        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        // kill(-pgid, 0) fails with ESRCH once every member is gone.
712        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}