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