scrut/executors/
subprocess_runner.rs1use std::io::ErrorKind;
9use std::io::Seek;
10use std::io::Write;
11use std::path::PathBuf;
12use std::time::Duration;
13
14use anyhow::Context;
15use anyhow::Result;
16use subprocess::Exec;
17use subprocess::ExitStatus;
18use subprocess::NullFile;
19use subprocess::Redirection;
20use tempfile::tempfile_in;
21use tracing::debug;
22use tracing::debug_span;
23use tracing::trace;
24
25use super::DEFAULT_SHELL;
26use super::context::Context as ExecutionContext;
27use super::runner::Runner;
28use crate::output::DetachedProcess;
29use crate::output::ExitStatus as OutputExitStatus;
30use crate::output::Output;
31use crate::testcase::TestCase;
32
33#[derive(Clone)]
39pub struct SubprocessRunner(pub(super) PathBuf);
40
41impl SubprocessRunner {
42 pub fn new(p: PathBuf) -> Self {
43 Self(p)
44 }
45}
46
47impl Runner for SubprocessRunner {
48 fn run(&self, _name: &str, testcase: &TestCase, context: &ExecutionContext) -> Result<Output> {
49 let shell = &self.0;
50
51 let mut envs = testcase.config.environment.clone();
53 envs.insert("SHELL".into(), shell.to_string_lossy().to_string());
54
55 let mut exec = Exec::cmd(shell)
56 .env_extend(&Vec::from_iter(envs.iter()))
57 .cwd(&context.work_directory);
58
59 let input = &testcase.shell_expression as &str;
60 let is_detached = testcase.config.detached.unwrap_or(false);
61 if is_detached {
62 let mut tmp =
65 tempfile_in(&context.temp_directory).context("Create temporary STDIN file")?;
66 tmp.write(input.as_bytes()).context("write to STDIN file")?;
67 tmp.seek(std::io::SeekFrom::Start(0))
68 .context("reset STDIN file")?;
69 exec = exec
70 .stdout(NullFile)
71 .stderr(NullFile)
72 .stdin(Redirection::File(tmp));
73 } else {
74 exec = exec
75 .stdout(Redirection::Pipe)
76 .stderr(
77 if testcase.config.output_stream
78 == Some(crate::config::OutputStreamControl::Combined)
79 {
80 Redirection::Merge
81 } else {
82 Redirection::Pipe
83 },
84 )
85 .stdin(Redirection::Pipe);
86 }
87
88 let mut process = exec.detached().popen().context("start process")?;
89 let span = debug_span!("process", pid = ?process.pid());
90 let _s = span.enter();
91 trace!(testcase = %&testcase, "running testcase in subprocess");
92
93 if is_detached {
95 let detached_process =
96 match (process.pid(), testcase.config.detached_kill_signal.clone()) {
97 (Some(pid), Some(signal)) => Some(DetachedProcess { pid, signal }),
98 (_, _) => None,
99 };
100 debug!(
101 "detaching, not waiting for output, marking for kill = {}",
102 detached_process.is_some(),
103 );
104 return Ok(Output {
105 exit_code: OutputExitStatus::Detached,
106 detached_process,
107 ..Default::default()
108 });
109 }
110
111 let mut comm = process.communicate_start(Some(input.as_bytes().to_vec()));
113 if let Some(timeout) = testcase.config.timeout {
114 comm = comm.limit_time(timeout);
115 debug!(
116 "waiting for output (max {})",
117 humantime::format_duration(Duration::from_secs(timeout.as_secs()))
118 );
119 } else {
120 debug!("waiting for output (no timeout)");
121 }
122
123 let (stdout, stderr, exit_code) = match comm.read() {
125 Ok((stdout, stderr)) => (
127 stdout,
128 stderr,
129 process.wait().context("capture process exit")?.into(),
130 ),
131
132 Err(err) => {
134 let kind = err.kind();
135 let (stdout, stderr) = err.capture;
136
137 let exit = if cfg!(windows) {
140 let process_result = process.wait().unwrap_or(ExitStatus::Undetermined);
141 if kind == ErrorKind::TimedOut {
142 OutputExitStatus::Timeout(testcase.config.timeout.unwrap_or_default())
143 } else if let ExitStatus::Exited(code) = process_result {
144 (code as i32).into()
145 } else {
146 OutputExitStatus::Unknown
147 }
148 } else if kind == ErrorKind::TimedOut {
149 OutputExitStatus::Timeout(testcase.config.timeout.unwrap_or_default())
150 } else {
151 OutputExitStatus::Unknown
152 };
153 (stdout, stderr, exit)
154 }
155 };
156
157 Ok(Output {
158 stderr: testcase
159 .render_output(&stderr.unwrap_or_default()[..])?
160 .to_vec()
161 .into(),
162 stdout: testcase
163 .render_output(&stdout.unwrap_or_default()[..])?
164 .to_vec()
165 .into(),
166 exit_code,
167 detached_process: None,
168 })
169 }
170}
171
172impl Default for SubprocessRunner {
173 fn default() -> Self {
174 Self(DEFAULT_SHELL.to_owned())
175 }
176}
177
178impl From<ExitStatus> for OutputExitStatus {
179 fn from(value: ExitStatus) -> Self {
180 match value {
181 ExitStatus::Exited(code) => OutputExitStatus::Code(code as i32),
182 ExitStatus::Signaled(_) => OutputExitStatus::Unknown,
183 ExitStatus::Other(code) => OutputExitStatus::Code(code),
184 ExitStatus::Undetermined => OutputExitStatus::Unknown,
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use std::time::Duration;
192
193 use super::Runner;
194 use super::SubprocessRunner;
195 use crate::config::OutputStreamControl;
196 use crate::config::TestCaseConfig;
197 use crate::executors::context::Context as ExecutionContext;
198 use crate::output::ExitStatus;
199 use crate::output::Output;
200 use crate::testcase::TestCase;
201
202 #[cfg(not(target_os = "windows"))]
203 #[cfg(feature = "volatile_tests")]
204 #[test]
205 fn test_execute_with_timeout_with_timeout() {
206 let output = SubprocessRunner::default()
207 .run(
208 "name",
209 &TestCase::from_expression("sleep 0.2 && echo OK1 && sleep 0.2 && echo OK2"),
210 &ExecutionContext::new_for_test(),
211 )
212 .expect("execute without error");
213 let expect: Output = ("OK1\nOK2\n", "").into();
214 assert_eq!(expect, output);
215 }
216
217 #[test]
218 fn test_execute_captures_stdout_and_stderr_separately() {
219 let output = SubprocessRunner::default()
220 .run(
221 "name",
222 &TestCase::from_expression("echo OK1 && ( 1>&2 echo OK2 )"),
223 &ExecutionContext::new_for_test(),
224 )
225 .expect("execute without error");
226 let expect: Output = ("OK1\n", "OK2\n").into();
227 assert_eq!(expect, output);
228 }
229
230 #[test]
231 fn test_execute_captures_stdout_and_stderr_combined() {
232 let output = SubprocessRunner::default()
233 .run(
234 "name",
235 &TestCase {
236 title: "Test".into(),
237 shell_expression: "echo OK1 && ( 1>&2 echo OK2 )".into(),
238 config: TestCaseConfig {
239 output_stream: Some(OutputStreamControl::Combined),
240 ..Default::default()
241 },
242 ..Default::default()
243 },
244 &ExecutionContext::new_for_test(),
245 )
246 .expect("execute without error");
247 let expect: Output = ("OK1\nOK2\n", "").into();
248 assert_eq!(expect, output);
249 }
250
251 #[cfg(not(target_os = "windows"))]
252 #[test]
253 fn test_execute_captures_non_printable_characters() {
254 let output = SubprocessRunner::default()
255 .run(
256 "name",
257 &TestCase::from_expression("echo -e \"š¦\r\nš\""),
258 &ExecutionContext::new_for_test(),
259 )
260 .expect("execute without error");
261
262 let expect: Output = ("š¦\nš\n", "").into();
263 assert_eq!(expect, output);
264 }
265
266 #[cfg(not(target_os = "windows"))]
267 #[test]
268 fn test_execute_captures_exit_code() {
269 let output = SubprocessRunner::default()
270 .run(
271 "name",
272 &TestCase::from_expression("( exit 123 )"),
273 &ExecutionContext::new_for_test(),
274 )
275 .expect("execute without error");
276
277 let expect: Output = ("", "", Some(123)).into();
278 assert_eq!(expect, output);
279 }
280
281 #[test]
282 fn test_execute_respects_timeout() {
283 let start = std::time::SystemTime::now();
284 let output = SubprocessRunner::default()
285 .run(
286 "name",
287 &TestCase::from_expression_timed(
288 "echo ONE && sleep 1 && echo TWO",
289 Some(Duration::from_millis(100)),
290 ),
291 &ExecutionContext::new_for_test(),
292 )
293 .expect("execution still ends in non-error");
294 let duration = std::time::SystemTime::now()
295 .duration_since(start)
296 .expect("duration between start and now");
297
298 assert!(
299 duration >= Duration::from_millis(100),
300 "waited at least 100 ms ({:?})",
301 duration,
302 );
303 let max_wait = if cfg!(windows) { 10000 } else { 1000 };
304 assert!(
305 duration < Duration::from_millis(max_wait),
306 "waited at most 1 s ({:?})",
307 duration,
308 );
309 assert_eq!(
310 ExitStatus::Timeout(Duration::from_millis(100)),
311 output.exit_code,
312 "timeout reflected in exit code",
313 );
314 }
315}