use std::io::{self, IsTerminal, Read, Write};
use std::os::unix::process::CommandExt;
use std::process::{Command, ExitStatus, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const KILL_GRACE: Duration = Duration::from_secs(1);
const WATCHDOG_POLL_INTERVAL: Duration = Duration::from_millis(50);
pub const STREAM_DISPLAY_BYTES_PER_SECOND: usize = 64 * 1024;
pub const COMMAND_MAX_STREAM_BYTES: usize = 256 * 1024;
const CHUNK_SIZE: usize = 4 * 1024;
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_YELLOW: &str = "\x1b[33m";
const ANSI_RESET: &str = "\x1b[0m";
#[derive(Debug)]
pub struct ChildOutput {
pub stdout: String,
pub stderr: String,
pub status: ExitStatus,
pub duration: Duration,
pub truncated: bool,
pub timed_out: bool,
}
#[expect(
unsafe_code,
reason = "CommandExt::pre_exec is an unsafe API; the closure only calls setpgid(0, 0) in the child after fork"
)]
pub fn run_streamed(cmd: &mut Command, timeout: Option<Duration>) -> io::Result<ChildOutput> {
let started = Instant::now();
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
unsafe {
cmd.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut child = cmd.spawn()?;
let pid = child.id();
let color = io::stderr().is_terminal();
eprintln!("{}", start_line(pid));
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_thread = stdout.map(|reader| {
let limiter = DisplayRateLimiter::new_per_second();
thread::spawn(move || pump(reader, StreamKind::Stdout, color, limiter))
});
let stderr_thread = stderr.map(|reader| {
let limiter = DisplayRateLimiter::new_per_second();
thread::spawn(move || pump(reader, StreamKind::Stderr, color, limiter))
});
let (status, timed_out) = wait_with_timeout(&mut child, pid, timeout)?;
let (stdout_bytes, stdout_truncated) = join_pump(stdout_thread)?;
let (stderr_bytes, stderr_truncated) = join_pump(stderr_thread)?;
let duration = started.elapsed();
eprintln!("{}", finish_line(&status, duration));
Ok(ChildOutput {
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
status,
duration,
truncated: stdout_truncated || stderr_truncated,
timed_out,
})
}
fn wait_with_timeout(
child: &mut std::process::Child,
pid: u32,
timeout: Option<Duration>,
) -> io::Result<(ExitStatus, bool)> {
let Some(timeout) = timeout else {
return Ok((child.wait()?, false));
};
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok((status, false));
}
if Instant::now() >= deadline {
kill_group(pid, libc::SIGTERM);
let force_at = Instant::now() + KILL_GRACE;
loop {
if let Some(status) = child.try_wait()? {
return Ok((status, true));
}
if Instant::now() >= force_at {
kill_group(pid, libc::SIGKILL);
let status = child.wait()?;
return Ok((status, true));
}
thread::sleep(WATCHDOG_POLL_INTERVAL);
}
}
thread::sleep(WATCHDOG_POLL_INTERVAL);
}
}
#[expect(
unsafe_code,
reason = "libc::killpg only reads its arguments and reports failure via the return value"
)]
fn kill_group(pid: u32, signal: i32) {
unsafe {
libc::killpg(pid as libc::pid_t, signal);
}
}
pub fn start_line(pid: u32) -> String {
format!("[child] started (pid {pid})")
}
pub fn finish_line(status: &ExitStatus, duration: Duration) -> String {
let termination = match status.code() {
Some(code) => format!("exit {code}"),
None => "signal".to_string(),
};
format!(
"[child] finished ({termination}, {:.1}s)",
duration.as_secs_f64()
)
}
type Pumped = (Vec<u8>, bool);
fn join_pump(handle: Option<thread::JoinHandle<io::Result<Pumped>>>) -> io::Result<Pumped> {
match handle {
Some(h) => h
.join()
.map_err(|_| io::Error::other("child output reader thread panicked"))?,
None => Ok((Vec::new(), false)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StreamKind {
Stdout,
Stderr,
}
impl StreamKind {
fn ansi(self) -> &'static str {
match self {
Self::Stdout => ANSI_DIM,
Self::Stderr => ANSI_YELLOW,
}
}
}
fn pump<R: Read>(
mut reader: R,
kind: StreamKind,
color: bool,
mut limiter: DisplayRateLimiter,
) -> io::Result<Pumped> {
let mut accumulated = Vec::new();
let mut truncated = false;
let mut chunk = [0u8; CHUNK_SIZE];
loop {
let n = reader.read(&mut chunk)?;
if n == 0 {
break;
}
let remaining = COMMAND_MAX_STREAM_BYTES.saturating_sub(accumulated.len());
if remaining > 0 {
let take = n.min(remaining);
accumulated.extend_from_slice(&chunk[..take]);
if take < n {
truncated = true;
}
} else {
truncated = true;
}
if color {
let allowed = limiter.allow(&chunk[..n]);
if allowed > 0 {
let mut sink = io::stderr().lock();
write_display(&mut sink, kind, color, &chunk[..allowed])?;
}
}
}
Ok((accumulated, truncated))
}
fn write_display<W: Write>(
sink: &mut W,
kind: StreamKind,
color: bool,
bytes: &[u8],
) -> io::Result<()> {
if color {
sink.write_all(kind.ansi().as_bytes())?;
sink.write_all(bytes)?;
sink.write_all(ANSI_RESET.as_bytes())
} else {
sink.write_all(bytes)
}
}
#[derive(Debug, Clone)]
pub struct DisplayRateLimiter {
budget: usize,
window: Duration,
window_start: Instant,
used: usize,
}
impl DisplayRateLimiter {
pub fn new(budget: usize, window: Duration) -> Self {
Self {
budget,
window,
window_start: Instant::now(),
used: 0,
}
}
pub fn new_per_second() -> Self {
Self::new(STREAM_DISPLAY_BYTES_PER_SECOND, Duration::from_secs(1))
}
pub fn allow(&mut self, bytes: &[u8]) -> usize {
let now = Instant::now();
if now.duration_since(self.window_start) >= self.window {
self.window_start = now;
self.used = 0;
}
let allowed = bytes.len().min(self.budget.saturating_sub(self.used));
self.used += allowed;
allowed
}
pub fn write<W: Write>(&mut self, sink: &mut W, bytes: &[u8]) -> io::Result<()> {
let allowed = self.allow(bytes);
if allowed > 0 {
sink.write_all(&bytes[..allowed])?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_bytes(limiter: &mut DisplayRateLimiter, sink: &mut Vec<u8>, bytes: &[u8]) {
limiter.write(sink, bytes).expect("write");
}
#[test]
fn limiter_caps_display_at_window_budget() {
let mut limiter = DisplayRateLimiter::new(4, Duration::from_secs(1));
let mut sink = Vec::new();
let payload = b"abcdefghij";
write_bytes(&mut limiter, &mut sink, payload);
assert_eq!(sink.len(), 4, "first window caps at budget");
write_bytes(&mut limiter, &mut sink, payload);
assert_eq!(sink.len(), 4, "same window still exhausted");
}
#[test]
fn limiter_resets_after_window_elapses() {
let mut limiter = DisplayRateLimiter::new(4, Duration::from_millis(1));
let mut sink = Vec::new();
write_bytes(&mut limiter, &mut sink, b"aaaa");
std::thread::sleep(Duration::from_millis(5));
write_bytes(&mut limiter, &mut sink, b"bbbb");
assert_eq!(sink.len(), 8, "a new window restores the budget");
}
#[test]
fn limiter_allows_up_to_budget_across_chunks() {
let mut limiter = DisplayRateLimiter::new(5, Duration::from_secs(1));
let mut sink = Vec::new();
write_bytes(&mut limiter, &mut sink, b"ab");
write_bytes(&mut limiter, &mut sink, b"cde");
write_bytes(&mut limiter, &mut sink, b"fgh");
assert_eq!(sink, b"abcde", "partial chunk fills the remaining budget");
}
#[test]
fn pump_accumulates_full_output_under_small_budget() {
let reader = io::Cursor::new(b"x".repeat(10 * 1024));
let limiter = DisplayRateLimiter::new(1, Duration::from_secs(1));
let (bytes, truncated) = pump(reader, StreamKind::Stdout, false, limiter).expect("pump");
assert_eq!(bytes.len(), 10 * 1024, "accumulation is never rate-capped");
assert!(!truncated, "10 KiB is below the stream cap");
}
#[test]
fn pump_truncates_accumulation_at_stream_cap() {
let payload = vec![b'x'; COMMAND_MAX_STREAM_BYTES + 1];
let reader = io::Cursor::new(payload);
let limiter = DisplayRateLimiter::new(COMMAND_MAX_STREAM_BYTES, Duration::from_secs(1));
let (bytes, truncated) = pump(reader, StreamKind::Stdout, false, limiter).expect("pump");
assert_eq!(
bytes.len(),
COMMAND_MAX_STREAM_BYTES,
"retained output hits the cap"
);
assert!(truncated);
}
#[test]
fn run_streamed_truncates_large_output_at_stream_cap() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("yes x | head -c 300000");
let out = run_streamed(&mut cmd, None).expect("run");
assert!(out.status.success());
assert_eq!(
out.stdout.len(),
COMMAND_MAX_STREAM_BYTES,
"retained stdout hits the stream cap"
);
assert!(out.truncated);
}
#[test]
fn run_streamed_captures_full_output() {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("printf 'out-line1\\nout-line2\\n'; printf 'err-line\\n' >&2");
let out = run_streamed(&mut cmd, None).expect("run");
assert!(out.status.success());
assert_eq!(out.stdout, "out-line1\nout-line2\n");
assert_eq!(out.stderr, "err-line\n");
}
#[test]
fn run_streamed_non_zero_exit_is_not_an_error() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 3");
let out = run_streamed(&mut cmd, None).expect("run");
assert_eq!(out.status.code(), Some(3));
}
#[test]
fn run_streamed_does_not_deadlock_when_both_streams_overflow() {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("i=0; while [ $i -lt 100000 ]; do echo out; echo err >&2; i=$((i+1)); done");
let out = run_streamed(&mut cmd, None).expect("run");
assert!(out.status.success());
assert!(out.truncated);
}
#[test]
fn run_streamed_kills_child_on_timeout() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 30");
let started = Instant::now();
let out = run_streamed(&mut cmd, Some(Duration::from_millis(200))).expect("run");
assert!(out.timed_out, "watchdog killed the child");
assert!(started.elapsed() < Duration::from_secs(10));
}
#[test]
fn run_streamed_timeout_disabled_when_none() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 0");
let out = run_streamed(&mut cmd, None).expect("run");
assert!(!out.timed_out);
assert!(out.status.success());
}
#[test]
fn run_streamed_timeout_kills_process_group_descendants() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sh -c 'sleep 30' & wait");
let started = Instant::now();
let out = run_streamed(&mut cmd, Some(Duration::from_millis(200))).expect("run");
assert!(out.timed_out);
assert!(started.elapsed() < Duration::from_secs(10));
}
#[test]
fn start_line_contains_pid() {
assert_eq!(start_line(12345), "[child] started (pid 12345)");
}
#[test]
fn finish_line_reports_exit_code_and_duration() {
let status = std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.status()
.expect("run");
assert_eq!(
finish_line(&status, Duration::from_millis(1250)),
"[child] finished (exit 0, 1.2s)"
);
}
#[test]
fn finish_line_reports_signal_without_exit_code() {
let status = std::process::Command::new("sh")
.arg("-c")
.arg("kill -9 $$")
.status()
.expect("run");
assert!(status.code().is_none());
let line = finish_line(&status, Duration::ZERO);
assert!(
line.starts_with("[child] finished (signal, 0.0s)"),
"{line}"
);
}
#[test]
fn write_display_wraps_in_colour_when_enabled() {
let mut sink = Vec::new();
write_display(&mut sink, StreamKind::Stdout, true, b"abc").expect("write");
assert_eq!(sink, b"\x1b[2mabc\x1b[0m");
let mut sink = Vec::new();
write_display(&mut sink, StreamKind::Stderr, true, b"abc").expect("write");
assert_eq!(sink, b"\x1b[33mabc\x1b[0m");
}
#[test]
fn write_display_is_raw_when_colour_disabled() {
let mut sink = Vec::new();
write_display(&mut sink, StreamKind::Stdout, false, b"abc").expect("write");
write_display(&mut sink, StreamKind::Stderr, false, b"def").expect("write");
assert_eq!(sink, b"abcdef", "no ANSI codes outside a terminal");
}
#[test]
fn limiter_allow_returns_displayable_count() {
let mut limiter = DisplayRateLimiter::new(5, Duration::from_secs(1));
assert_eq!(limiter.allow(b"ab"), 2);
assert_eq!(limiter.allow(b"cdefgh"), 3, "fills the remaining budget");
assert_eq!(limiter.allow(b"ij"), 0, "budget exhausted");
}
}