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        // On Windows the sibling carries an `.exe` suffix; try it first so a
46        // bare `ct-*` name resolves next to us before falling back to PATH.
47        #[cfg(windows)]
48        {
49            let win = dir.join(format!("{name}.exe"));
50            if win.is_file() {
51                return win.into_os_string();
52            }
53        }
54        let candidate = dir.join(name);
55        if candidate.is_file() {
56            return candidate.into_os_string();
57        }
58    }
59    OsString::from(cmd)
60}
61
62/// Kill the child's process group (Unix) or the child itself (elsewhere).
63#[cfg(unix)]
64fn kill_tree(child: &mut Child) {
65    // The child was made a process-group leader at spawn, so a negative pid
66    // signals the whole group — a build tool's own forked children included.
67    let pid = child.id() as i32;
68    unsafe {
69        libc::kill(-pid, libc::SIGKILL);
70    }
71    let _ = child.kill();
72}
73
74#[cfg(not(unix))]
75fn kill_tree(child: &mut Child) {
76    let _ = child.kill();
77}
78
79/// Run `command` to completion with stdout/stderr captured, writing
80/// `stdin_text` (if any) to its standard input, killing it if it outlives
81/// `timeout`.
82pub fn run_captured(
83    mut command: Command,
84    stdin_text: Option<&str>,
85    timeout: Option<Duration>,
86) -> Result<Outcome, String> {
87    command
88        .stdin(Stdio::piped())
89        .stdout(Stdio::piped())
90        .stderr(Stdio::piped());
91    #[cfg(unix)]
92    {
93        use std::os::unix::process::CommandExt;
94        // Lead a fresh process group so a timeout can kill the whole tree.
95        command.process_group(0);
96    }
97
98    let mut child = command
99        .spawn()
100        .map_err(|e| format!("failed to launch: {e}"))?;
101
102    // Feed stdin from a thread so a child that never reads cannot deadlock the
103    // supervisor. With no input the pipe handle drops here unused, closing the
104    // child's stdin immediately.
105    let stdin_pipe = child.stdin.take();
106    let stdin_thread = stdin_text.map(|text| {
107        let text = text.to_string();
108        std::thread::spawn(move || {
109            if let Some(mut pipe) = stdin_pipe {
110                use std::io::Write;
111                let _ = pipe.write_all(text.as_bytes());
112            }
113        })
114    });
115
116    // Drain both streams concurrently so a chatty child never blocks on a full
117    // pipe while we wait.
118    let mut out_pipe = child.stdout.take().expect("stdout was piped");
119    let mut err_pipe = child.stderr.take().expect("stderr was piped");
120    let out_thread = std::thread::spawn(move || {
121        let mut buf = Vec::new();
122        let _ = out_pipe.read_to_end(&mut buf);
123        buf
124    });
125    let err_thread = std::thread::spawn(move || {
126        let mut buf = Vec::new();
127        let _ = err_pipe.read_to_end(&mut buf);
128        buf
129    });
130
131    let deadline = timeout.map(|t| Instant::now() + t);
132    let (status, timed_out) = loop {
133        match child.try_wait() {
134            Ok(Some(status)) => break (Some(status), false),
135            Ok(None) => {}
136            Err(e) => return Err(format!("waiting for command: {e}")),
137        }
138        if let Some(d) = deadline
139            && Instant::now() >= d
140        {
141            kill_tree(&mut child);
142            let _ = child.wait();
143            break (None, true);
144        }
145        std::thread::sleep(Duration::from_millis(10));
146    };
147
148    if let Some(t) = stdin_thread {
149        let _ = t.join();
150    }
151    let stdout = String::from_utf8_lossy(&out_thread.join().unwrap_or_default()).into_owned();
152    let stderr = String::from_utf8_lossy(&err_thread.join().unwrap_or_default()).into_owned();
153
154    Ok(Outcome {
155        stdout,
156        stderr,
157        status,
158        timed_out,
159    })
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn resolve_falls_back_to_bare_name() {
168        // Not a ct-* tool: resolution must hand back the name for PATH lookup.
169        assert_eq!(resolve_program("grep", "grep"), OsString::from("grep"));
170        // A pathed command is never sibling-resolved.
171        assert_eq!(resolve_program("/bin/ls", "ls"), OsString::from("/bin/ls"));
172    }
173
174    #[cfg(unix)]
175    #[test]
176    fn captures_streams_and_status() {
177        let mut c = Command::new("sh");
178        c.args(["-c", "echo out; echo err >&2; exit 3"]);
179        let r = run_captured(c, None, None).unwrap();
180        assert_eq!(r.stdout, "out\n");
181        assert_eq!(r.stderr, "err\n");
182        assert_eq!(r.status.unwrap().code(), Some(3));
183        assert!(!r.timed_out);
184    }
185
186    #[cfg(unix)]
187    #[test]
188    fn stdin_text_reaches_the_child() {
189        let mut c = Command::new("cat");
190        c.arg("-");
191        let r = run_captured(c, Some("hello\n"), None).unwrap();
192        assert_eq!(r.stdout, "hello\n");
193    }
194
195    #[cfg(unix)]
196    #[test]
197    fn timeout_kills_and_reports() {
198        let mut c = Command::new("sh");
199        c.args(["-c", "sleep 30"]);
200        let started = Instant::now();
201        let r = run_captured(c, None, Some(Duration::from_millis(100))).unwrap();
202        assert!(r.timed_out);
203        assert!(r.status.is_none());
204        assert!(
205            started.elapsed() < Duration::from_secs(5),
206            "kill must be prompt"
207        );
208    }
209}