use std::io::Read;
use std::process::{Command, ExitStatus, Stdio};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::supervise::pid_file;
const POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Default)]
pub struct CappedStream {
pub bytes: Vec<u8>,
pub truncated: bool,
}
impl CappedStream {
fn empty() -> Self {
Self::default()
}
}
pub enum TimedOutcome {
Exited {
status: ExitStatus,
stdout: CappedStream,
stderr: CappedStream,
},
TimedOut,
SpawnErr(std::io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
Timeout,
Cancelled,
}
pub enum ControlledOutcome {
Exited {
status: ExitStatus,
stdout: CappedStream,
stderr: CappedStream,
},
Stopped {
reason: StopReason,
stdout: CappedStream,
stderr: CappedStream,
},
SpawnErr(std::io::Error),
}
pub fn run_with_timeout(cmd: Command, timeout: Duration, cap: usize) -> TimedOutcome {
match run_with_control(cmd, Some(timeout), &|| false, cap) {
ControlledOutcome::Exited {
status,
stdout,
stderr,
} => TimedOutcome::Exited {
status,
stdout,
stderr,
},
ControlledOutcome::Stopped { .. } => TimedOutcome::TimedOut,
ControlledOutcome::SpawnErr(e) => TimedOutcome::SpawnErr(e),
}
}
pub fn run_with_control(
mut cmd: Command,
timeout: Option<Duration>,
cancel: &dyn Fn() -> bool,
cap: usize,
) -> ControlledOutcome {
use std::os::unix::process::CommandExt;
if cancel() {
return ControlledOutcome::Stopped {
reason: StopReason::Cancelled,
stdout: CappedStream::empty(),
stderr: CappedStream::empty(),
};
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.process_group(0);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return ControlledOutcome::SpawnErr(e),
};
let pid = child.id();
let mut out_reader: Option<JoinHandle<CappedStream>> = child
.stdout
.take()
.map(|s| std::thread::spawn(move || read_capped(s, cap)));
let mut err_reader: Option<JoinHandle<CappedStream>> = child
.stderr
.take()
.map(|s| std::thread::spawn(move || read_capped(s, cap)));
let deadline = timeout.and_then(|t| Instant::now().checked_add(t));
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
let reason = if cancel() {
Some(StopReason::Cancelled)
} else if deadline.is_some_and(|d| Instant::now() >= d) {
Some(StopReason::Timeout)
} else {
None
};
if let Some(reason) = reason {
if let Ok(Some(status)) = child.try_wait() {
let (stdout, stderr) =
drain_readers(pid, out_reader.take(), err_reader.take());
return ControlledOutcome::Exited {
status,
stdout,
stderr,
};
}
kill_group(pid, &mut child);
let _ = child.wait();
let (stdout, stderr) = drain_readers(pid, out_reader.take(), err_reader.take());
return ControlledOutcome::Stopped {
reason,
stdout,
stderr,
};
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => {
kill_group(pid, &mut child);
let _ = child.wait();
let _ = drain_readers(pid, out_reader.take(), err_reader.take());
return ControlledOutcome::SpawnErr(e);
}
}
};
let (stdout, stderr) = drain_readers(pid, out_reader.take(), err_reader.take());
ControlledOutcome::Exited {
status,
stdout,
stderr,
}
}
fn kill_group(pid: u32, child: &mut std::process::Child) {
if let Some(pgid) = pid_file::to_pid_t(pid) {
unsafe { libc::kill(-pgid, libc::SIGKILL) };
} else {
let _ = child.kill();
}
}
fn drain_readers(
pid: u32,
out_reader: Option<JoinHandle<CappedStream>>,
err_reader: Option<JoinHandle<CappedStream>>,
) -> (CappedStream, CappedStream) {
const GRACE: Duration = Duration::from_millis(200);
const HARD: Duration = Duration::from_secs(2);
let reader_done = |r: Option<&JoinHandle<CappedStream>>| r.is_none_or(JoinHandle::is_finished);
let both_finished = || reader_done(out_reader.as_ref()) && reader_done(err_reader.as_ref());
if !poll_until(GRACE, both_finished) {
if let Some(pgid) = pid_file::to_pid_t(pid) {
unsafe { libc::kill(-pgid, libc::SIGKILL) };
}
poll_until(HARD, both_finished);
}
(finish_or_detach(out_reader), finish_or_detach(err_reader))
}
fn poll_until(budget: Duration, done: impl Fn() -> bool) -> bool {
let deadline = Instant::now() + budget;
loop {
if done() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
fn finish_or_detach(reader: Option<JoinHandle<CappedStream>>) -> CappedStream {
match reader {
Some(h) if h.is_finished() => h.join().unwrap_or_else(|_| CappedStream::empty()),
Some(_) | None => CappedStream::empty(),
}
}
fn read_capped(mut r: impl Read, cap: usize) -> CappedStream {
let mut bytes = Vec::new();
let mut truncated = false;
let mut chunk = [0u8; 8192];
loop {
match r.read(&mut chunk) {
Ok(n) if n > 0 => {
if bytes.len() < cap {
let take = (cap - bytes.len()).min(n);
bytes.extend_from_slice(&chunk[..take]);
if take < n {
truncated = true;
}
} else {
truncated = true;
}
}
Ok(_) | Err(_) => break,
}
}
CappedStream { bytes, truncated }
}
#[cfg(test)]
mod tests {
use super::*;
fn sh(script: &str) -> Command {
let mut c = Command::new("/bin/sh");
c.arg("-c").arg(script);
c
}
#[test]
fn captures_stdout_and_exit_status() {
let out = run_with_timeout(sh("printf hello; exit 0"), Duration::from_secs(5), 1 << 20);
match out {
TimedOutcome::Exited {
status,
stdout,
stderr,
} => {
assert!(status.success());
assert_eq!(stdout.bytes, b"hello");
assert!(!stdout.truncated);
assert!(stderr.bytes.is_empty());
}
_ => panic!("expected Exited"),
}
}
#[test]
fn captures_stderr_and_nonzero_status() {
let out = run_with_timeout(
sh("printf oops 1>&2; exit 7"),
Duration::from_secs(5),
1 << 20,
);
match out {
TimedOutcome::Exited { status, stderr, .. } => {
assert_eq!(status.code(), Some(7));
assert_eq!(stderr.bytes, b"oops");
}
_ => panic!("expected Exited"),
}
}
#[test]
fn caps_oversized_output_and_flags_truncation() {
let out = run_with_timeout(
sh("yes AAAAAAAA | head -c 65536"),
Duration::from_secs(10),
1024,
);
match out {
TimedOutcome::Exited { stdout, .. } => {
assert_eq!(stdout.bytes.len(), 1024, "retained exactly the cap");
assert!(stdout.truncated, "overflow must flag truncation");
}
_ => panic!("expected Exited"),
}
}
#[test]
fn kills_group_on_timeout() {
let start = Instant::now();
let out = run_with_timeout(
sh("sleep 30 & sleep 30"),
Duration::from_millis(200),
1 << 20,
);
assert!(matches!(out, TimedOutcome::TimedOut));
assert!(
start.elapsed() < Duration::from_secs(5),
"timeout must fire promptly, not wait for the child"
);
}
#[test]
fn control_cancel_stops_promptly_with_partial_output() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let flag = Arc::new(AtomicBool::new(false));
let trip = Arc::clone(&flag);
let handle = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
trip.store(true, Ordering::SeqCst);
});
let start = Instant::now();
let out = run_with_control(
sh("printf partial; sleep 30"),
None,
&move || flag.load(Ordering::SeqCst),
1 << 20,
);
handle.join().unwrap();
match out {
ControlledOutcome::Stopped {
reason,
stdout,
stderr,
} => {
assert_eq!(reason, StopReason::Cancelled);
assert_eq!(stdout.bytes, b"partial");
assert!(stderr.bytes.is_empty());
}
_ => panic!("expected Stopped(Cancelled)"),
}
assert!(
start.elapsed() < Duration::from_secs(5),
"cancel must fire promptly, not wait for the child"
);
}
#[test]
fn control_deadline_stops_when_never_cancelled() {
let start = Instant::now();
let out = run_with_control(
sh("sleep 30"),
Some(Duration::from_millis(200)),
&|| false,
1 << 20,
);
assert!(matches!(
out,
ControlledOutcome::Stopped {
reason: StopReason::Timeout,
..
}
));
assert!(start.elapsed() < Duration::from_secs(5));
}
#[test]
fn escaped_background_child_does_not_hang_on_deadline() {
let start = Instant::now();
let out = run_with_control(
sh("sleep 30 & exit 0"),
Some(Duration::from_millis(100)),
&|| false,
1 << 20,
);
assert!(matches!(
out,
ControlledOutcome::Exited { .. } | ControlledOutcome::Stopped { .. }
));
assert!(
start.elapsed() < Duration::from_secs(5),
"must not hang on a backgrounded descendant holding the pipes"
);
}
#[test]
fn control_precancelled_never_spawns() {
let start = Instant::now();
let out = run_with_control(sh("sleep 30"), None, &|| true, 1 << 20);
assert!(matches!(
out,
ControlledOutcome::Stopped {
reason: StopReason::Cancelled,
..
}
));
assert!(start.elapsed() < Duration::from_secs(1));
}
#[test]
fn control_exits_normally_when_neither_fires() {
let out = run_with_control(sh("printf ok; exit 0"), None, &|| false, 1 << 20);
match out {
ControlledOutcome::Exited { status, stdout, .. } => {
assert!(status.success());
assert_eq!(stdout.bytes, b"ok");
}
_ => panic!("expected Exited"),
}
}
#[test]
fn missing_binary_is_spawn_err() {
let out = run_with_timeout(
Command::new("/nonexistent/orchestratectl-no-such-binary"),
Duration::from_secs(5),
1 << 20,
);
match out {
TimedOutcome::SpawnErr(e) => {
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
}
_ => panic!("expected SpawnErr"),
}
}
}