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                // C237: a missing program here is the shell itself, not the command.
80                verdict: Verdict::CannotRun(format!(
81                    "shell '{}' cannot start: {e}; [verify] shell",
82                    shell.unwrap_or("sh")
83                )),
84                tail: String::new(),
85                elapsed: start.elapsed(),
86            };
87        }
88    };
89    let out = drain(child.stdout.take());
90    let err = drain(child.stderr.take());
91    let deadline = start + timeout;
92    let status = loop {
93        match child.try_wait() {
94            Ok(Some(s)) => break Some(s),
95            Ok(None) if Instant::now() >= deadline => {
96                kill_group(&mut child);
97                break None;
98            }
99            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
100            Err(_) => break None,
101        }
102    };
103    let stderr = err.recv_timeout(DRAIN).unwrap_or_default();
104    let stdout = out.recv_timeout(DRAIN).unwrap_or_default();
105    let tail = last_line(&stderr).or_else(|| last_line(&stdout)).unwrap_or_default();
106    let verdict = match status {
107        None => Verdict::Timeout,
108        Some(s) if s.success() => Verdict::Pass,
109        Some(s) if s.code() == Some(NOT_FOUND) => Verdict::CannotRun(format!("command not found ({tail})")),
110        Some(s) => Verdict::Fail(s.code()),
111    };
112    Outcome { verdict, tail, elapsed: start.elapsed() }
113}
114
115#[cfg(all(test, unix))]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn verdicts() {
121        let root = std::env::temp_dir();
122        let t = Duration::from_secs(5);
123        assert_eq!(run(&root, "true", &[], t, None).verdict, Verdict::Pass);
124        let failed = run(&root, "echo nope >&2; exit 3", &[], t, None);
125        assert_eq!((failed.verdict, failed.tail.as_str()), (Verdict::Fail(Some(3)), "nope"));
126        assert!(matches!(run(&root, "definitely-not-a-program-xyz", &[], t, None).verdict, Verdict::CannotRun(_)));
127        let env = [("SPECFOLD_T".to_string(), "1".to_string())];
128        assert_eq!(run(&root, "test \"$SPECFOLD_T\" = 1", &env, t, None).verdict, Verdict::Pass);
129    }
130
131    #[test]
132    fn a_configured_shell_runs_its_own_syntax() {
133        let root = std::env::temp_dir();
134        let t = Duration::from_secs(5);
135        assert_eq!(run(&root, "diff <(echo a) <(echo a)", &[], t, Some("bash")).verdict, Verdict::Pass);
136    }
137
138    #[test]
139    fn a_hung_command_and_its_children_are_killed() {
140        let root = std::env::temp_dir();
141        let o = run(&root, "sleep 30 & sleep 30", &[], Duration::from_millis(300), None);
142        assert_eq!(o.verdict, Verdict::Timeout);
143        assert!(o.elapsed < Duration::from_secs(5), "{:?}", o.elapsed);
144    }
145}