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#[derive(Clone, Default)]
44struct OpInterrupt {
45    cancel: Option<Arc<AtomicBool>>,
46    deadline: Option<Instant>,
47}
48
49thread_local! {
50    static CURRENT: RefCell<Option<OpInterrupt>> = const { RefCell::new(None) };
51}
52
53/// Guard returned by [`install`]. Restores the previously installed
54/// interrupt context on drop so nested builtin dispatch (child VMs running
55/// on the same thread) composes correctly.
56pub struct OpInterruptGuard {
57    // Outer Option = "guard owes a restore"; inner Option is the previous
58    // thread-local slot value (which can itself be None).
59    #[allow(clippy::option_option)]
60    prev: Option<Option<OpInterrupt>>,
61}
62
63impl Drop for OpInterruptGuard {
64    fn drop(&mut self) {
65        if let Some(prev) = self.prev.take() {
66            CURRENT.with(|slot| *slot.borrow_mut() = prev);
67        }
68    }
69}
70
71/// Install the interrupt sources a blocking builtin on this thread should
72/// observe: an optional cooperative cancel token and an optional deadline.
73/// The VM calls this around sync builtin dispatch; tests use it to simulate
74/// scope cancellation without booting a full interpreter.
75pub fn install(cancel: Option<Arc<AtomicBool>>, deadline: Option<Instant>) -> OpInterruptGuard {
76    let prev = CURRENT.with(|slot| slot.borrow_mut().replace(OpInterrupt { cancel, deadline }));
77    OpInterruptGuard { prev: Some(prev) }
78}
79
80/// Returns `true` when an interrupt context is installed on this thread.
81///
82/// This is separate from [`requested`] so blocking operations can decide
83/// whether to use a short heartbeat poll or a true indefinite wait.
84pub fn installed() -> bool {
85    CURRENT.with(|slot| slot.borrow().is_some())
86}
87
88/// Returns `true` when the interrupt context installed on this thread has
89/// fired: the cancel token is set, or the deadline has passed. Cheap enough
90/// to call from a ~20ms poll loop. Returns `false` when nothing is armed.
91pub fn requested() -> bool {
92    CURRENT.with(|slot| {
93        let ctx = slot.borrow();
94        let Some(ctx) = ctx.as_ref() else {
95            return false;
96        };
97        if ctx
98            .cancel
99            .as_ref()
100            .is_some_and(|token| token.load(Ordering::SeqCst))
101        {
102            return true;
103        }
104        ctx.deadline
105            .is_some_and(|deadline| Instant::now() >= deadline)
106    })
107}
108
109/// Put the child in its own process group (`setpgid(0, 0)`) so a later
110/// group signal reaps ordinary grandchildren too. No-op on non-Unix targets — group
111/// semantics are Unix-first; Windows callers fall back to killing the
112/// direct child handle (`TerminateProcess` via `Child::kill`).
113pub fn configure_kill_group(command: &mut std::process::Command) {
114    #[cfg(unix)]
115    {
116        use std::os::unix::process::CommandExt;
117        command.process_group(0);
118    }
119    #[cfg(not(unix))]
120    {
121        let _ = command;
122    }
123}
124
125/// Signal a pid and its process group. No-op on non-Unix targets.
126pub fn signal_pid_and_group(pid: u32, signal: i32) {
127    #[cfg(unix)]
128    {
129        // SAFETY: kill(2) takes a pid_t (i32 on all Unix targets) and a
130        // signal number; calling it with any valid signal is well-defined.
131        extern "C" {
132            fn kill(pid: i32, sig: i32) -> i32;
133        }
134        unsafe {
135            kill(-(pid as i32), signal);
136            kill(pid as i32, signal);
137        }
138    }
139    #[cfg(not(unix))]
140    {
141        let _ = (pid, signal);
142    }
143}
144
145/// Signal a pid, its process group, and every descendant process visible in
146/// the system process table. Descendants are signalled deepest-first so a
147/// child that escaped into its own process group (for example via `setsid`)
148/// cannot survive a timeout merely because it left the original group.
149pub fn signal_pid_tree_and_group(pid: u32, signal: i32) {
150    #[cfg(unix)]
151    {
152        for child_pid in descendant_pids(pid) {
153            signal_pid_and_group(child_pid, signal);
154        }
155        signal_pid_and_group(pid, signal);
156    }
157    #[cfg(not(unix))]
158    {
159        let _ = (pid, signal);
160    }
161}
162
163#[cfg(unix)]
164fn descendant_pids(root: u32) -> Vec<u32> {
165    use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
166
167    let mut sys = System::new();
168    sys.refresh_processes_specifics(ProcessesToUpdate::All, false, ProcessRefreshKind::nothing());
169    let edges = sys
170        .processes()
171        .iter()
172        .filter_map(|(pid, process)| Some((pid.as_u32(), process.parent()?.as_u32())))
173        .collect::<Vec<_>>();
174    descendant_pids_from_parent_edges(root, &edges)
175}
176
177#[cfg(unix)]
178fn descendant_pids_from_parent_edges(root: u32, edges: &[(u32, u32)]) -> Vec<u32> {
179    use std::collections::{HashMap, HashSet};
180
181    let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
182    for &(pid, parent) in edges {
183        children.entry(parent).or_default().push(pid);
184    }
185
186    let mut seen = HashSet::new();
187    let mut stack = vec![(root, 0usize)];
188    let mut descendants = Vec::new();
189    while let Some((pid, depth)) = stack.pop() {
190        if !seen.insert(pid) {
191            continue;
192        }
193        if pid != root {
194            descendants.push((pid, depth));
195        }
196        if let Some(kids) = children.get(&pid) {
197            for &child in kids {
198                stack.push((child, depth + 1));
199            }
200        }
201    }
202
203    descendants.sort_by(|(left_pid, left_depth), (right_pid, right_depth)| {
204        right_depth
205            .cmp(left_depth)
206            .then_with(|| left_pid.cmp(right_pid))
207    });
208    descendants.into_iter().map(|(pid, _depth)| pid).collect()
209}
210
211/// How an interruptible child wait ended.
212pub enum ChildWait {
213    /// The child exited on its own.
214    Exited(std::process::ExitStatus),
215    /// The caller-supplied timeout elapsed; the child tree/group was killed.
216    TimedOut,
217    /// [`requested`] fired; the child tree/group was SIGTERMed and, after
218    /// [`SUBPROCESS_TERM_GRACE`], SIGKILLed. Carries the reaped status when
219    /// the OS reported one.
220    Interrupted(Option<std::process::ExitStatus>),
221}
222
223/// Wait for `child` while polling [`requested`] and the optional timeout.
224///
225/// Used by the VM-side `process.*` builtins (`exec`, `shell`, `exec_opts`,
226/// `spawn_captured`). The hostlib `run_command` family implements the same
227/// protocol inside its `ProcessSpawner` abstraction. Callers should have
228/// spawned the child with [`configure_kill_group`] so group signals reach
229/// ordinary grandchildren; escaped descendants are reaped by process-tree
230/// scanning on Unix.
231pub fn wait_child_interruptible(
232    child: &mut std::process::Child,
233    timeout: Option<Duration>,
234) -> std::io::Result<ChildWait> {
235    let deadline = timeout.map(|limit| Instant::now() + limit);
236    loop {
237        if let Some(status) = child.try_wait()? {
238            return Ok(ChildWait::Exited(status));
239        }
240        if requested() {
241            let status = terminate_child_group(child);
242            return Ok(ChildWait::Interrupted(status));
243        }
244        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
245            // Timeout keeps its historical semantics: immediate SIGKILL.
246            if let Some(pid) = child_pid(child) {
247                signal_pid_tree_and_group(pid, 9);
248            }
249            let _ = child.kill();
250            let _ = child.wait();
251            return Ok(ChildWait::TimedOut);
252        }
253        std::thread::sleep(Duration::from_millis(20));
254    }
255}
256
257/// Gracefully terminate `child` and its process tree/group: SIGTERM, wait up to
258/// [`SUBPROCESS_TERM_GRACE`], then SIGKILL. Reaps the child and returns its
259/// exit status when available. On non-Unix targets this is a best-effort
260/// direct `Child::kill` (`TerminateProcess`), which does not reach
261/// grandchildren.
262pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
263    #[cfg(unix)]
264    {
265        if let Some(pid) = child_pid(child) {
266            const SIGTERM: i32 = 15;
267            signal_pid_tree_and_group(pid, SIGTERM);
268            let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
269            loop {
270                match child.try_wait() {
271                    Ok(Some(status)) => {
272                        // The direct child is gone, but SIGTERM-immune
273                        // descendants may linger — sweep the group.
274                        signal_pid_tree_and_group(pid, 9);
275                        return Some(status);
276                    }
277                    Ok(None) => {
278                        if Instant::now() >= grace_deadline {
279                            break;
280                        }
281                        std::thread::sleep(Duration::from_millis(20));
282                    }
283                    Err(_) => break,
284                }
285            }
286            signal_pid_tree_and_group(pid, 9);
287        }
288    }
289    let _ = child.kill();
290    child.wait().ok()
291}
292
293fn child_pid(child: &std::process::Child) -> Option<u32> {
294    let pid = child.id();
295    (pid > 0).then_some(pid)
296}
297
298/// Collect one captured pipe from a drain thread that sends the full buffer
299/// on EOF.
300///
301/// `killed == true` (the child group was already signalled) keeps a 100ms
302/// best-effort window for partial output. Otherwise wait for EOF like
303/// `Command::output` would — but keep observing [`requested`], because a
304/// lingering grandchild that inherited the pipe can hold it open long after
305/// the direct child exited; on interrupt the group gets the same SIGTERM →
306/// grace → SIGKILL treatment.
307pub(crate) fn drain_captured_pipe(
308    rx: &std::sync::mpsc::Receiver<Vec<u8>>,
309    killed: bool,
310    child_pid: u32,
311) -> Vec<u8> {
312    use std::sync::mpsc::RecvTimeoutError;
313    if killed {
314        return rx
315            .recv_timeout(Duration::from_millis(100))
316            .unwrap_or_default();
317    }
318    loop {
319        match rx.recv_timeout(Duration::from_millis(20)) {
320            Ok(buf) => return buf,
321            Err(RecvTimeoutError::Disconnected) => return Vec::new(),
322            Err(RecvTimeoutError::Timeout) => {
323                if requested() {
324                    const SIGTERM: i32 = 15;
325                    signal_pid_tree_and_group(child_pid, SIGTERM);
326                    if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
327                        signal_pid_tree_and_group(child_pid, 9);
328                        return buf;
329                    }
330                    signal_pid_tree_and_group(child_pid, 9);
331                    return rx
332                        .recv_timeout(Duration::from_millis(100))
333                        .unwrap_or_default();
334                }
335            }
336        }
337    }
338}
339
340/// Spawn a drain thread that reads `reader` to EOF and sends the buffer.
341pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
342    mut reader: R,
343) -> std::sync::mpsc::Receiver<Vec<u8>> {
344    let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
345    std::thread::spawn(move || {
346        let mut buf = Vec::new();
347        let _ = reader.read_to_end(&mut buf);
348        let _ = tx.send(buf);
349    });
350    rx
351}
352
353/// Interrupt-aware replacement for `Command::output()`: the child runs in
354/// its own kill group, stdout/stderr are captured in full, stdin is closed,
355/// and the wait polls [`requested`]. When an interrupt fires the whole
356/// group is gracefully terminated and the (signal-terminated) status is
357/// returned — the interpreter surfaces the pending cancellation / deadline
358/// error at the next op boundary.
359pub fn capture_output_interruptible(
360    command: &mut std::process::Command,
361) -> std::io::Result<std::process::Output> {
362    use std::process::Stdio;
363    command
364        .stdout(Stdio::piped())
365        .stderr(Stdio::piped())
366        .stdin(Stdio::null());
367    configure_kill_group(command);
368    let mut child = command.spawn()?;
369    let pid = child.id();
370    let rx_out = child.stdout.take().map(spawn_pipe_drain);
371    let rx_err = child.stderr.take().map(spawn_pipe_drain);
372
373    let (status, killed) = match wait_child_interruptible(&mut child, None)? {
374        ChildWait::Exited(status) => (status, false),
375        // No timeout is armed here, but keep the arm total.
376        ChildWait::TimedOut => (std::process::ExitStatus::default(), true),
377        ChildWait::Interrupted(status) => (status.unwrap_or_default(), true),
378    };
379    let stdout = rx_out
380        .map(|rx| drain_captured_pipe(&rx, killed, pid))
381        .unwrap_or_default();
382    let stderr = rx_err
383        .map(|rx| drain_captured_pipe(&rx, killed, pid))
384        .unwrap_or_default();
385    Ok(std::process::Output {
386        status,
387        stdout,
388        stderr,
389    })
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn requested_is_false_without_context() {
398        assert!(!requested());
399    }
400
401    #[test]
402    fn installed_tracks_guard_lifetime() {
403        assert!(!installed());
404        let guard = install(None, None);
405        assert!(installed());
406        drop(guard);
407        assert!(!installed());
408    }
409
410    #[test]
411    fn cancel_token_trips_requested_and_guard_restores() {
412        let token = Arc::new(AtomicBool::new(false));
413        let guard = install(Some(token.clone()), None);
414        assert!(!requested());
415        token.store(true, Ordering::SeqCst);
416        assert!(requested());
417        drop(guard);
418        assert!(!requested());
419    }
420
421    #[test]
422    fn deadline_trips_requested() {
423        let expired = Instant::now()
424            .checked_sub(Duration::from_millis(1))
425            .expect("monotonic clock supports a 1ms test lookback");
426        let _guard = install(None, Some(expired));
427        assert!(requested());
428    }
429
430    #[test]
431    fn nested_installs_restore_in_order() {
432        let outer_token = Arc::new(AtomicBool::new(true));
433        let _outer = install(Some(outer_token), None);
434        assert!(requested());
435        {
436            let _inner = install(None, None);
437            assert!(!requested());
438        }
439        assert!(requested());
440    }
441
442    #[cfg(unix)]
443    #[test]
444    fn descendant_pids_from_parent_edges_returns_deepest_first_tree_only() {
445        let edges = [
446            (20, 10),
447            (30, 20),
448            (40, 20),
449            (50, 30),
450            (60, 99),
451            (70, 60),
452            // A malformed process table cycle should not hang traversal.
453            (80, 90),
454            (90, 80),
455        ];
456
457        assert_eq!(
458            descendant_pids_from_parent_edges(10, &edges),
459            vec![50, 30, 40, 20]
460        );
461        assert_eq!(descendant_pids_from_parent_edges(99, &edges), vec![70, 60]);
462        assert_eq!(
463            descendant_pids_from_parent_edges(123, &edges),
464            Vec::<u32>::new()
465        );
466    }
467
468    #[cfg(unix)]
469    #[test]
470    fn interrupted_wait_kills_process_group() {
471        // Child spawns a grandchild; the whole group must die on interrupt.
472        let mut command = std::process::Command::new("sh");
473        command.args(["-c", "sleep 30 & wait"]);
474        configure_kill_group(&mut command);
475        let mut child = command.spawn().expect("spawn sh");
476        let pgid = child.id();
477
478        let cancel = Arc::new(AtomicBool::new(true));
479        let _guard = install(Some(cancel), None);
480        let started = Instant::now();
481        let outcome = wait_child_interruptible(&mut child, None).expect("wait");
482        assert!(matches!(outcome, ChildWait::Interrupted(_)));
483        assert!(started.elapsed() < Duration::from_secs(10));
484
485        // kill(-pgid, 0) fails with ESRCH once every member is gone.
486        extern "C" {
487            fn kill(pid: i32, sig: i32) -> i32;
488        }
489        let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
490        let deadline = Instant::now() + Duration::from_secs(5);
491        while !group_gone() && Instant::now() < deadline {
492            std::thread::sleep(Duration::from_millis(50));
493        }
494        assert!(group_gone(), "process group {pgid} survived interrupt");
495    }
496}