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(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}