use std::io::Read;
use std::process::{Command, Output, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(10);
#[derive(Debug)]
pub(crate) enum Bounded {
Exited(Output),
Killed,
OutputHeldOpen,
}
struct Draining(Receiver<std::io::Result<Vec<u8>>>);
impl Draining {
fn of<R: Read + Send + 'static>(mut source: R) -> Self {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut buffer = Vec::new();
let read = source.read_to_end(&mut buffer).map(|_count| buffer);
let _ = sender.send(read);
});
Self(receiver)
}
fn collect_within(&self, budget: Duration) -> Option<std::io::Result<Vec<u8>>> {
self.0.recv_timeout(budget).ok()
}
}
pub(crate) fn run_bounded(command: &mut Command, budget: Duration) -> std::io::Result<Bounded> {
let deadline = Instant::now() + budget;
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = Draining::of(
child
.stdout
.take()
.expect("stdout is piped on the line that spawned this child"),
);
let stderr = Draining::of(
child
.stderr
.take()
.expect("stderr is piped on the line that spawned this child"),
);
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
child.kill()?;
child.wait()?;
return Ok(Bounded::Killed);
}
std::thread::sleep(POLL_INTERVAL.min(left));
};
let Some(stdout) = stdout.collect_within(deadline.saturating_duration_since(Instant::now()))
else {
return Ok(Bounded::OutputHeldOpen);
};
let Some(stderr) = stderr.collect_within(deadline.saturating_duration_since(Instant::now()))
else {
return Ok(Bounded::OutputHeldOpen);
};
Ok(Bounded::Exited(Output {
status,
stdout: stdout?,
stderr: stderr?,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_child_that_outlives_its_budget_is_killed_rather_than_waited_for() {
let started = Instant::now();
let outcome = run_bounded(Command::new("sleep").arg("30"), Duration::from_millis(100))
.expect("the child spawns, and killing it is the only other syscall");
assert!(
matches!(outcome, Bounded::Killed),
"a 30s sleep cannot finish inside 100ms: {outcome:?}"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"the sleep was waited out rather than killed, in {:?}",
started.elapsed()
);
}
#[test]
fn a_child_that_finishes_comes_back_with_both_streams() {
let outcome = run_bounded(
Command::new("sh")
.arg("-c")
.arg("printf wool; printf bleat >&2"),
Duration::from_secs(30),
)
.expect("sh is on every host this crate compiles for");
let Bounded::Exited(output) = outcome else {
panic!("a printf finishes well inside 30s: {outcome:?}");
};
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "wool");
assert_eq!(String::from_utf8_lossy(&output.stderr), "bleat");
}
#[test]
fn a_child_whose_output_outlives_it_is_not_reported_as_killed() {
let started = Instant::now();
let outcome = run_bounded(
Command::new("sh").arg("-c").arg("sleep 5 & exit 0"),
Duration::from_millis(200),
)
.expect("sh is on every host this crate compiles for");
assert!(
matches!(outcome, Bounded::OutputHeldOpen),
"the shell exits at once and the backgrounded sleep holds both \
pipes: {outcome:?}"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"the budget did not bound the reads, in {:?}",
started.elapsed()
);
}
#[test]
fn a_child_louder_than_one_pipeful_is_not_deadlocked_against_its_own_budget() {
let outcome = run_bounded(
Command::new("sh")
.arg("-c")
.arg("head -c 200000 /dev/zero; head -c 200000 /dev/zero >&2"),
Duration::from_secs(30),
)
.expect("sh is on every host this crate compiles for");
let Bounded::Exited(output) = outcome else {
panic!("the writes finish in milliseconds against a draining reader: {outcome:?}");
};
assert_eq!(output.stdout.len(), 200_000);
assert_eq!(output.stderr.len(), 200_000);
}
}