use std::io;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;
pub(crate) struct Output {
pub status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub(crate) enum Run {
Completed(Output),
TimedOut,
}
pub(crate) fn run_process(cmd: &mut Command, timeout: Option<Duration>) -> io::Result<Run> {
match timeout {
None => run_blocking(cmd),
Some(timeout) => run_with_timeout(cmd, timeout),
}
}
fn run_blocking(cmd: &mut Command) -> io::Result<Run> {
let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
Ok(Run::Completed(Output {
status: output.status,
stdout: output.stdout,
stderr: output.stderr,
}))
}
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> io::Result<Run> {
set_own_process_group(cmd);
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let stdout_pipe = child.stdout.take().expect("stdout piped");
let stderr_pipe = child.stderr.take().expect("stderr piped");
wait_and_drain(&mut child, stdout_pipe, stderr_pipe, timeout)
}
fn wait_and_drain<O, E>(
child: &mut Child,
stdout_pipe: O,
stderr_pipe: E,
timeout: Duration,
) -> io::Result<Run>
where
O: io::Read + Send + 'static,
E: io::Read + Send + 'static,
{
let stdout_thread = std::thread::spawn(move || drain(stdout_pipe));
let stderr_thread = std::thread::spawn(move || drain(stderr_pipe));
match child.wait_timeout(timeout)? {
Some(status) => {
let stdout = finish_drain(stdout_thread.join(), "stdout");
let stderr = finish_drain(stderr_thread.join(), "stderr");
Ok(Run::Completed(Output {
status,
stdout: stdout?,
stderr: stderr?,
}))
}
None => {
kill_child_tree(child);
stdout_thread.join().ok();
stderr_thread.join().ok();
Ok(Run::TimedOut)
}
}
}
fn drain<R: io::Read>(mut pipe: R) -> io::Result<Vec<u8>> {
let mut buf = Vec::new();
pipe.read_to_end(&mut buf)?;
Ok(buf)
}
fn finish_drain(
joined: std::thread::Result<io::Result<Vec<u8>>>,
stream: &str,
) -> io::Result<Vec<u8>> {
match joined {
Ok(Ok(buf)) => Ok(buf),
Ok(Err(e)) => Err(io::Error::new(
e.kind(),
format!("failed to read child {stream}: {e}"),
)),
Err(_) => Err(io::Error::other(format!(
"the {stream} drain thread panicked"
))),
}
}
#[cfg(unix)]
fn set_own_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(not(unix))]
fn set_own_process_group(_cmd: &mut Command) {}
#[cfg(unix)]
fn kill_child_tree(child: &mut Child) {
use nix::sys::signal::{killpg, Signal};
use nix::unistd::Pid;
let pgid = Pid::from_raw(child.id() as i32);
let _ = killpg(pgid, Signal::SIGKILL);
child.wait().ok();
}
#[cfg(not(unix))]
fn kill_child_tree(child: &mut Child) {
child.kill().ok();
child.wait().ok();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzer::test_spawn_guard;
struct FailingReader {
head: io::Cursor<Vec<u8>>,
}
impl FailingReader {
fn new(head: &[u8]) -> Self {
Self {
head: io::Cursor::new(head.to_vec()),
}
}
}
impl io::Read for FailingReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match io::Read::read(&mut self.head, buf)? {
0 => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"pipe died mid-stream",
)),
n => Ok(n),
}
}
}
#[test]
fn failing_reader_yields_its_bytes_before_erroring() {
let mut reader = FailingReader::new(b"partial output");
let mut buf = Vec::new();
io::Read::read_to_end(&mut reader, &mut buf).ok();
assert_eq!(buf, b"partial output");
}
#[test]
fn drain_returns_the_whole_stream() {
assert_eq!(drain(&b"complete output"[..]).unwrap(), b"complete output");
}
#[test]
fn drain_errors_on_a_read_that_fails_partway() {
let err = drain(FailingReader::new(b"partial output")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn finish_drain_names_the_stream_and_keeps_the_error_kind() {
let failed: std::thread::Result<io::Result<Vec<u8>>> = Ok(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"pipe died mid-stream",
)));
let err = finish_drain(failed, "stdout").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
assert!(err.to_string().contains("stdout"), "{err}");
}
#[test]
fn finish_drain_turns_a_panicked_thread_into_an_error() {
let panicked: std::thread::Result<io::Result<Vec<u8>>> = Err(Box::new("boom"));
let err = finish_drain(panicked, "stderr").unwrap_err();
assert!(err.to_string().contains("stderr"), "{err}");
}
fn spawn_immediate_child() -> Child {
Command::new("/bin/echo")
.arg("ok")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn /bin/echo")
}
#[test]
fn completed_arm_propagates_a_failed_stdout_drain() {
let _lock = test_spawn_guard();
let mut child = spawn_immediate_child();
let Err(err) = wait_and_drain(
&mut child,
FailingReader::new(b"partial output"),
io::empty(),
Duration::from_secs(10),
) else {
panic!("a failed stdout drain must not be reported as a completed run");
};
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
assert!(err.to_string().contains("stdout"), "{err}");
}
#[test]
fn completed_arm_propagates_a_failed_stderr_drain() {
let _lock = test_spawn_guard();
let mut child = spawn_immediate_child();
let Err(err) = wait_and_drain(
&mut child,
io::empty(),
FailingReader::new(b"partial output"),
Duration::from_secs(10),
) else {
panic!("a failed stderr drain must not be reported as a completed run");
};
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
assert!(err.to_string().contains("stderr"), "{err}");
}
#[test]
fn completed_arm_returns_each_stream_in_its_own_field() {
let _lock = test_spawn_guard();
let mut child = spawn_immediate_child();
let run = wait_and_drain(
&mut child,
&b"the output"[..],
&b"the diagnostics"[..],
Duration::from_secs(10),
)
.unwrap();
match run {
Run::Completed(out) => {
assert!(out.status.success());
assert_eq!(out.stdout, b"the output");
assert_eq!(out.stderr, b"the diagnostics");
}
Run::TimedOut => panic!("a child that already exited must not time out"),
}
}
#[test]
fn timed_out_arm_discards_a_failed_drain() {
let _lock = test_spawn_guard();
let mut cmd = Command::new("sleep");
cmd.arg("60").stdout(Stdio::null()).stderr(Stdio::null());
set_own_process_group(&mut cmd);
let mut child = cmd.spawn().expect("spawn sleep");
let run = wait_and_drain(
&mut child,
FailingReader::new(b"partial output"),
io::empty(),
Duration::from_secs(1),
)
.unwrap();
match run {
Run::TimedOut => {}
Run::Completed(_) => panic!("a slow process should have timed out"),
}
}
#[test]
fn no_timeout_runs_to_completion() {
let _lock = test_spawn_guard();
let mut cmd = Command::new("/bin/echo");
cmd.arg("hello");
match run_process(&mut cmd, None).unwrap() {
Run::Completed(out) => {
assert!(out.status.success());
assert_eq!(out.stdout, b"hello\n");
}
Run::TimedOut => panic!("must not time out when no timeout is set"),
}
}
#[test]
fn completes_within_timeout() {
let _lock = test_spawn_guard();
let mut cmd = Command::new("/bin/echo");
cmd.arg("ok");
match run_process(&mut cmd, Some(Duration::from_secs(10))).unwrap() {
Run::Completed(out) => {
assert!(out.status.success());
assert_eq!(out.stdout, b"ok\n");
}
Run::TimedOut => panic!("a fast process should complete before the deadline"),
}
}
#[test]
fn timeout_kills_slow_process() {
let _lock = test_spawn_guard();
let mut cmd = Command::new("sleep");
cmd.arg("60");
match run_process(&mut cmd, Some(Duration::from_secs(1))).unwrap() {
Run::TimedOut => {}
Run::Completed(_) => panic!("a slow process should have timed out"),
}
}
#[cfg(unix)]
#[test]
fn timeout_kills_whole_process_tree_promptly() {
use std::time::Instant;
let _lock = test_spawn_guard();
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg("sleep 30 | cat");
let start = Instant::now();
match run_process(&mut cmd, Some(Duration::from_secs(1))).unwrap() {
Run::TimedOut => {}
Run::Completed(_) => panic!("a slow process tree should have timed out"),
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"timeout must fire promptly, not wait for orphaned grandchildren; took {elapsed:?}"
);
}
}