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 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 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/// How an interruptible child wait ended.
146pub enum ChildWait {
147    /// The child exited on its own.
148    Exited(std::process::ExitStatus),
149    /// The caller-supplied timeout elapsed; the child (group) was killed.
150    TimedOut,
151    /// [`requested`] fired; the child group was SIGTERMed and, after
152    /// [`SUBPROCESS_TERM_GRACE`], SIGKILLed. Carries the reaped status when
153    /// the OS reported one.
154    Interrupted(Option<std::process::ExitStatus>),
155}
156
157/// Wait for `child` while polling [`requested`] and the optional timeout.
158///
159/// Used by the VM-side `process.*` builtins (`exec`, `shell`, `exec_opts`,
160/// `spawn_captured`). The hostlib `run_command` family implements the same
161/// protocol inside its `ProcessSpawner` abstraction. Callers should have
162/// spawned the child with [`configure_kill_group`] so the group signals
163/// reach grandchildren.
164pub fn wait_child_interruptible(
165    child: &mut std::process::Child,
166    timeout: Option<Duration>,
167) -> std::io::Result<ChildWait> {
168    let deadline = timeout.map(|limit| Instant::now() + limit);
169    loop {
170        if let Some(status) = child.try_wait()? {
171            return Ok(ChildWait::Exited(status));
172        }
173        if requested() {
174            let status = terminate_child_group(child);
175            return Ok(ChildWait::Interrupted(status));
176        }
177        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
178            // Timeout keeps its historical semantics: immediate SIGKILL.
179            if let Some(pid) = child_pid(child) {
180                signal_pid_and_group(pid, 9);
181            }
182            let _ = child.kill();
183            let _ = child.wait();
184            return Ok(ChildWait::TimedOut);
185        }
186        std::thread::sleep(Duration::from_millis(20));
187    }
188}
189
190/// Gracefully terminate `child` and its process group: SIGTERM, wait up to
191/// [`SUBPROCESS_TERM_GRACE`], then SIGKILL. Reaps the child and returns its
192/// exit status when available. On non-Unix targets this is a best-effort
193/// direct `Child::kill` (`TerminateProcess`), which does not reach
194/// grandchildren.
195pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
196    #[cfg(unix)]
197    {
198        if let Some(pid) = child_pid(child) {
199            const SIGTERM: i32 = 15;
200            signal_pid_and_group(pid, SIGTERM);
201            let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
202            loop {
203                match child.try_wait() {
204                    Ok(Some(status)) => {
205                        // The direct child is gone, but SIGTERM-immune
206                        // descendants may linger — sweep the group.
207                        signal_pid_and_group(pid, 9);
208                        return Some(status);
209                    }
210                    Ok(None) => {
211                        if Instant::now() >= grace_deadline {
212                            break;
213                        }
214                        std::thread::sleep(Duration::from_millis(20));
215                    }
216                    Err(_) => break,
217                }
218            }
219            signal_pid_and_group(pid, 9);
220        }
221    }
222    let _ = child.kill();
223    child.wait().ok()
224}
225
226fn child_pid(child: &std::process::Child) -> Option<u32> {
227    let pid = child.id();
228    (pid > 0).then_some(pid)
229}
230
231/// Collect one captured pipe from a drain thread that sends the full buffer
232/// on EOF.
233///
234/// `killed == true` (the child group was already signalled) keeps a 100ms
235/// best-effort window for partial output. Otherwise wait for EOF like
236/// `Command::output` would — but keep observing [`requested`], because a
237/// lingering grandchild that inherited the pipe can hold it open long after
238/// the direct child exited; on interrupt the group gets the same SIGTERM →
239/// grace → SIGKILL treatment.
240pub(crate) fn drain_captured_pipe(
241    rx: &std::sync::mpsc::Receiver<Vec<u8>>,
242    killed: bool,
243    child_pid: u32,
244) -> Vec<u8> {
245    use std::sync::mpsc::RecvTimeoutError;
246    if killed {
247        return rx
248            .recv_timeout(Duration::from_millis(100))
249            .unwrap_or_default();
250    }
251    loop {
252        match rx.recv_timeout(Duration::from_millis(20)) {
253            Ok(buf) => return buf,
254            Err(RecvTimeoutError::Disconnected) => return Vec::new(),
255            Err(RecvTimeoutError::Timeout) => {
256                if requested() {
257                    const SIGTERM: i32 = 15;
258                    signal_pid_and_group(child_pid, SIGTERM);
259                    if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
260                        signal_pid_and_group(child_pid, 9);
261                        return buf;
262                    }
263                    signal_pid_and_group(child_pid, 9);
264                    return rx
265                        .recv_timeout(Duration::from_millis(100))
266                        .unwrap_or_default();
267                }
268            }
269        }
270    }
271}
272
273/// Spawn a drain thread that reads `reader` to EOF and sends the buffer.
274pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
275    mut reader: R,
276) -> std::sync::mpsc::Receiver<Vec<u8>> {
277    let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
278    std::thread::spawn(move || {
279        let mut buf = Vec::new();
280        let _ = reader.read_to_end(&mut buf);
281        let _ = tx.send(buf);
282    });
283    rx
284}
285
286/// Interrupt-aware replacement for `Command::output()`: the child runs in
287/// its own kill group, stdout/stderr are captured in full, stdin is closed,
288/// and the wait polls [`requested`]. When an interrupt fires the whole
289/// group is gracefully terminated and the (signal-terminated) status is
290/// returned — the interpreter surfaces the pending cancellation / deadline
291/// error at the next op boundary.
292pub fn capture_output_interruptible(
293    command: &mut std::process::Command,
294) -> std::io::Result<std::process::Output> {
295    use std::process::Stdio;
296    command
297        .stdout(Stdio::piped())
298        .stderr(Stdio::piped())
299        .stdin(Stdio::null());
300    configure_kill_group(command);
301    let mut child = command.spawn()?;
302    let pid = child.id();
303    let rx_out = child.stdout.take().map(spawn_pipe_drain);
304    let rx_err = child.stderr.take().map(spawn_pipe_drain);
305
306    let (status, killed) = match wait_child_interruptible(&mut child, None)? {
307        ChildWait::Exited(status) => (status, false),
308        // No timeout is armed here, but keep the arm total.
309        ChildWait::TimedOut => (std::process::ExitStatus::default(), true),
310        ChildWait::Interrupted(status) => (status.unwrap_or_default(), true),
311    };
312    let stdout = rx_out
313        .map(|rx| drain_captured_pipe(&rx, killed, pid))
314        .unwrap_or_default();
315    let stderr = rx_err
316        .map(|rx| drain_captured_pipe(&rx, killed, pid))
317        .unwrap_or_default();
318    Ok(std::process::Output {
319        status,
320        stdout,
321        stderr,
322    })
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn requested_is_false_without_context() {
331        assert!(!requested());
332    }
333
334    #[test]
335    fn installed_tracks_guard_lifetime() {
336        assert!(!installed());
337        let guard = install(None, None);
338        assert!(installed());
339        drop(guard);
340        assert!(!installed());
341    }
342
343    #[test]
344    fn cancel_token_trips_requested_and_guard_restores() {
345        let token = Arc::new(AtomicBool::new(false));
346        let guard = install(Some(token.clone()), None);
347        assert!(!requested());
348        token.store(true, Ordering::SeqCst);
349        assert!(requested());
350        drop(guard);
351        assert!(!requested());
352    }
353
354    #[test]
355    fn deadline_trips_requested() {
356        let expired = Instant::now()
357            .checked_sub(Duration::from_millis(1))
358            .expect("monotonic clock supports a 1ms test lookback");
359        let _guard = install(None, Some(expired));
360        assert!(requested());
361    }
362
363    #[test]
364    fn nested_installs_restore_in_order() {
365        let outer_token = Arc::new(AtomicBool::new(true));
366        let _outer = install(Some(outer_token), None);
367        assert!(requested());
368        {
369            let _inner = install(None, None);
370            assert!(!requested());
371        }
372        assert!(requested());
373    }
374
375    #[cfg(unix)]
376    #[test]
377    fn interrupted_wait_kills_process_group() {
378        // Child spawns a grandchild; the whole group must die on interrupt.
379        let mut command = std::process::Command::new("sh");
380        command.args(["-c", "sleep 30 & wait"]);
381        configure_kill_group(&mut command);
382        let mut child = command.spawn().expect("spawn sh");
383        let pgid = child.id();
384
385        let cancel = Arc::new(AtomicBool::new(true));
386        let _guard = install(Some(cancel), None);
387        let started = Instant::now();
388        let outcome = wait_child_interruptible(&mut child, None).expect("wait");
389        assert!(matches!(outcome, ChildWait::Interrupted(_)));
390        assert!(started.elapsed() < Duration::from_secs(10));
391
392        // kill(-pgid, 0) fails with ESRCH once every member is gone.
393        extern "C" {
394            fn kill(pid: i32, sig: i32) -> i32;
395        }
396        let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
397        let deadline = Instant::now() + Duration::from_secs(5);
398        while !group_gone() && Instant::now() < deadline {
399            std::thread::sleep(Duration::from_millis(50));
400        }
401        assert!(group_gone(), "process group {pgid} survived interrupt");
402    }
403}