specfold_core/runner/
shell.rs1use 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 CannotRun(String),
17}
18
19#[derive(Debug, Clone)]
20pub struct Outcome {
21 pub verdict: Verdict,
22 pub tail: String,
24 pub elapsed: Duration,
25}
26
27const NOT_FOUND: i32 = 127;
29const 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 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}