Skip to main content

amont_runtime/
tree_run.rs

1//! Running one tree gate's command with a deadline (ADR-0024).
2//!
3//! A tree gate is optional work beside the commit's real checks, so it must
4//! never outlive the time it was given, and never leave anything running when
5//! it is stopped:
6//!
7//! - it runs in its **own process group**, reniced so it yields CPU to the
8//!   mandatory checks;
9//! - it has a hard **deadline** and a **cancel** flag (the dispatcher sets it
10//!   when the slack after the mandatory checks runs out);
11//! - on either, the **whole group** is killed (TERM, then KILL) before this
12//!   returns, so a lock held for the run is released only once nothing that
13//!   could still write the cache is alive.
14//!
15//! The outcome is richer than `common::run_quiet`'s `bool`. The caller needs to
16//! tell a lint failure (withhold the stamp, say how many problems) from a
17//! timeout or a cancel (the cache was not proven cold or warm) from a tool that
18//! could not start.
19
20use std::collections::VecDeque;
21use std::fs::File;
22use std::io::{BufRead, BufReader, Read};
23use std::path::Path;
24use std::process::{Child, Command, Stdio};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29/// How a tree gate's run ended.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum TreeRun {
32    /// Exit 0.
33    Passed,
34    /// Non-zero exit, with a one-line summary (the tool's last non-empty
35    /// output line, e.g. eslint's `✖ 3 problems (3 errors, 0 warnings)`).
36    Failed(String),
37    /// The deadline passed; the group was killed.
38    TimedOut,
39    /// The cancel flag was set; the group was killed.
40    Cancelled,
41    /// The command could not be started.
42    Spawn(String),
43}
44
45/// Lines of output kept for the summary. The tool's output is not shown: a
46/// tree gate never decides the commit, so its findings are CI's to print.
47const TAIL: usize = 64;
48
49/// How often the runner looks at the child, the deadline and the flag.
50const POLL: Duration = Duration::from_millis(20);
51
52/// How long TERM gets before KILL.
53const GRACE: Duration = Duration::from_millis(500);
54
55/// Nice increment for a tree gate: below the mandatory checks, not starved.
56#[cfg(unix)]
57const NICENESS: i32 = 10;
58
59#[cfg(unix)]
60extern "C" {
61    #[link_name = "setpriority"]
62    fn libc_setpriority(which: i32, who: u32, prio: i32) -> i32;
63    #[link_name = "kill"]
64    fn libc_kill(pid: i32, sig: i32) -> i32;
65    #[link_name = "pipe"]
66    fn libc_pipe(fds: *mut i32) -> i32;
67}
68
69/// One pipe for BOTH stdout and stderr, as `2>&1` would: the tool's lines
70/// arrive in the order it wrote them, so "the last line" means something.
71#[cfg(unix)]
72fn merged_pipe() -> Option<(File, Stdio, Stdio)> {
73    use std::os::unix::io::FromRawFd;
74    let mut fds = [0i32; 2];
75    // SAFETY: `fds` is two writable ints, which is all `pipe` writes.
76    if unsafe { libc_pipe(fds.as_mut_ptr()) } != 0 {
77        return None;
78    }
79    // SAFETY: both fds were just created and are owned by nobody else.
80    let (read, write) = unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) };
81    let write2 = write.try_clone().ok()?;
82    Some((read, Stdio::from(write), Stdio::from(write2)))
83}
84
85#[cfg(unix)]
86const SIGTERM: i32 = 15;
87#[cfg(unix)]
88const SIGKILL: i32 = 9;
89
90/// Signal every process in `pgid`'s group. `kill(-pgid, sig)`.
91#[cfg(unix)]
92fn signal_group(pgid: u32, sig: i32) -> bool {
93    // SAFETY: plain integers in, an int out; no memory is shared.
94    unsafe { libc_kill(-(pgid as i32), sig) == 0 }
95}
96
97/// Whether any process of the group is still alive (`kill(-pgid, 0)`).
98#[cfg(unix)]
99pub fn group_alive(pgid: u32) -> bool {
100    signal_group(pgid, 0)
101}
102
103fn kill_tree(child: &mut Child) {
104    #[cfg(unix)]
105    {
106        let pgid = child.id();
107        signal_group(pgid, SIGTERM);
108        let until = Instant::now() + GRACE;
109        while Instant::now() < until {
110            // Reap the leader so its pid cannot linger as a zombie in the
111            // group, then ask whether anybody else is left.
112            let _ = child.try_wait();
113            if !group_alive(pgid) {
114                break;
115            }
116            std::thread::sleep(POLL);
117        }
118        if group_alive(pgid) {
119            signal_group(pgid, SIGKILL);
120        }
121    }
122    #[cfg(not(unix))]
123    {
124        let _ = Command::new("taskkill")
125            .args(["/PID", &child.id().to_string(), "/T", "/F"])
126            .stdin(Stdio::null())
127            .stdout(Stdio::null())
128            .stderr(Stdio::null())
129            .status();
130    }
131    let _ = child.kill();
132    let _ = child.wait();
133}
134
135fn drain(
136    stream: impl Read + Send + 'static,
137    tail: Arc<Mutex<VecDeque<String>>>,
138) -> std::thread::JoinHandle<()> {
139    std::thread::spawn(move || {
140        for line in BufReader::new(stream).lines().map_while(Result::ok) {
141            if let Ok(mut t) = tail.lock() {
142                if t.len() == TAIL {
143                    t.pop_front();
144                }
145                t.push_back(line);
146            }
147        }
148    })
149}
150
151fn summary(tail: &Mutex<VecDeque<String>>) -> String {
152    tail.lock()
153        .ok()
154        .and_then(|t| {
155            t.iter()
156                .rev()
157                .map(|l| l.trim())
158                .find(|l| !l.is_empty())
159                .map(str::to_string)
160        })
161        .unwrap_or_else(|| "no output".to_string())
162}
163
164/// Run `argv` in `cwd` until it exits, `deadline` passes, or `cancel` is set.
165pub fn run(argv: &[String], cwd: &Path, deadline: Instant, cancel: &AtomicBool) -> TreeRun {
166    run_inner(argv, cwd, deadline, cancel, false).0
167}
168
169/// [`run`], returning the tool's output (its last lines) on success — for a
170/// bounded, cancellable probe such as `<tool> --version`.
171pub fn run_output(
172    argv: &[String],
173    cwd: &Path,
174    deadline: Instant,
175    cancel: &AtomicBool,
176) -> Result<String, TreeRun> {
177    match run_inner(argv, cwd, deadline, cancel, true) {
178        (TreeRun::Passed, out) => Ok(out),
179        (other, _) => Err(other),
180    }
181}
182
183fn run_inner(
184    argv: &[String],
185    cwd: &Path,
186    deadline: Instant,
187    cancel: &AtomicBool,
188    need_output: bool,
189) -> (TreeRun, String) {
190    let Some((program, rest)) = argv.split_first() else {
191        return (TreeRun::Spawn("empty command".into()), String::new());
192    };
193    let mut cmd = Command::new(program);
194    cmd.args(rest).current_dir(cwd).stdin(Stdio::null());
195    #[cfg(unix)]
196    let merged = merged_pipe();
197    #[cfg(not(unix))]
198    let merged: Option<(File, Stdio, Stdio)> = None;
199    let reader = match merged {
200        Some((read, out, err)) => {
201            cmd.stdout(out).stderr(err);
202            Some(read)
203        }
204        None => {
205            cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
206            None
207        }
208    };
209    crate::hooks::common::strip_git_env(&mut cmd);
210    #[cfg(unix)]
211    {
212        use std::os::unix::process::CommandExt;
213        cmd.process_group(0);
214        // SAFETY: `setpriority` is async-signal-safe and touches no memory;
215        // it lowers only this child's priority, between fork and exec.
216        unsafe {
217            cmd.pre_exec(|| {
218                libc_setpriority(0, 0, NICENESS);
219                Ok(())
220            });
221        }
222    }
223    let mut child = match cmd.spawn() {
224        Ok(c) => c,
225        Err(e) => return (TreeRun::Spawn(format!("{program}: {e}")), String::new()),
226    };
227    // The parent's copies of the write end went into `cmd` and die with it,
228    // so the reader sees EOF once every process holding the pipe has exited.
229    drop(cmd);
230    let tail = Arc::new(Mutex::new(VecDeque::with_capacity(TAIL)));
231    let mut drains = Vec::new();
232    if let Some(read) = reader {
233        drains.push(drain(read, Arc::clone(&tail)));
234    }
235    if let Some(out) = child.stdout.take() {
236        drains.push(drain(out, Arc::clone(&tail)));
237    }
238    if let Some(err) = child.stderr.take() {
239        drains.push(drain(err, Arc::clone(&tail)));
240    }
241    loop {
242        match child.try_wait() {
243            Ok(Some(status)) => {
244                // Read to EOF before summarising — bounded, because a
245                // grandchild the tool left behind may still hold the pipe. A
246                // caller that needs the OUTPUT (a version probe) waits up to
247                // its own deadline or cancel: on a loaded machine the reader
248                // can lag the exit, and a half-read version would silently
249                // name another cache namespace.
250                let until = if need_output {
251                    deadline
252                } else {
253                    Instant::now() + GRACE
254                };
255                while Instant::now() < until
256                    && !(need_output && cancel.load(Ordering::SeqCst))
257                    && drains.iter().any(|d| !d.is_finished())
258                {
259                    std::thread::sleep(Duration::from_millis(5));
260                }
261                if need_output && drains.iter().any(|d| !d.is_finished()) {
262                    return (TreeRun::TimedOut, String::new());
263                }
264                let out = tail
265                    .lock()
266                    .map(|t| t.iter().cloned().collect::<Vec<_>>().join("\n"))
267                    .unwrap_or_default();
268                return if status.success() {
269                    (TreeRun::Passed, out)
270                } else {
271                    (TreeRun::Failed(summary(&tail)), out)
272                };
273            }
274            Ok(None) => {}
275            Err(e) => {
276                kill_tree(&mut child);
277                return (TreeRun::Spawn(format!("{program}: {e}")), String::new());
278            }
279        }
280        if cancel.load(Ordering::SeqCst) {
281            kill_tree(&mut child);
282            return (TreeRun::Cancelled, String::new());
283        }
284        if Instant::now() >= deadline {
285            kill_tree(&mut child);
286            return (TreeRun::TimedOut, String::new());
287        }
288        std::thread::sleep(POLL);
289    }
290}
291
292#[cfg(all(test, unix))]
293mod tests {
294    use super::*;
295
296    fn sh(script: &str) -> Vec<String> {
297        vec!["sh".into(), "-c".into(), script.into()]
298    }
299
300    fn soon(ms: u64) -> Instant {
301        Instant::now() + Duration::from_millis(ms)
302    }
303
304    #[test]
305    fn tree_run_passed() {
306        let r = run(
307            &sh("exit 0"),
308            Path::new("."),
309            soon(5_000),
310            &AtomicBool::new(false),
311        );
312        assert_eq!(r, TreeRun::Passed);
313    }
314
315    #[test]
316    fn tree_run_failed_summarises_the_last_line() {
317        let r = run(
318            &sh("echo 'a.ts: bad'; echo '3 problems' >&2; exit 1"),
319            Path::new("."),
320            soon(5_000),
321            &AtomicBool::new(false),
322        );
323        assert_eq!(r, TreeRun::Failed("3 problems".into()));
324    }
325
326    #[test]
327    fn tree_run_spawn_error() {
328        let r = run(
329            &["amont-no-such-program-xyz".to_string()],
330            Path::new("."),
331            soon(5_000),
332            &AtomicBool::new(false),
333        );
334        assert!(matches!(r, TreeRun::Spawn(_)), "{r:?}");
335    }
336
337    /// The deadline kills the WHOLE group: a grandchild the tool forked must
338    /// not survive it (it could still be writing the cache).
339    #[test]
340    fn tree_run_timed_out_leaves_no_child_alive() {
341        let dir = std::env::temp_dir().join(format!("amont-tree-run-{}", std::process::id()));
342        std::fs::create_dir_all(&dir).unwrap();
343        let pidfile = dir.join("grandchild.pid");
344        let script = format!("sleep 30 & echo $! > {}; wait", pidfile.display());
345        let r = run(&sh(&script), &dir, soon(400), &AtomicBool::new(false));
346        assert_eq!(r, TreeRun::TimedOut);
347        let pid: i32 = std::fs::read_to_string(&pidfile)
348            .unwrap()
349            .trim()
350            .parse()
351            .unwrap();
352        std::thread::sleep(Duration::from_millis(100));
353        // SAFETY: probing a pid with signal 0.
354        let alive = unsafe { libc_kill(pid, 0) == 0 };
355        assert!(!alive, "grandchild {pid} survived the deadline");
356        let _ = std::fs::remove_dir_all(&dir);
357    }
358
359    #[test]
360    fn tree_run_cancelled() {
361        let flag = Arc::new(AtomicBool::new(false));
362        let setter = Arc::clone(&flag);
363        std::thread::spawn(move || {
364            std::thread::sleep(Duration::from_millis(150));
365            setter.store(true, Ordering::SeqCst);
366        });
367        let r = run(&sh("sleep 30"), Path::new("."), soon(10_000), &flag);
368        assert_eq!(r, TreeRun::Cancelled);
369    }
370}