Skip to main content

coding_tools/
supervise.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Bounded child-command execution for the dispatching tools (`ct-test`,
5//! `ct-each`).
6//!
7//! [`run_captured`] spawns a command with both streams captured, optionally
8//! feeds it literal stdin, and enforces an optional timeout: when the limit
9//! passes the child's whole **process group** is killed (on Unix; the child
10//! alone elsewhere) and the run is reported as [`timed_out`](Outcome::timed_out)
11//! rather than aborting the tool — so a timeout folds into the framed verdict
12//! (`ERROR`, `{CODE}` = `timeout`) instead of producing an unexplained death.
13//!
14//! [`resolve_program`] is the suite's sibling resolution: a bare `ct-*` name is
15//! looked up next to the running executable before falling back to `PATH`, so
16//! the tools compose whether installed or freshly built.
17
18use std::ffi::OsString;
19use std::io::Read;
20use std::process::{Child, Command, ExitStatus, Stdio};
21use std::time::{Duration, Instant};
22
23/// What a supervised run produced.
24pub struct Outcome {
25    /// Captured standard output (lossy UTF-8).
26    pub stdout: String,
27    /// Captured standard error (lossy UTF-8).
28    pub stderr: String,
29    /// The child's exit status; `None` when the run timed out and was killed.
30    pub status: Option<ExitStatus>,
31    /// Whether the timeout fired (the child and its process group were killed).
32    pub timed_out: bool,
33}
34
35/// Resolve the program to launch. A bare `ct-*` name is resolved to a sibling
36/// of the current executable first — the same resolution the `ct` umbrella
37/// uses — so suite tools compose without `PATH` games; anything else launches
38/// by name via `PATH`.
39pub fn resolve_program(cmd: &str, name: &str) -> OsString {
40    if name.starts_with("ct-")
41        && !cmd.contains('/')
42        && let Ok(exe) = std::env::current_exe()
43        && let Some(dir) = exe.parent()
44    {
45        let candidate = dir.join(name);
46        if candidate.is_file() {
47            return candidate.into_os_string();
48        }
49    }
50    OsString::from(cmd)
51}
52
53/// Kill the child's process group (Unix) or the child itself (elsewhere).
54#[cfg(unix)]
55fn kill_tree(child: &mut Child) {
56    // The child was made a process-group leader at spawn, so a negative pid
57    // signals the whole group — a build tool's own forked children included.
58    let pid = child.id() as i32;
59    unsafe {
60        libc::kill(-pid, libc::SIGKILL);
61    }
62    let _ = child.kill();
63}
64
65#[cfg(not(unix))]
66fn kill_tree(child: &mut Child) {
67    let _ = child.kill();
68}
69
70/// Run `command` to completion with stdout/stderr captured, writing
71/// `stdin_text` (if any) to its standard input, killing it if it outlives
72/// `timeout`.
73pub fn run_captured(
74    mut command: Command,
75    stdin_text: Option<&str>,
76    timeout: Option<Duration>,
77) -> Result<Outcome, String> {
78    command
79        .stdin(Stdio::piped())
80        .stdout(Stdio::piped())
81        .stderr(Stdio::piped());
82    #[cfg(unix)]
83    {
84        use std::os::unix::process::CommandExt;
85        // Lead a fresh process group so a timeout can kill the whole tree.
86        command.process_group(0);
87    }
88
89    let mut child = command.spawn().map_err(|e| format!("failed to launch: {e}"))?;
90
91    // Feed stdin from a thread so a child that never reads cannot deadlock the
92    // supervisor. With no input the pipe handle drops here unused, closing the
93    // child's stdin immediately.
94    let stdin_pipe = child.stdin.take();
95    let stdin_thread = stdin_text.map(|text| {
96        let text = text.to_string();
97        std::thread::spawn(move || {
98            if let Some(mut pipe) = stdin_pipe {
99                use std::io::Write;
100                let _ = pipe.write_all(text.as_bytes());
101            }
102        })
103    });
104
105    // Drain both streams concurrently so a chatty child never blocks on a full
106    // pipe while we wait.
107    let mut out_pipe = child.stdout.take().expect("stdout was piped");
108    let mut err_pipe = child.stderr.take().expect("stderr was piped");
109    let out_thread = std::thread::spawn(move || {
110        let mut buf = Vec::new();
111        let _ = out_pipe.read_to_end(&mut buf);
112        buf
113    });
114    let err_thread = std::thread::spawn(move || {
115        let mut buf = Vec::new();
116        let _ = err_pipe.read_to_end(&mut buf);
117        buf
118    });
119
120    let deadline = timeout.map(|t| Instant::now() + t);
121    let (status, timed_out) = loop {
122        match child.try_wait() {
123            Ok(Some(status)) => break (Some(status), false),
124            Ok(None) => {}
125            Err(e) => return Err(format!("waiting for command: {e}")),
126        }
127        if let Some(d) = deadline
128            && Instant::now() >= d
129        {
130            kill_tree(&mut child);
131            let _ = child.wait();
132            break (None, true);
133        }
134        std::thread::sleep(Duration::from_millis(10));
135    };
136
137    if let Some(t) = stdin_thread {
138        let _ = t.join();
139    }
140    let stdout = String::from_utf8_lossy(&out_thread.join().unwrap_or_default()).into_owned();
141    let stderr = String::from_utf8_lossy(&err_thread.join().unwrap_or_default()).into_owned();
142
143    Ok(Outcome {
144        stdout,
145        stderr,
146        status,
147        timed_out,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn resolve_falls_back_to_bare_name() {
157        // Not a ct-* tool: resolution must hand back the name for PATH lookup.
158        assert_eq!(resolve_program("grep", "grep"), OsString::from("grep"));
159        // A pathed command is never sibling-resolved.
160        assert_eq!(
161            resolve_program("/bin/ls", "ls"),
162            OsString::from("/bin/ls")
163        );
164    }
165
166    #[cfg(unix)]
167    #[test]
168    fn captures_streams_and_status() {
169        let mut c = Command::new("sh");
170        c.args(["-c", "echo out; echo err >&2; exit 3"]);
171        let r = run_captured(c, None, None).unwrap();
172        assert_eq!(r.stdout, "out\n");
173        assert_eq!(r.stderr, "err\n");
174        assert_eq!(r.status.unwrap().code(), Some(3));
175        assert!(!r.timed_out);
176    }
177
178    #[cfg(unix)]
179    #[test]
180    fn stdin_text_reaches_the_child() {
181        let mut c = Command::new("cat");
182        c.arg("-");
183        let r = run_captured(c, Some("hello\n"), None).unwrap();
184        assert_eq!(r.stdout, "hello\n");
185    }
186
187    #[cfg(unix)]
188    #[test]
189    fn timeout_kills_and_reports() {
190        let mut c = Command::new("sh");
191        c.args(["-c", "sleep 30"]);
192        let started = Instant::now();
193        let r = run_captured(c, None, Some(Duration::from_millis(100))).unwrap();
194        assert!(r.timed_out);
195        assert!(r.status.is_none());
196        assert!(
197            started.elapsed() < Duration::from_secs(5),
198            "kill must be prompt"
199        );
200    }
201}