Skip to main content

mermaid_model/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/// `CREATE_NEW_PROCESS_GROUP`: the child gets its own process group, so
52/// console control events aimed at mermaid's group never reach it.
53#[cfg(target_os = "windows")]
54pub const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
55
56/// `CREATE_NO_WINDOW`: the child gets its OWN console with **no window**.
57/// Paired with [`CREATE_NEW_PROCESS_GROUP`], this is the flag set for every
58/// "outlives mermaid" spawn (the exec tool's background launcher, ollama
59/// autostart, the managed-search server): the child is exempt from the parent
60/// console's Ctrl+C fan-out, survives mermaid exiting, and console APIs still
61/// work inside it.
62///
63/// **Not `DETACHED_PROCESS` (0x8).** That flag is the intuitive choice and it
64/// is wrong here. It gives the child *no console at all*, so the moment a
65/// console-subsystem child touches console I/O Windows allocates one for it —
66/// and on Windows 11, where Windows Terminal is the default console host, that
67/// allocation opens a **visible window**. Measured directly: spawning
68/// `cmd /C ping … >NUL` with `DETACHED_PROCESS` adds one visible top-level
69/// window; the same spawn with `CREATE_NO_WINDOW` adds none. Redirecting stdio
70/// to null does not prevent it — the window comes from console allocation, not
71/// from output. A killed child then leaves the orphaned window on the user's
72/// desktop showing a launch error.
73///
74/// It is also less compatible: PowerShell dies at startup under
75/// `DETACHED_PROCESS` because it requires a console.
76///
77/// The two flags are mutually exclusive, and there is no case in this codebase
78/// that wants `DETACHED_PROCESS`, so the constant is deliberately gone rather
79/// than left available to reach for.
80#[cfg(target_os = "windows")]
81pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
82
83/// pids 0 and 1 are never legitimate teardown targets. On Unix we signal the
84/// *process group* `-pid`, so `kill -KILL -- -0` hits our OWN process group
85/// (mermaid self-terminates) and `-1` fans out to every process we may signal.
86/// Real managed children always have pid > 1; anything at or below 1 is a
87/// phantom (e.g. a `ManagedProcess` that ever recorded pid 0) and must be a
88/// no-op so a stray `/stop` can't take the app — or the box — down with it.
89fn is_signalable(pid: u32) -> bool {
90    pid > 1
91}
92
93/// Terminate `pid`'s process tree (async). Safe to call on a pid that has
94/// already exited — signals are best-effort and every error is swallowed.
95pub async fn terminate_tree(pid: u32, grace: Grace) {
96    if !is_signalable(pid) {
97        return;
98    }
99    #[cfg(not(target_os = "windows"))]
100    {
101        if grace == Grace::Graceful {
102            unix_kill("-TERM", pid).await;
103            tokio::time::sleep(GRACE_PERIOD).await;
104        }
105        unix_kill("-KILL", pid).await;
106    }
107    #[cfg(target_os = "windows")]
108    {
109        let _ = grace; // taskkill /F is always forceful.
110        taskkill_tree(pid).await;
111    }
112}
113
114/// Blocking sibling for sync call sites (the daemon's `/stop` / `/restart` run
115/// off a sync runtime client and can't await).
116pub fn terminate_tree_blocking(pid: u32, grace: Grace) {
117    if !is_signalable(pid) {
118        return;
119    }
120    #[cfg(not(target_os = "windows"))]
121    {
122        if grace == Grace::Graceful {
123            unix_kill_blocking("-TERM", pid);
124            std::thread::sleep(GRACE_PERIOD);
125        }
126        unix_kill_blocking("-KILL", pid);
127    }
128    #[cfg(target_os = "windows")]
129    {
130        let _ = grace;
131        taskkill_tree_blocking(pid);
132    }
133}
134
135#[cfg(not(target_os = "windows"))]
136async fn unix_kill(signal: &str, pid: u32) {
137    let _ = tokio::process::Command::new("kill")
138        .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
139        .stdin(Stdio::null())
140        .stdout(Stdio::null())
141        .stderr(Stdio::null())
142        .status()
143        .await;
144}
145
146#[cfg(not(target_os = "windows"))]
147fn unix_kill_blocking(signal: &str, pid: u32) {
148    let _ = std::process::Command::new("kill")
149        .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
150        .stdin(Stdio::null())
151        .stdout(Stdio::null())
152        .stderr(Stdio::null())
153        .status();
154}
155
156#[cfg(target_os = "windows")]
157async fn taskkill_tree(pid: u32) {
158    let _ = tokio::process::Command::new("taskkill")
159        .args(["/PID", &pid.to_string(), "/T", "/F"])
160        .stdin(Stdio::null())
161        .stdout(Stdio::null())
162        .stderr(Stdio::null())
163        .status()
164        .await;
165}
166
167#[cfg(target_os = "windows")]
168fn taskkill_tree_blocking(pid: u32) {
169    let _ = std::process::Command::new("taskkill")
170        .args(["/PID", &pid.to_string(), "/T", "/F"])
171        .stdin(Stdio::null())
172        .stdout(Stdio::null())
173        .stderr(Stdio::null())
174        .status();
175}
176
177// ---------------------------------------------------------------------------
178// Bounded subprocess execution
179// ---------------------------------------------------------------------------
180
181/// Cadence for deadline-armed `try_wait` polling. Cheap enough to leave tight —
182/// it bounds how much latency the deadline machinery adds to a fast child.
183const POLL_INTERVAL: Duration = Duration::from_millis(10);
184
185/// After a child exits, how long `output_with_timeout` waits for its pipes to
186/// hit EOF. Normally EOF is immediate; the grace only bites when a grandchild
187/// inherited the pipe and outlives the child — partial output is returned.
188const READER_GRACE: Duration = Duration::from_millis(250);
189
190/// Bounded reap window after a deadline kill. SIGKILL can't be ignored, but a
191/// child stuck in uninterruptible I/O (dead X socket, hung NFS) may not die
192/// promptly — and trading the caller's hang for ours would defeat the point.
193const REAP_GRACE: Duration = Duration::from_millis(500);
194
195/// Run `cmd` to completion with a deadline, capturing stdout/stderr — a
196/// `Command::output()` that cannot hang. On expiry the child is killed and
197/// (bounded-best-effort) reaped, and `ErrorKind::TimedOut` comes back. stdin
198/// is null.
199///
200/// Pipes are drained on background threads, so a child that writes more than
201/// a pipe buffer's worth can't deadlock against the wait loop.
202pub fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result<Output> {
203    cmd.stdin(Stdio::null())
204        .stdout(Stdio::piped())
205        .stderr(Stdio::piped());
206    let mut child = cmd.spawn()?;
207    let stdout_rx = drain_pipe(child.stdout.take());
208    let stderr_rx = drain_pipe(child.stderr.take());
209
210    match wait_deadline(&mut child, timeout)? {
211        Some(status) => Ok(Output {
212            status,
213            stdout: collect_drained(stdout_rx),
214            stderr: collect_drained(stderr_rx),
215        }),
216        None => Err(timed_out(cmd, timeout)),
217    }
218}
219
220/// Feed `input` to `cmd`'s stdin and wait for exit under a deadline — the
221/// write-side sibling of [`output_with_timeout`]. stdout/stderr go to null,
222/// so tools that fork a long-lived holder of their fds (`wl-copy` and `xclip`
223/// serve the selection from a background fork) can't pin any pipe of ours.
224///
225/// stdin is fed from a background thread: a child that never reads can't
226/// block the caller, and dropping the handle after the write delivers EOF.
227/// A write against a dead child (EPIPE) is ignored — the exit status or the
228/// timeout already tells that story.
229pub fn write_stdin_with_timeout(
230    cmd: &mut Command,
231    input: Vec<u8>,
232    timeout: Duration,
233) -> std::io::Result<ExitStatus> {
234    cmd.stdin(Stdio::piped())
235        .stdout(Stdio::null())
236        .stderr(Stdio::null());
237    let mut child = cmd.spawn()?;
238    if let Some(mut stdin) = child.stdin.take() {
239        std::thread::spawn(move || {
240            let _ = stdin.write_all(&input);
241            // Dropping `stdin` closes the pipe — the child sees EOF.
242        });
243    }
244    match wait_deadline(&mut child, timeout)? {
245        Some(status) => Ok(status),
246        None => Err(timed_out(cmd, timeout)),
247    }
248}
249
250/// Poll-wait for `child` up to `timeout`. `Ok(None)` means the deadline
251/// passed: the child has been killed and reaping was attempted for
252/// [`REAP_GRACE`]. An unreapable child (unkillable, stuck in uninterruptible
253/// I/O) lingers as a zombie until this process exits — accepted for that
254/// pathological case rather than risking a blocking `wait()` here.
255fn wait_deadline(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
256    let deadline = Instant::now() + timeout;
257    loop {
258        if let Some(status) = child.try_wait()? {
259            return Ok(Some(status));
260        }
261        if Instant::now() >= deadline {
262            let _ = child.kill();
263            let reap_deadline = Instant::now() + REAP_GRACE;
264            while Instant::now() < reap_deadline {
265                if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) {
266                    break;
267                }
268                std::thread::sleep(POLL_INTERVAL);
269            }
270            return Ok(None);
271        }
272        std::thread::sleep(POLL_INTERVAL);
273    }
274}
275
276/// Stream a pipe's bytes over a channel from a background thread. The thread
277/// exits on EOF or when the receiver is gone; chunking (rather than one
278/// read-to-end) means a pipe held open past child exit still yields whatever
279/// was written before it.
280fn drain_pipe<R: Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
281    let (tx, rx) = mpsc::channel();
282    if let Some(mut pipe) = pipe {
283        std::thread::spawn(move || {
284            let mut buf = [0u8; 8192];
285            loop {
286                match pipe.read(&mut buf) {
287                    Ok(0) | Err(_) => break,
288                    Ok(n) => {
289                        if tx.send(buf[..n].to_vec()).is_err() {
290                            break;
291                        }
292                    },
293                }
294            }
295        });
296    }
297    rx
298}
299
300/// Gather everything a [`drain_pipe`] thread produced. Returns as soon as the
301/// pipe hits EOF (sender dropped); otherwise gives up after [`READER_GRACE`]
302/// so a grandchild that inherited the pipe can't stall us, keeping any
303/// partial output.
304fn collect_drained(rx: mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
305    let mut out = Vec::new();
306    let deadline = Instant::now() + READER_GRACE;
307    while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
308        match rx.recv_timeout(remaining) {
309            Ok(chunk) => out.extend_from_slice(&chunk),
310            // Disconnected (EOF) or Timeout (grace expired) — either way, done.
311            Err(_) => break,
312        }
313    }
314    out
315}
316
317/// The error a deadline kill surfaces: `ErrorKind::TimedOut`, naming the
318/// program so an `anyhow` context chain reads well in the status line.
319fn timed_out(cmd: &Command, timeout: Duration) -> std::io::Error {
320    std::io::Error::new(
321        std::io::ErrorKind::TimedOut,
322        format!(
323            "{} did not exit within {timeout:?} and was killed",
324            cmd.get_program().to_string_lossy()
325        ),
326    )
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn pids_0_and_1_are_never_signalable() {
335        // The dangerous shapes: `-0` is our own process group, `-1` is every
336        // signalable process. Neither may ever reach a real `kill`.
337        assert!(!is_signalable(0));
338        assert!(!is_signalable(1));
339    }
340
341    #[test]
342    fn real_pids_are_signalable() {
343        assert!(is_signalable(2));
344        assert!(is_signalable(1234));
345        assert!(is_signalable(u32::MAX));
346    }
347
348    #[tokio::test]
349    async fn terminate_tree_is_a_noop_for_unsafe_pids() {
350        // Must return promptly without shelling out to `kill` — the guard runs
351        // before any process spawn. (If it did signal, it would target our own
352        // group; the test process surviving is the assertion.)
353        terminate_tree(0, Grace::Immediate).await;
354        terminate_tree(1, Grace::Graceful).await;
355        terminate_tree_blocking(0, Grace::Immediate);
356        terminate_tree_blocking(1, Grace::Graceful);
357    }
358
359    // ---- bounded execution ----
360
361    #[cfg(unix)]
362    fn sh(script: &str) -> Command {
363        let mut c = Command::new("sh");
364        c.args(["-c", script]);
365        c
366    }
367
368    #[cfg(windows)]
369    fn cmd_c(script: &str) -> Command {
370        let mut c = Command::new("cmd");
371        c.args(["/C", script]);
372        c
373    }
374
375    #[cfg(unix)]
376    #[test]
377    fn output_with_timeout_captures_output() {
378        let out = output_with_timeout(&mut sh("echo hello"), Duration::from_secs(10)).unwrap();
379        assert!(out.status.success());
380        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
381    }
382
383    #[cfg(unix)]
384    #[test]
385    fn output_with_timeout_kills_a_hung_child() {
386        let start = Instant::now();
387        let err = output_with_timeout(&mut sh("sleep 30"), Duration::from_millis(200))
388            .expect_err("a hung child must surface as an error");
389        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
390        assert!(
391            start.elapsed() < Duration::from_secs(10),
392            "deadline kill must not wait for the child's natural exit"
393        );
394    }
395
396    #[cfg(unix)]
397    #[test]
398    fn output_with_timeout_drains_more_than_a_pipe_buffer() {
399        // 1 MiB ≫ the ~64 KiB pipe buffer: without background draining the
400        // child blocks on a full pipe and the wait loop would time out.
401        let out = output_with_timeout(
402            &mut sh("head -c 1048576 /dev/zero"),
403            Duration::from_secs(10),
404        )
405        .unwrap();
406        assert!(out.status.success());
407        assert_eq!(out.stdout.len(), 1_048_576);
408    }
409
410    #[cfg(unix)]
411    #[test]
412    fn output_with_timeout_tolerates_a_grandchild_holding_the_pipe() {
413        // The child exits immediately but leaves a background grandchild
414        // holding the inherited stdout pipe open (the `wl-copy`/`xclip`
415        // serve-the-selection pattern). Output written before the exit must
416        // still come back, within READER_GRACE rather than the grandchild's
417        // lifetime.
418        let start = Instant::now();
419        let out = output_with_timeout(
420            &mut sh("echo early; sleep 5 & exit 0"),
421            Duration::from_secs(10),
422        )
423        .unwrap();
424        assert!(out.status.success());
425        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "early");
426        assert!(
427            start.elapsed() < Duration::from_secs(4),
428            "an fd-holding grandchild must not stall collection"
429        );
430    }
431
432    #[cfg(unix)]
433    #[test]
434    fn write_stdin_with_timeout_feeds_the_child() {
435        // The child's exit status proves the bytes arrived and EOF followed.
436        let status = write_stdin_with_timeout(
437            &mut sh(r#"input=$(cat); [ "$input" = "hello" ]"#),
438            b"hello".to_vec(),
439            Duration::from_secs(10),
440        )
441        .unwrap();
442        assert!(status.success());
443    }
444
445    #[cfg(unix)]
446    #[test]
447    fn write_stdin_with_timeout_kills_a_child_that_never_reads() {
448        // Input larger than the pipe buffer, against a child that never reads
449        // stdin: the writer thread blocks on the full pipe and must be freed
450        // by the deadline kill (EPIPE), not wedge anything.
451        let start = Instant::now();
452        let err = write_stdin_with_timeout(
453            &mut sh("sleep 30"),
454            vec![b'x'; 1_048_576],
455            Duration::from_millis(200),
456        )
457        .expect_err("a child that never reads must time out");
458        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
459        assert!(start.elapsed() < Duration::from_secs(10));
460    }
461
462    /// Every long-lived Windows spawn must use `CREATE_NO_WINDOW`, never
463    /// `DETACHED_PROCESS`.
464    ///
465    /// This is a source check rather than a behavioral one because the symptom
466    /// is a *desktop* artifact, not a process property: `DETACHED_PROCESS`
467    /// leaves the child with no console, Windows allocates one on first console
468    /// I/O, and on Windows 11 that opens a real Terminal window. A killed child
469    /// then orphans it. The test suite itself did this on every run — the bug
470    /// was found by noticing a stack of dead `ping -n 31 127.0.0.1` windows on
471    /// a developer's desktop, which no assertion in this suite could have seen.
472    #[cfg(windows)]
473    #[test]
474    fn no_spawn_site_uses_detached_process() {
475        // Split so this scanner's own source never contains the whole token —
476        // otherwise the guard reports itself.
477        let needle = concat!("DETACHED", "_PROCESS");
478        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
479        let mut offenders = Vec::new();
480        let mut stack = vec![root];
481        while let Some(dir) = stack.pop() {
482            let Ok(entries) = std::fs::read_dir(&dir) else {
483                continue;
484            };
485            for entry in entries.flatten() {
486                let path = entry.path();
487                if path.is_dir() {
488                    stack.push(path);
489                    continue;
490                }
491                if path.extension().is_none_or(|ext| ext != "rs") {
492                    continue;
493                }
494                let Ok(text) = std::fs::read_to_string(&path) else {
495                    continue;
496                };
497                for (number, line) in text.lines().enumerate() {
498                    // Prose (this doc comment, the constant's docs) explains why
499                    // the flag is banned; only real code counts.
500                    let code = line.trim_start();
501                    if code.starts_with("//") || code.starts_with("///") {
502                        continue;
503                    }
504                    if line.contains(needle) {
505                        offenders.push(format!("{}:{}", path.display(), number + 1));
506                    }
507                }
508            }
509        }
510        assert!(
511            offenders.is_empty(),
512            "{needle} opens a visible console window on Windows 11 — use \
513             CREATE_NO_WINDOW instead. Offending lines: {offenders:?}",
514        );
515    }
516
517    #[cfg(windows)]
518    #[test]
519    fn output_with_timeout_captures_output() {
520        let out = output_with_timeout(&mut cmd_c("echo hello"), Duration::from_secs(20)).unwrap();
521        assert!(out.status.success());
522        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
523    }
524
525    #[cfg(windows)]
526    #[test]
527    fn output_with_timeout_kills_a_hung_child() {
528        let start = Instant::now();
529        let err = output_with_timeout(
530            &mut cmd_c("ping -n 30 127.0.0.1"),
531            Duration::from_millis(500),
532        )
533        .expect_err("a hung child must surface as an error");
534        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
535        assert!(
536            start.elapsed() < Duration::from_secs(20),
537            "deadline kill must not wait for the child's natural exit"
538        );
539    }
540
541    #[cfg(windows)]
542    #[test]
543    fn write_stdin_with_timeout_feeds_the_child() {
544        // findstr exits 0 iff a line of stdin matches — proves delivery + EOF.
545        let status = write_stdin_with_timeout(
546            &mut cmd_c("findstr hello"),
547            b"hello\r\n".to_vec(),
548            Duration::from_secs(20),
549        )
550        .unwrap();
551        assert!(status.success());
552    }
553}