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
103/// The process groups of tree gates running right now.
104///
105/// A gate's own process group is what lets a timeout or a cancel kill the
106/// whole tree it forked — and also what hides it from a signal sent to amont:
107/// `kill <pid>`, a tool's timeout, the group of the `git push` that ran the
108/// hook. Fifteen `uv run pyright` groups from killed pushes were found alive
109/// 3 hours to 3 days later, re-parented to init (amont#302). So every running
110/// group is listed here, and the signal watcher (`staged_only`) reaps the list
111/// before amont re-raises and dies.
112#[cfg(unix)]
113static LIVE: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
114
115/// Set once amont is dying of a signal; see [`reap_live`].
116static DYING: AtomicBool = AtomicBool::new(false);
117
118/// Hold the calling thread while amont dies of a signal: the watcher thread
119/// re-raises it with the default disposition, which ends the process. Never
120/// returns once `DYING` is set; returns at once otherwise.
121fn park_if_dying() {
122    while DYING.load(Ordering::SeqCst) {
123        std::thread::sleep(POLL);
124    }
125}
126
127/// Lists `pgid` in [`LIVE`] for as long as it is held.
128#[cfg(unix)]
129struct Listed(u32);
130
131#[cfg(unix)]
132impl Listed {
133    fn new(pgid: u32) -> Listed {
134        LIVE.lock().unwrap_or_else(|p| p.into_inner()).push(pgid);
135        Listed(pgid)
136    }
137}
138
139#[cfg(unix)]
140impl Drop for Listed {
141    fn drop(&mut self) {
142        LIVE.lock()
143            .unwrap_or_else(|p| p.into_inner())
144            .retain(|g| *g != self.0);
145    }
146}
147
148/// Kill every tree gate still running: TERM, the same grace as a timeout,
149/// then KILL. Called by the signal watcher in ordinary thread context, never
150/// from the handler itself. A no-op when no gate runs.
151#[cfg(unix)]
152pub fn reap_live() {
153    // Before the kill: a gate that dies of THIS reap must not return to its
154    // caller, which would finish and exit 0 before the watcher re-raises —
155    // a killed amont reporting success. `run_output` parks instead.
156    DYING.store(true, std::sync::atomic::Ordering::SeqCst);
157    let groups: Vec<u32> = LIVE.lock().unwrap_or_else(|p| p.into_inner()).clone();
158    if groups.is_empty() {
159        return;
160    }
161    for g in &groups {
162        signal_group(*g, SIGTERM);
163    }
164    let until = Instant::now() + GRACE;
165    while Instant::now() < until && groups.iter().any(|g| group_alive(*g)) {
166        std::thread::sleep(POLL);
167    }
168    for g in groups.iter().filter(|g| group_alive(**g)) {
169        signal_group(*g, SIGKILL);
170    }
171}
172
173#[cfg(not(unix))]
174pub fn reap_live() {}
175
176fn kill_tree(child: &mut Child) {
177    #[cfg(unix)]
178    {
179        let pgid = child.id();
180        signal_group(pgid, SIGTERM);
181        let until = Instant::now() + GRACE;
182        while Instant::now() < until {
183            // Reap the leader so its pid cannot linger as a zombie in the
184            // group, then ask whether anybody else is left.
185            let _ = child.try_wait();
186            if !group_alive(pgid) {
187                break;
188            }
189            std::thread::sleep(POLL);
190        }
191        if group_alive(pgid) {
192            signal_group(pgid, SIGKILL);
193        }
194    }
195    #[cfg(not(unix))]
196    {
197        let _ = Command::new("taskkill")
198            .args(["/PID", &child.id().to_string(), "/T", "/F"])
199            .stdin(Stdio::null())
200            .stdout(Stdio::null())
201            .stderr(Stdio::null())
202            .status();
203    }
204    let _ = child.kill();
205    let _ = child.wait();
206}
207
208fn drain(
209    stream: impl Read + Send + 'static,
210    tail: Arc<Mutex<VecDeque<String>>>,
211) -> std::thread::JoinHandle<()> {
212    std::thread::spawn(move || {
213        for line in BufReader::new(stream).lines().map_while(Result::ok) {
214            if let Ok(mut t) = tail.lock() {
215                if t.len() == TAIL {
216                    t.pop_front();
217                }
218                t.push_back(line);
219            }
220        }
221    })
222}
223
224fn summary(tail: &Mutex<VecDeque<String>>) -> String {
225    tail.lock()
226        .ok()
227        .and_then(|t| {
228            t.iter()
229                .rev()
230                .map(|l| l.trim())
231                .find(|l| !l.is_empty())
232                .map(str::to_string)
233        })
234        .unwrap_or_else(|| "no output".to_string())
235}
236
237/// Run `argv` in `cwd` until it exits, `deadline` passes, or `cancel` is set.
238pub fn run(argv: &[String], cwd: &Path, deadline: Instant, cancel: &AtomicBool) -> TreeRun {
239    let ran = run_inner(argv, cwd, deadline, cancel, false).0;
240    park_if_dying();
241    ran
242}
243
244/// [`run`], returning the tool's output (its last lines) on success — for a
245/// bounded, cancellable probe such as `<tool> --version`.
246pub fn run_output(
247    argv: &[String],
248    cwd: &Path,
249    deadline: Instant,
250    cancel: &AtomicBool,
251) -> Result<String, TreeRun> {
252    let ran = run_inner(argv, cwd, deadline, cancel, true);
253    park_if_dying();
254    match ran {
255        (TreeRun::Passed, out) => Ok(out),
256        (other, _) => Err(other),
257    }
258}
259
260fn run_inner(
261    argv: &[String],
262    cwd: &Path,
263    deadline: Instant,
264    cancel: &AtomicBool,
265    need_output: bool,
266) -> (TreeRun, String) {
267    let Some((program, rest)) = argv.split_first() else {
268        return (TreeRun::Spawn("empty command".into()), String::new());
269    };
270    let mut cmd = Command::new(program);
271    cmd.args(rest).current_dir(cwd).stdin(Stdio::null());
272    #[cfg(unix)]
273    let merged = merged_pipe();
274    #[cfg(not(unix))]
275    let merged: Option<(File, Stdio, Stdio)> = None;
276    let reader = match merged {
277        Some((read, out, err)) => {
278            cmd.stdout(out).stderr(err);
279            Some(read)
280        }
281        None => {
282            cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
283            None
284        }
285    };
286    crate::hooks::common::strip_git_env(&mut cmd);
287    #[cfg(unix)]
288    {
289        use std::os::unix::process::CommandExt;
290        cmd.process_group(0);
291        // SAFETY: `setpriority` is async-signal-safe and touches no memory;
292        // it lowers only this child's priority, between fork and exec.
293        unsafe {
294            cmd.pre_exec(|| {
295                libc_setpriority(0, 0, NICENESS);
296                Ok(())
297            });
298        }
299    }
300    // Armed before the spawn: a signal landing between the spawn and the
301    // listing would otherwise find nothing to reap. Idempotent.
302    crate::staged_only::install_signal_handler();
303    let mut child = match cmd.spawn() {
304        Ok(c) => c,
305        Err(e) => return (TreeRun::Spawn(format!("{program}: {e}")), String::new()),
306    };
307    #[cfg(unix)]
308    let _listed = Listed::new(child.id());
309    // The parent's copies of the write end went into `cmd` and die with it,
310    // so the reader sees EOF once every process holding the pipe has exited.
311    drop(cmd);
312    let tail = Arc::new(Mutex::new(VecDeque::with_capacity(TAIL)));
313    let mut drains = Vec::new();
314    if let Some(read) = reader {
315        drains.push(drain(read, Arc::clone(&tail)));
316    }
317    if let Some(out) = child.stdout.take() {
318        drains.push(drain(out, Arc::clone(&tail)));
319    }
320    if let Some(err) = child.stderr.take() {
321        drains.push(drain(err, Arc::clone(&tail)));
322    }
323    loop {
324        match child.try_wait() {
325            Ok(Some(status)) => {
326                // Read to EOF before summarising — bounded, because a
327                // grandchild the tool left behind may still hold the pipe. A
328                // caller that needs the OUTPUT (a version probe) waits up to
329                // its own deadline or cancel: on a loaded machine the reader
330                // can lag the exit, and a half-read version would silently
331                // name another cache namespace.
332                let until = if need_output {
333                    deadline
334                } else {
335                    Instant::now() + GRACE
336                };
337                while Instant::now() < until
338                    && !(need_output && cancel.load(Ordering::SeqCst))
339                    && drains.iter().any(|d| !d.is_finished())
340                {
341                    std::thread::sleep(Duration::from_millis(5));
342                }
343                if need_output && drains.iter().any(|d| !d.is_finished()) {
344                    return (TreeRun::TimedOut, String::new());
345                }
346                let out = tail
347                    .lock()
348                    .map(|t| t.iter().cloned().collect::<Vec<_>>().join("\n"))
349                    .unwrap_or_default();
350                return if status.success() {
351                    (TreeRun::Passed, out)
352                } else {
353                    (TreeRun::Failed(summary(&tail)), out)
354                };
355            }
356            Ok(None) => {}
357            Err(e) => {
358                kill_tree(&mut child);
359                return (TreeRun::Spawn(format!("{program}: {e}")), String::new());
360            }
361        }
362        if cancel.load(Ordering::SeqCst) {
363            kill_tree(&mut child);
364            return (TreeRun::Cancelled, String::new());
365        }
366        if Instant::now() >= deadline {
367            kill_tree(&mut child);
368            return (TreeRun::TimedOut, String::new());
369        }
370        std::thread::sleep(POLL);
371    }
372}
373
374#[cfg(all(test, unix))]
375mod tests {
376    use super::*;
377
378    fn sh(script: &str) -> Vec<String> {
379        vec!["sh".into(), "-c".into(), script.into()]
380    }
381
382    fn soon(ms: u64) -> Instant {
383        Instant::now() + Duration::from_millis(ms)
384    }
385
386    #[test]
387    fn tree_run_passed() {
388        let r = run(
389            &sh("exit 0"),
390            Path::new("."),
391            soon(5_000),
392            &AtomicBool::new(false),
393        );
394        assert_eq!(r, TreeRun::Passed);
395    }
396
397    #[test]
398    fn tree_run_failed_summarises_the_last_line() {
399        let r = run(
400            &sh("echo 'a.ts: bad'; echo '3 problems' >&2; exit 1"),
401            Path::new("."),
402            soon(5_000),
403            &AtomicBool::new(false),
404        );
405        assert_eq!(r, TreeRun::Failed("3 problems".into()));
406    }
407
408    #[test]
409    fn tree_run_spawn_error() {
410        let r = run(
411            &["amont-no-such-program-xyz".to_string()],
412            Path::new("."),
413            soon(5_000),
414            &AtomicBool::new(false),
415        );
416        assert!(matches!(r, TreeRun::Spawn(_)), "{r:?}");
417    }
418
419    /// The deadline kills the WHOLE group: a grandchild the tool forked must
420    /// not survive it (it could still be writing the cache).
421    #[test]
422    fn tree_run_timed_out_leaves_no_child_alive() {
423        let dir = std::env::temp_dir().join(format!("amont-tree-run-{}", std::process::id()));
424        std::fs::create_dir_all(&dir).unwrap();
425        let pidfile = dir.join("grandchild.pid");
426        let script = format!("sleep 30 & echo $! > {}; wait", pidfile.display());
427        let r = run(&sh(&script), &dir, soon(400), &AtomicBool::new(false));
428        assert_eq!(r, TreeRun::TimedOut);
429        let pid: i32 = std::fs::read_to_string(&pidfile)
430            .unwrap()
431            .trim()
432            .parse()
433            .unwrap();
434        std::thread::sleep(Duration::from_millis(100));
435        // SAFETY: probing a pid with signal 0.
436        let alive = unsafe { libc_kill(pid, 0) == 0 };
437        assert!(!alive, "grandchild {pid} survived the deadline");
438        let _ = std::fs::remove_dir_all(&dir);
439    }
440
441    #[test]
442    fn tree_run_cancelled() {
443        let flag = Arc::new(AtomicBool::new(false));
444        let setter = Arc::clone(&flag);
445        std::thread::spawn(move || {
446            std::thread::sleep(Duration::from_millis(150));
447            setter.store(true, Ordering::SeqCst);
448        });
449        let r = run(&sh("sleep 30"), Path::new("."), soon(10_000), &flag);
450        assert_eq!(r, TreeRun::Cancelled);
451    }
452}