use std::io::{self, BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct CommandResult {
pub success: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stream {
Stdout,
Stderr,
}
pub type LineSink = Arc<dyn Fn(Stream, &str) + Send + Sync>;
#[derive(Debug, Clone)]
pub struct StreamedResult {
pub success: bool,
pub timed_out: bool,
pub duration: Duration,
pub output: String,
}
const MAX_LINE: usize = 8 * 1024;
const DRAIN_GRACE: Duration = Duration::from_secs(1);
const MAX_POLL: Duration = Duration::from_millis(20);
#[cfg(windows)]
fn shell(cmd: &str) -> Command {
let mut c = Command::new("cmd");
c.arg("/C").arg(cmd);
c
}
#[cfg(not(windows))]
fn shell(cmd: &str) -> Command {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd);
c
}
pub fn run(cmd: &str, dir: &Path) -> io::Result<CommandResult> {
let status = shell(cmd).current_dir(dir).status()?;
Ok(CommandResult {
success: status.success(),
})
}
pub fn run_streamed(
cmd: &str,
dir: &Path,
env: &[(String, String)],
timeout: Option<Duration>,
sink: LineSink,
) -> io::Result<StreamedResult> {
let start = Instant::now();
let mut command = shell(cmd);
command
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in env {
command.env(k, v);
}
let mut child = command.spawn()?;
let tap = Arc::new(Tap::new(sink));
let (done_tx, done_rx) = channel::<()>();
let mut readers = 0usize;
if let Some(out) = child.stdout.take() {
spawn_pump(out, Stream::Stdout, Arc::clone(&tap), done_tx.clone());
readers += 1;
}
if let Some(err) = child.stderr.take() {
spawn_pump(err, Stream::Stderr, Arc::clone(&tap), done_tx.clone());
readers += 1;
}
drop(done_tx);
let deadline = timeout.map(|t| start + t);
let mut timed_out = false;
let mut poll = Duration::from_millis(1);
let status = loop {
match child.try_wait()? {
Some(status) => break Some(status),
None => {
if deadline.is_some_and(|d| Instant::now() >= d) {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break None;
}
std::thread::sleep(poll);
poll = (poll * 2).min(MAX_POLL);
}
}
};
drain(&done_rx, readers);
let output = tap.close();
Ok(StreamedResult {
success: !timed_out && status.map(|s| s.success()).unwrap_or(false),
timed_out,
duration: start.elapsed(),
output,
})
}
fn drain(done_rx: &Receiver<()>, readers: usize) {
for _ in 0..readers {
if done_rx.recv_timeout(DRAIN_GRACE).is_err() {
break;
}
}
}
struct Tap {
sink: LineSink,
buffer: Mutex<Option<String>>,
}
impl Tap {
fn new(sink: LineSink) -> Self {
Tap {
sink,
buffer: Mutex::new(Some(String::new())),
}
}
fn push(&self, stream: Stream, line: &str) {
let mut guard = self.buffer.lock().unwrap_or_else(|e| e.into_inner());
let Some(buffer) = guard.as_mut() else {
return;
};
buffer.push_str(line);
buffer.push('\n');
(self.sink)(stream, line);
}
fn close(&self) -> String {
let mut guard = self.buffer.lock().unwrap_or_else(|e| e.into_inner());
guard.take().unwrap_or_default()
}
}
fn spawn_pump<R: Read + Send + 'static>(
reader: R,
stream: Stream,
tap: Arc<Tap>,
done: Sender<()>,
) {
std::thread::spawn(move || {
let mut reader = BufReader::new(reader);
let mut raw: Vec<u8> = Vec::new();
loop {
match read_line_bounded(&mut reader, &mut raw) {
Ok(0) | Err(_) => break,
Ok(_) => {
let line = String::from_utf8_lossy(trim_eol(&raw));
tap.push(stream, &line);
}
}
}
let _ = done.send(());
});
}
fn trim_eol(bytes: &[u8]) -> &[u8] {
let mut end = bytes.len();
if end > 0 && bytes[end - 1] == b'\n' {
end -= 1;
}
if end > 0 && bytes[end - 1] == b'\r' {
end -= 1;
}
&bytes[..end]
}
fn read_line_bounded(reader: &mut impl BufRead, out: &mut Vec<u8>) -> io::Result<usize> {
out.clear();
loop {
let available = match reader.fill_buf() {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if available.is_empty() {
return Ok(out.len()); }
match available.iter().position(|&b| b == b'\n') {
Some(i) => {
out.extend_from_slice(&available[..=i]);
reader.consume(i + 1);
return Ok(out.len());
}
None => {
let n = available.len();
out.extend_from_slice(available);
reader.consume(n);
if out.len() >= MAX_LINE {
return Ok(out.len());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
type Collected = Arc<Mutex<Vec<(Stream, String)>>>;
fn collector() -> (LineSink, Collected) {
let lines = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&lines);
let sink: LineSink = Arc::new(move |stream, line: &str| {
seen.lock().unwrap().push((stream, line.to_string()));
});
(sink, lines)
}
fn long_command() -> &'static str {
if cfg!(windows) {
"ping -n 60 127.0.0.1 >nul"
} else {
"sleep 60"
}
}
#[test]
fn streams_stdout_and_stderr_separately() {
let (sink, lines) = collector();
let res =
run_streamed("echo one && echo two 1>&2", Path::new("."), &[], None, sink).unwrap();
assert!(res.success, "{res:?}");
assert!(!res.timed_out);
let lines = lines.lock().unwrap();
let out: Vec<&(Stream, String)> =
lines.iter().filter(|(s, _)| *s == Stream::Stdout).collect();
let err: Vec<&(Stream, String)> =
lines.iter().filter(|(s, _)| *s == Stream::Stderr).collect();
assert!(
out.iter().any(|(_, l)| l.trim() == "one"),
"stdout not streamed: {lines:?}"
);
assert!(
err.iter().any(|(_, l)| l.trim() == "two"),
"stderr not streamed: {lines:?}"
);
assert!(res.output.contains("one") && res.output.contains("two"));
}
#[test]
fn lines_arrive_before_the_command_exits() {
let seen = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&seen);
let sink: LineSink = Arc::new(move |_, _: &str| {
counter.fetch_add(1, Ordering::SeqCst);
});
let slow = if cfg!(windows) {
"echo first && ping -n 3 127.0.0.1 >nul && echo second"
} else {
"echo first && sleep 2 && echo second"
};
let watcher = Arc::clone(&seen);
let probe = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(800));
watcher.load(Ordering::SeqCst)
});
let res = run_streamed(slow, Path::new("."), &[], None, sink).unwrap();
assert!(res.success, "{res:?}");
assert_eq!(
probe.join().unwrap(),
1,
"the first line should have been delivered while the command was still running"
);
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
#[test]
fn timeout_kills_a_hanging_command() {
let (sink, _) = collector();
let start = Instant::now();
let res = run_streamed(
long_command(),
Path::new("."),
&[],
Some(Duration::from_millis(300)),
sink,
)
.unwrap();
assert!(res.timed_out, "should report a timeout: {res:?}");
assert!(!res.success, "a killed command has not succeeded");
assert!(
start.elapsed() < Duration::from_secs(20),
"the command should have been killed, not waited out: {:?}",
start.elapsed()
);
}
#[test]
fn a_command_finishing_inside_its_limit_is_untouched() {
let (sink, _) = collector();
let res = run_streamed(
"echo quick",
Path::new("."),
&[],
Some(Duration::from_secs(30)),
sink,
)
.unwrap();
assert!(res.success && !res.timed_out, "{res:?}");
assert!(res.output.contains("quick"));
}
#[test]
fn injected_environment_reaches_the_command() {
let (sink, _) = collector();
let echo = if cfg!(windows) {
"echo token=%TOKEN%"
} else {
"echo token=$TOKEN"
};
let res = run_streamed(
echo,
Path::new("."),
&[("TOKEN".to_string(), "abc123".to_string())],
None,
sink,
)
.unwrap();
assert!(res.output.contains("token=abc123"), "{}", res.output);
}
#[test]
fn a_failing_command_reports_failure_without_a_timeout() {
let (sink, _) = collector();
let res = run_streamed("exit 3", Path::new("."), &[], None, sink).unwrap();
assert!(!res.success);
assert!(!res.timed_out, "a plain failure is not a timeout");
}
#[test]
fn a_line_that_never_ends_is_split_rather_than_buffered_forever() {
let mut reader = BufReader::new(io::Cursor::new(vec![b'x'; MAX_LINE * 2 + 5]));
let mut raw = Vec::new();
assert_eq!(read_line_bounded(&mut reader, &mut raw).unwrap(), MAX_LINE);
assert_eq!(read_line_bounded(&mut reader, &mut raw).unwrap(), MAX_LINE);
assert_eq!(read_line_bounded(&mut reader, &mut raw).unwrap(), 5);
assert_eq!(read_line_bounded(&mut reader, &mut raw).unwrap(), 0);
}
#[test]
fn crlf_and_lf_line_endings_both_trim() {
assert_eq!(trim_eol(b"hello\n"), b"hello");
assert_eq!(trim_eol(b"hello\r\n"), b"hello");
assert_eq!(trim_eol(b"hello"), b"hello");
assert_eq!(trim_eol(b"\n"), b"");
}
}