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