Skip to main content

scrut/executors/
subprocess_runner.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8use 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/// A runner that starts an interpreter (usually `bash`) in a sub-process and
34/// writes the shell expression of a given [`crate::testcase::TestCase`] into
35/// STDIN.
36///
37/// Constraining the max execution time is supported.
38#[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        // apply environment variables (ensure SHELL is set)
52        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            // Why is a temporary file created here? Because the subprocess crate closes the
63            // STDIN pipe when it goes out of scope, which will interrupt the detached child.
64            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        // when detaching, do not wait for the process to finish
94        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        // constraint max execution time?
112        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        // wait for the process to finish and handle the result
124        let (stdout, stderr, exit_code) = match comm.read() {
125            // successs! we are happy!
126            Ok((stdout, stderr)) => (
127                stdout,
128                stderr,
129                process.wait().context("capture process exit")?.into(),
130            ),
131
132            // bummer, a sad thing happened
133            Err(err) => {
134                let kind = err.kind();
135                let (stdout, stderr) = err.capture;
136
137                // windows execution returns [`ErrorKind::BrokenPipe`] in case
138                // anything explicitly runs `exit <code>`
139                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}