Skip to main content

specfold_core/runner/
shell.rs

1//! One shell command with a deadline. On timeout the whole process group is killed, so a
2//! command that started children cannot keep the run waiting on their open pipes.
3
4use std::io::Read;
5use std::path::Path;
6use std::process::{Child, Command, Stdio};
7use std::sync::mpsc;
8use std::time::{Duration, Instant};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Verdict {
12    Pass,
13    Fail(Option<i32>),
14    Timeout,
15    /// The command could not run: a program is missing, or the shell itself failed to start.
16    CannotRun(String),
17}
18
19#[derive(Debug, Clone)]
20pub struct Outcome {
21    pub verdict: Verdict,
22    /// The last line of output, stderr first, for the report.
23    pub tail: String,
24    pub elapsed: Duration,
25}
26
27/// Exit status shells use for "command not found".
28const NOT_FOUND: i32 = 127;
29/// How long to wait for output pipes after a kill before giving up on them.
30const DRAIN: Duration = Duration::from_secs(1);
31
32fn spawn(root: &Path, cmd: &str, env: &[(String, String)], shell: Option<&str>) -> std::io::Result<Child> {
33    let (prog, flag) = if cfg!(windows) { ("cmd", "/C") } else { (shell.unwrap_or("sh"), "-c") };
34    let mut c = Command::new(prog);
35    c.arg(flag).arg(cmd).current_dir(root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
36    for (k, v) in env {
37        c.env(k, v);
38    }
39    #[cfg(unix)]
40    {
41        use std::os::unix::process::CommandExt;
42        c.process_group(0);
43    }
44    c.spawn()
45}
46
47fn kill_group(child: &mut Child) {
48    #[cfg(unix)]
49    {
50        // procps-ng 3.3 reads `kill -KILL -<pgid>` as a signal to its own group; `-s … --` does not.
51        let _ = Command::new("kill").args(["-s", "KILL", "--"]).arg(format!("-{}", child.id())).status();
52    }
53    let _ = child.kill();
54    let _ = child.wait();
55}
56
57fn drain<R: Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<String> {
58    let (tx, rx) = mpsc::channel();
59    std::thread::spawn(move || {
60        let mut buf = String::new();
61        if let Some(mut p) = pipe {
62            let _ = p.read_to_string(&mut buf);
63        }
64        let _ = tx.send(buf);
65    });
66    rx
67}
68
69fn last_line(text: &str) -> Option<String> {
70    text.lines().rev().find(|l| !l.trim().is_empty()).map(|l| l.trim().chars().take(200).collect())
71}
72
73pub fn run(root: &Path, cmd: &str, env: &[(String, String)], timeout: Duration, shell: Option<&str>) -> Outcome {
74    let start = Instant::now();
75    let mut child = match spawn(root, cmd, env, shell) {
76        Ok(c) => c,
77        Err(e) => {
78            return Outcome {
79                verdict: Verdict::CannotRun(e.to_string()),
80                tail: String::new(),
81                elapsed: start.elapsed(),
82            };
83        }
84    };
85    let out = drain(child.stdout.take());
86    let err = drain(child.stderr.take());
87    let deadline = start + timeout;
88    let status = loop {
89        match child.try_wait() {
90            Ok(Some(s)) => break Some(s),
91            Ok(None) if Instant::now() >= deadline => {
92                kill_group(&mut child);
93                break None;
94            }
95            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
96            Err(_) => break None,
97        }
98    };
99    let stderr = err.recv_timeout(DRAIN).unwrap_or_default();
100    let stdout = out.recv_timeout(DRAIN).unwrap_or_default();
101    let tail = last_line(&stderr).or_else(|| last_line(&stdout)).unwrap_or_default();
102    let verdict = match status {
103        None => Verdict::Timeout,
104        Some(s) if s.success() => Verdict::Pass,
105        Some(s) if s.code() == Some(NOT_FOUND) => Verdict::CannotRun(format!("command not found ({tail})")),
106        Some(s) => Verdict::Fail(s.code()),
107    };
108    Outcome { verdict, tail, elapsed: start.elapsed() }
109}
110
111#[cfg(all(test, unix))]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn verdicts() {
117        let root = std::env::temp_dir();
118        let t = Duration::from_secs(5);
119        assert_eq!(run(&root, "true", &[], t, None).verdict, Verdict::Pass);
120        let failed = run(&root, "echo nope >&2; exit 3", &[], t, None);
121        assert_eq!((failed.verdict, failed.tail.as_str()), (Verdict::Fail(Some(3)), "nope"));
122        assert!(matches!(run(&root, "definitely-not-a-program-xyz", &[], t, None).verdict, Verdict::CannotRun(_)));
123        let env = [("SPECFOLD_T".to_string(), "1".to_string())];
124        assert_eq!(run(&root, "test \"$SPECFOLD_T\" = 1", &env, t, None).verdict, Verdict::Pass);
125    }
126
127    #[test]
128    fn a_configured_shell_runs_its_own_syntax() {
129        let root = std::env::temp_dir();
130        let t = Duration::from_secs(5);
131        assert_eq!(run(&root, "diff <(echo a) <(echo a)", &[], t, Some("bash")).verdict, Verdict::Pass);
132    }
133
134    #[test]
135    fn a_hung_command_and_its_children_are_killed() {
136        let root = std::env::temp_dir();
137        let o = run(&root, "sleep 30 & sleep 30", &[], Duration::from_millis(300), None);
138        assert_eq!(o.verdict, Verdict::Timeout);
139        assert!(o.elapsed < Duration::from_secs(5), "{:?}", o.elapsed);
140    }
141}