Skip to main content

mermaid_cli/utils/
proc.rs

1//! Process plumbing the rest of the app must not reimplement: tree termination
2//! (taking a spawned child *and its descendants* down) and bounded,
3//! kill-on-timeout subprocess execution.
4//!
5//! Every tree we manage is spawned as a process-group leader (`process_group(0)`
6//! on Unix; `mode=background` uses `setsid`; Windows kills the tree by pid via
7//! `taskkill /T`). Terminating the whole group reaches a grandchild that a bare
8//! per-pid signal would orphan — the bug this module centralizes the fix for: the
9//! Esc-cancel path already group-killed, but the foreground timeout, the
10//! Ctrl+B-detached cleanup, and the daemon's `/stop`/`/restart` each signalled a
11//! single pid.
12//!
13//! Unix sends to BOTH the group (`-pid`) and the bare pid, so a process that
14//! turned out not to be a group leader (e.g. a `mode=background` launch on a host
15//! without `setsid`) is still killed directly rather than missed entirely.
16//!
17//! Callers pick the grace: `Immediate` (Esc-cancel / timeout, which want the
18//! fastest possible teardown) or `Graceful` (`/stop`, background cleanup, which
19//! give the tree a beat to exit cleanly before the SIGKILL).
20//!
21//! The bounded-execution half (`output_with_timeout`, `write_stdin_with_timeout`)
22//! exists because `std::process` has no deadline primitive: `Command::output()`
23//! and `wait()` block until the child chooses to exit. Callers that shell out to
24//! helpers which can wedge indefinitely — clipboard tools waiting on a hung
25//! selection owner, PowerShell on a cold or broken CLR — use these to turn a
26//! potential permanent hang into a bounded stall plus an error.
27
28use std::io::{Read, Write};
29use std::process::{Child, Command, ExitStatus, Output, Stdio};
30use std::sync::mpsc;
31use std::time::{Duration, Instant};
32
33/// How aggressively to tear a tree down.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Grace {
36    /// SIGKILL the group immediately.
37    Immediate,
38    /// SIGTERM the group, brief grace, then SIGKILL — lets it clean up first.
39    Graceful,
40}
41
42/// How long `Graceful` waits between SIGTERM and the SIGKILL backstop.
43const GRACE_PERIOD: Duration = Duration::from_millis(400);
44
45/// pids 0 and 1 are never legitimate teardown targets. On Unix we signal the
46/// *process group* `-pid`, so `kill -KILL -- -0` hits our OWN process group
47/// (mermaid self-terminates) and `-1` fans out to every process we may signal.
48/// Real managed children always have pid > 1; anything at or below 1 is a
49/// phantom (e.g. a `ManagedProcess` that ever recorded pid 0) and must be a
50/// no-op so a stray `/stop` can't take the app — or the box — down with it.
51fn is_signalable(pid: u32) -> bool {
52    pid > 1
53}
54
55/// Terminate `pid`'s process tree (async). Safe to call on a pid that has
56/// already exited — signals are best-effort and every error is swallowed.
57pub async fn terminate_tree(pid: u32, grace: Grace) {
58    if !is_signalable(pid) {
59        return;
60    }
61    #[cfg(not(target_os = "windows"))]
62    {
63        if grace == Grace::Graceful {
64            unix_kill("-TERM", pid).await;
65            tokio::time::sleep(GRACE_PERIOD).await;
66        }
67        unix_kill("-KILL", pid).await;
68    }
69    #[cfg(target_os = "windows")]
70    {
71        let _ = grace; // taskkill /F is always forceful.
72        taskkill_tree(pid).await;
73    }
74}
75
76/// Blocking sibling for sync call sites (the daemon's `/stop` / `/restart` run
77/// off a sync runtime client and can't await).
78pub fn terminate_tree_blocking(pid: u32, grace: Grace) {
79    if !is_signalable(pid) {
80        return;
81    }
82    #[cfg(not(target_os = "windows"))]
83    {
84        if grace == Grace::Graceful {
85            unix_kill_blocking("-TERM", pid);
86            std::thread::sleep(GRACE_PERIOD);
87        }
88        unix_kill_blocking("-KILL", pid);
89    }
90    #[cfg(target_os = "windows")]
91    {
92        let _ = grace;
93        taskkill_tree_blocking(pid);
94    }
95}
96
97#[cfg(not(target_os = "windows"))]
98async fn unix_kill(signal: &str, pid: u32) {
99    let _ = tokio::process::Command::new("kill")
100        .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
101        .stdin(Stdio::null())
102        .stdout(Stdio::null())
103        .stderr(Stdio::null())
104        .status()
105        .await;
106}
107
108#[cfg(not(target_os = "windows"))]
109fn unix_kill_blocking(signal: &str, pid: u32) {
110    let _ = std::process::Command::new("kill")
111        .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
112        .stdin(Stdio::null())
113        .stdout(Stdio::null())
114        .stderr(Stdio::null())
115        .status();
116}
117
118#[cfg(target_os = "windows")]
119async fn taskkill_tree(pid: u32) {
120    let _ = tokio::process::Command::new("taskkill")
121        .args(["/PID", &pid.to_string(), "/T", "/F"])
122        .stdin(Stdio::null())
123        .stdout(Stdio::null())
124        .stderr(Stdio::null())
125        .status()
126        .await;
127}
128
129#[cfg(target_os = "windows")]
130fn taskkill_tree_blocking(pid: u32) {
131    let _ = std::process::Command::new("taskkill")
132        .args(["/PID", &pid.to_string(), "/T", "/F"])
133        .stdin(Stdio::null())
134        .stdout(Stdio::null())
135        .stderr(Stdio::null())
136        .status();
137}
138
139// ---------------------------------------------------------------------------
140// Bounded subprocess execution
141// ---------------------------------------------------------------------------
142
143/// Cadence for deadline-armed `try_wait` polling. Cheap enough to leave tight —
144/// it bounds how much latency the deadline machinery adds to a fast child.
145const POLL_INTERVAL: Duration = Duration::from_millis(10);
146
147/// After a child exits, how long `output_with_timeout` waits for its pipes to
148/// hit EOF. Normally EOF is immediate; the grace only bites when a grandchild
149/// inherited the pipe and outlives the child — partial output is returned.
150const READER_GRACE: Duration = Duration::from_millis(250);
151
152/// Bounded reap window after a deadline kill. SIGKILL can't be ignored, but a
153/// child stuck in uninterruptible I/O (dead X socket, hung NFS) may not die
154/// promptly — and trading the caller's hang for ours would defeat the point.
155const REAP_GRACE: Duration = Duration::from_millis(500);
156
157/// Run `cmd` to completion with a deadline, capturing stdout/stderr — a
158/// `Command::output()` that cannot hang. On expiry the child is killed and
159/// (bounded-best-effort) reaped, and `ErrorKind::TimedOut` comes back. stdin
160/// is null.
161///
162/// Pipes are drained on background threads, so a child that writes more than
163/// a pipe buffer's worth can't deadlock against the wait loop.
164pub fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result<Output> {
165    cmd.stdin(Stdio::null())
166        .stdout(Stdio::piped())
167        .stderr(Stdio::piped());
168    let mut child = cmd.spawn()?;
169    let stdout_rx = drain_pipe(child.stdout.take());
170    let stderr_rx = drain_pipe(child.stderr.take());
171
172    match wait_deadline(&mut child, timeout)? {
173        Some(status) => Ok(Output {
174            status,
175            stdout: collect_drained(stdout_rx),
176            stderr: collect_drained(stderr_rx),
177        }),
178        None => Err(timed_out(cmd, timeout)),
179    }
180}
181
182/// Feed `input` to `cmd`'s stdin and wait for exit under a deadline — the
183/// write-side sibling of [`output_with_timeout`]. stdout/stderr go to null,
184/// so tools that fork a long-lived holder of their fds (`wl-copy` and `xclip`
185/// serve the selection from a background fork) can't pin any pipe of ours.
186///
187/// stdin is fed from a background thread: a child that never reads can't
188/// block the caller, and dropping the handle after the write delivers EOF.
189/// A write against a dead child (EPIPE) is ignored — the exit status or the
190/// timeout already tells that story.
191pub fn write_stdin_with_timeout(
192    cmd: &mut Command,
193    input: Vec<u8>,
194    timeout: Duration,
195) -> std::io::Result<ExitStatus> {
196    cmd.stdin(Stdio::piped())
197        .stdout(Stdio::null())
198        .stderr(Stdio::null());
199    let mut child = cmd.spawn()?;
200    if let Some(mut stdin) = child.stdin.take() {
201        std::thread::spawn(move || {
202            let _ = stdin.write_all(&input);
203            // Dropping `stdin` closes the pipe — the child sees EOF.
204        });
205    }
206    match wait_deadline(&mut child, timeout)? {
207        Some(status) => Ok(status),
208        None => Err(timed_out(cmd, timeout)),
209    }
210}
211
212/// Poll-wait for `child` up to `timeout`. `Ok(None)` means the deadline
213/// passed: the child has been killed and reaping was attempted for
214/// [`REAP_GRACE`]. An unreapable child (unkillable, stuck in uninterruptible
215/// I/O) lingers as a zombie until this process exits — accepted for that
216/// pathological case rather than risking a blocking `wait()` here.
217fn wait_deadline(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
218    let deadline = Instant::now() + timeout;
219    loop {
220        if let Some(status) = child.try_wait()? {
221            return Ok(Some(status));
222        }
223        if Instant::now() >= deadline {
224            let _ = child.kill();
225            let reap_deadline = Instant::now() + REAP_GRACE;
226            while Instant::now() < reap_deadline {
227                if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) {
228                    break;
229                }
230                std::thread::sleep(POLL_INTERVAL);
231            }
232            return Ok(None);
233        }
234        std::thread::sleep(POLL_INTERVAL);
235    }
236}
237
238/// Stream a pipe's bytes over a channel from a background thread. The thread
239/// exits on EOF or when the receiver is gone; chunking (rather than one
240/// read-to-end) means a pipe held open past child exit still yields whatever
241/// was written before it.
242fn drain_pipe<R: Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
243    let (tx, rx) = mpsc::channel();
244    if let Some(mut pipe) = pipe {
245        std::thread::spawn(move || {
246            let mut buf = [0u8; 8192];
247            loop {
248                match pipe.read(&mut buf) {
249                    Ok(0) | Err(_) => break,
250                    Ok(n) => {
251                        if tx.send(buf[..n].to_vec()).is_err() {
252                            break;
253                        }
254                    },
255                }
256            }
257        });
258    }
259    rx
260}
261
262/// Gather everything a [`drain_pipe`] thread produced. Returns as soon as the
263/// pipe hits EOF (sender dropped); otherwise gives up after [`READER_GRACE`]
264/// so a grandchild that inherited the pipe can't stall us, keeping any
265/// partial output.
266fn collect_drained(rx: mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
267    let mut out = Vec::new();
268    let deadline = Instant::now() + READER_GRACE;
269    while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
270        match rx.recv_timeout(remaining) {
271            Ok(chunk) => out.extend_from_slice(&chunk),
272            // Disconnected (EOF) or Timeout (grace expired) — either way, done.
273            Err(_) => break,
274        }
275    }
276    out
277}
278
279/// The error a deadline kill surfaces: `ErrorKind::TimedOut`, naming the
280/// program so an `anyhow` context chain reads well in the status line.
281fn timed_out(cmd: &Command, timeout: Duration) -> std::io::Error {
282    std::io::Error::new(
283        std::io::ErrorKind::TimedOut,
284        format!(
285            "{} did not exit within {timeout:?} and was killed",
286            cmd.get_program().to_string_lossy()
287        ),
288    )
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn pids_0_and_1_are_never_signalable() {
297        // The dangerous shapes: `-0` is our own process group, `-1` is every
298        // signalable process. Neither may ever reach a real `kill`.
299        assert!(!is_signalable(0));
300        assert!(!is_signalable(1));
301    }
302
303    #[test]
304    fn real_pids_are_signalable() {
305        assert!(is_signalable(2));
306        assert!(is_signalable(1234));
307        assert!(is_signalable(u32::MAX));
308    }
309
310    #[tokio::test]
311    async fn terminate_tree_is_a_noop_for_unsafe_pids() {
312        // Must return promptly without shelling out to `kill` — the guard runs
313        // before any process spawn. (If it did signal, it would target our own
314        // group; the test process surviving is the assertion.)
315        terminate_tree(0, Grace::Immediate).await;
316        terminate_tree(1, Grace::Graceful).await;
317        terminate_tree_blocking(0, Grace::Immediate);
318        terminate_tree_blocking(1, Grace::Graceful);
319    }
320
321    // ---- bounded execution ----
322
323    #[cfg(unix)]
324    fn sh(script: &str) -> Command {
325        let mut c = Command::new("sh");
326        c.args(["-c", script]);
327        c
328    }
329
330    #[cfg(windows)]
331    fn cmd_c(script: &str) -> Command {
332        let mut c = Command::new("cmd");
333        c.args(["/C", script]);
334        c
335    }
336
337    #[cfg(unix)]
338    #[test]
339    fn output_with_timeout_captures_output() {
340        let out = output_with_timeout(&mut sh("echo hello"), Duration::from_secs(10)).unwrap();
341        assert!(out.status.success());
342        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
343    }
344
345    #[cfg(unix)]
346    #[test]
347    fn output_with_timeout_kills_a_hung_child() {
348        let start = Instant::now();
349        let err = output_with_timeout(&mut sh("sleep 30"), Duration::from_millis(200))
350            .expect_err("a hung child must surface as an error");
351        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
352        assert!(
353            start.elapsed() < Duration::from_secs(10),
354            "deadline kill must not wait for the child's natural exit"
355        );
356    }
357
358    #[cfg(unix)]
359    #[test]
360    fn output_with_timeout_drains_more_than_a_pipe_buffer() {
361        // 1 MiB ≫ the ~64 KiB pipe buffer: without background draining the
362        // child blocks on a full pipe and the wait loop would time out.
363        let out = output_with_timeout(
364            &mut sh("head -c 1048576 /dev/zero"),
365            Duration::from_secs(10),
366        )
367        .unwrap();
368        assert!(out.status.success());
369        assert_eq!(out.stdout.len(), 1_048_576);
370    }
371
372    #[cfg(unix)]
373    #[test]
374    fn output_with_timeout_tolerates_a_grandchild_holding_the_pipe() {
375        // The child exits immediately but leaves a background grandchild
376        // holding the inherited stdout pipe open (the `wl-copy`/`xclip`
377        // serve-the-selection pattern). Output written before the exit must
378        // still come back, within READER_GRACE rather than the grandchild's
379        // lifetime.
380        let start = Instant::now();
381        let out = output_with_timeout(
382            &mut sh("echo early; sleep 5 & exit 0"),
383            Duration::from_secs(10),
384        )
385        .unwrap();
386        assert!(out.status.success());
387        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "early");
388        assert!(
389            start.elapsed() < Duration::from_secs(4),
390            "an fd-holding grandchild must not stall collection"
391        );
392    }
393
394    #[cfg(unix)]
395    #[test]
396    fn write_stdin_with_timeout_feeds_the_child() {
397        // The child's exit status proves the bytes arrived and EOF followed.
398        let status = write_stdin_with_timeout(
399            &mut sh(r#"input=$(cat); [ "$input" = "hello" ]"#),
400            b"hello".to_vec(),
401            Duration::from_secs(10),
402        )
403        .unwrap();
404        assert!(status.success());
405    }
406
407    #[cfg(unix)]
408    #[test]
409    fn write_stdin_with_timeout_kills_a_child_that_never_reads() {
410        // Input larger than the pipe buffer, against a child that never reads
411        // stdin: the writer thread blocks on the full pipe and must be freed
412        // by the deadline kill (EPIPE), not wedge anything.
413        let start = Instant::now();
414        let err = write_stdin_with_timeout(
415            &mut sh("sleep 30"),
416            vec![b'x'; 1_048_576],
417            Duration::from_millis(200),
418        )
419        .expect_err("a child that never reads must time out");
420        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
421        assert!(start.elapsed() < Duration::from_secs(10));
422    }
423
424    #[cfg(windows)]
425    #[test]
426    fn output_with_timeout_captures_output() {
427        let out = output_with_timeout(&mut cmd_c("echo hello"), Duration::from_secs(20)).unwrap();
428        assert!(out.status.success());
429        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
430    }
431
432    #[cfg(windows)]
433    #[test]
434    fn output_with_timeout_kills_a_hung_child() {
435        let start = Instant::now();
436        let err = output_with_timeout(
437            &mut cmd_c("ping -n 30 127.0.0.1"),
438            Duration::from_millis(500),
439        )
440        .expect_err("a hung child must surface as an error");
441        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
442        assert!(
443            start.elapsed() < Duration::from_secs(20),
444            "deadline kill must not wait for the child's natural exit"
445        );
446    }
447
448    #[cfg(windows)]
449    #[test]
450    fn write_stdin_with_timeout_feeds_the_child() {
451        // findstr exits 0 iff a line of stdin matches — proves delivery + EOF.
452        let status = write_stdin_with_timeout(
453            &mut cmd_c("findstr hello"),
454            b"hello\r\n".to_vec(),
455            Duration::from_secs(20),
456        )
457        .unwrap();
458        assert!(status.success());
459    }
460}