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