use std::io::{self, Read};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use crate::ByteSize;
const MAX_OUTPUT_SIZE: ByteSize = ByteSize::from_bytes(64 * 1024);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub(super) fn output(program: &str, args: &[&str], timeout: Duration) -> io::Result<Vec<u8>> {
let start = Instant::now();
let mut child = Command::new(program)
.args(args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let result = collect_output(&mut child, start, timeout);
if result.is_err() {
drop(child.kill());
drop(child.wait());
}
result
}
fn collect_output(child: &mut Child, start: Instant, timeout: Duration) -> io::Result<Vec<u8>> {
let stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("probe stdout was not piped"))?;
let (sender, receiver) = mpsc::sync_channel(1);
thread::Builder::new().spawn(move || {
let mut bytes = Vec::new();
let result = stdout
.take(MAX_OUTPUT_SIZE.as_bytes().saturating_add(1))
.read_to_end(&mut bytes);
drop(sender.send(result.map(|_| bytes)));
})?;
wait_for_success(child, start, timeout)?;
let bytes = receiver
.recv_timeout(timeout.saturating_sub(start.elapsed()))
.map_err(|error| match error {
mpsc::RecvTimeoutError::Timeout => {
io::Error::new(io::ErrorKind::TimedOut, "probe stdout timed out")
}
mpsc::RecvTimeoutError::Disconnected => io::Error::other("probe stdout reader stopped"),
})??;
if ByteSize::from_bytes(bytes.len() as u64) > MAX_OUTPUT_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"probe stdout exceeded the size limit",
));
}
Ok(bytes)
}
fn wait_for_success(child: &mut Child, start: Instant, timeout: Duration) -> io::Result<()> {
loop {
if let Some(status) = child.try_wait()? {
return if status.success() {
Ok(())
} else {
Err(io::Error::other(format!("probe command failed: {status}")))
};
}
let remaining = timeout.saturating_sub(start.elapsed());
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"probe command timed out",
));
}
thread::sleep(POLL_INTERVAL.min(remaining));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::{MAX_OUTPUT_SIZE, collect_output, output};
use std::io::ErrorKind;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const TEST_TIMEOUT: Duration = Duration::from_secs(1);
const SHORT_TIMEOUT: Duration = Duration::from_millis(50);
const COMPLETION_DEADLINE: Duration = Duration::from_secs(2);
const SLOW_COMMAND_DURATION: Duration = Duration::from_secs(5);
#[test]
fn captures_successful_output_but_not_stderr() {
assert_eq!(
output(
"/bin/sh",
&["-c", "printf '8.6, Default\\n'; printf diagnostic >&2"],
TEST_TIMEOUT,
)
.unwrap(),
b"8.6, Default\n"
);
}
#[test]
fn failed_and_missing_commands_return_errors() {
let error = output(
"/bin/sh",
&["-c", "printf '8.6, Default\\n'; exit 1"],
TEST_TIMEOUT,
)
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Other);
let directory = tempfile::tempdir().unwrap();
let error = output(
directory.path().join("missing-probe").to_str().unwrap(),
&[],
TEST_TIMEOUT,
)
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::NotFound);
}
#[test]
fn terminates_a_probe_that_does_not_finish() {
let command = format!("exec sleep {}", SLOW_COMMAND_DURATION.as_secs());
let start = Instant::now();
let error = output("/bin/sh", &["-c", &command], SHORT_TIMEOUT).unwrap_err();
assert_eq!(error.kind(), ErrorKind::TimedOut);
assert!(start.elapsed() < COMPLETION_DEADLINE);
}
#[test]
fn accepts_empty_output_and_output_up_to_the_limit() {
for size in [
0,
MAX_OUTPUT_SIZE.as_bytes() - 1,
MAX_OUTPUT_SIZE.as_bytes(),
] {
let command = format!("printf '%{size}s' ''");
let bytes = output("/bin/sh", &["-c", &command], TEST_TIMEOUT).unwrap();
assert_eq!(bytes, vec![b' '; usize::try_from(size).unwrap()]);
}
}
#[test]
fn rejects_output_one_byte_over_the_limit() {
let size = MAX_OUTPUT_SIZE.as_bytes() + 1;
let command = format!("printf '%{size}s' ''");
let error = output("/bin/sh", &["-c", &command], TEST_TIMEOUT).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidData);
}
#[test]
fn missing_stdout_returns_an_error_instead_of_panicking() {
let mut child = Command::new("/bin/sh")
.args(["-c", "exit 0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let result = collect_output(&mut child, Instant::now(), TEST_TIMEOUT);
child.wait().unwrap();
let error = result.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Other);
assert_eq!(error.to_string(), "probe stdout was not piped");
}
#[test]
fn times_out_when_a_descendant_keeps_stdout_open() {
let command = format!("sleep {} &", TEST_TIMEOUT.as_secs());
let mut child = Command::new("/bin/sh")
.args(["-c", &command])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
child.wait().unwrap();
let start = Instant::now();
let error = collect_output(&mut child, start, SHORT_TIMEOUT).unwrap_err();
assert_eq!(error.kind(), ErrorKind::TimedOut);
assert_eq!(error.to_string(), "probe stdout timed out");
assert!(start.elapsed() < COMPLETION_DEADLINE);
}
}