use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result};
use crate::log::StageLogger;
use crate::retry::Retriable;
#[cfg(windows)]
use super::process_tree::windows_job;
use super::process_tree::{
ChildTree, TreeRegistration, kill_child_tree, register_child_tree, set_own_process_group,
};
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
const POST_EXIT_DRAIN_GRACE: Duration = Duration::from_secs(3);
pub fn run_checked(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
run_inner(cmd, None, log, label, None)
}
pub fn run_checked_with_stdin(
cmd: &mut Command,
stdin: &[u8],
log: &StageLogger,
label: &str,
) -> Result<Output> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_inner(cmd, Some(stdin), log, label, None)
}
pub fn run_checked_with_stdin_timeout(
cmd: &mut Command,
stdin: &[u8],
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_inner(cmd, Some(stdin), log, label, Some(timeout))
}
pub fn run_checked_timeout(
cmd: &mut Command,
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
run_inner(cmd, None, log, label, Some(timeout))
}
fn wait_or_kill(
child: &Mutex<Child>,
readers_done: &AtomicUsize,
reader_count: usize,
timeout: Duration,
tree: ChildTree,
) -> std::io::Result<Option<ExitStatus>> {
let deadline = Instant::now() + timeout;
let mut exited: Option<ExitStatus> = None;
let mut drain_deadline: Option<Instant> = None;
loop {
if exited.is_none() {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
if let Some(status) = guard.try_wait()? {
exited = Some(status);
drain_deadline = Some(Instant::now() + POST_EXIT_DRAIN_GRACE);
} else if Instant::now() >= deadline {
kill_child_tree(&mut guard, tree);
return Ok(None);
}
}
if let Some(status) = exited {
if readers_done.load(Ordering::Acquire) >= reader_count {
return Ok(Some(status));
}
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
kill_child_tree(&mut guard, tree);
return Ok(Some(status));
}
}
std::thread::sleep(WAIT_POLL_INTERVAL);
}
}
fn capture_inner(
cmd: &mut Command,
stdin: Option<&[u8]>,
log: &StageLogger,
label: &str,
timeout: Option<Duration>,
) -> Result<Output> {
let verbose = log.is_verbose();
if timeout.is_some() {
set_own_process_group(cmd);
}
if stdin.is_none() {
cmd.stdin(Stdio::null());
}
let mut child = cmd
.spawn()
.with_context(|| format!("failed to spawn {label}"))?;
#[cfg(windows)]
let job = if timeout.is_some() {
windows_job::enclose_child(&child)
} else {
None
};
let tree = ChildTree {
pid: child.id() as i32,
#[cfg(windows)]
job,
};
let _registration = timeout.is_some().then(|| {
register_child_tree(tree);
TreeRegistration(tree)
});
let child_stdin = match stdin {
Some(_) => Some(
child
.stdin
.take()
.with_context(|| format!("{label}: child has no stdin pipe"))?,
),
None => None,
};
let child_stdout = child
.stdout
.take()
.with_context(|| format!("{label}: child has no stdout pipe"))?;
let child_stderr = child
.stderr
.take()
.with_context(|| format!("{label}: child has no stderr pipe"))?;
let child = Mutex::new(child);
let mut out_buf: Vec<u8> = Vec::new();
let mut err_buf: Vec<u8> = Vec::new();
let mut stdin_err: Option<std::io::Error> = None;
let mut timed_out = false;
let mut watchdog_err: Option<std::io::Error> = None;
let child_ref = &child;
let readers_done = AtomicUsize::new(0);
let readers_done_ref = &readers_done;
std::thread::scope(|s| {
let stdin_handle = child_stdin.map(|mut pipe| {
let bytes = stdin.expect("child_stdin is Some only when stdin is Some");
s.spawn(move || -> std::io::Result<()> {
pipe.write_all(bytes)?;
Ok(())
})
});
let out_handle = s.spawn(move || {
let buf = tee_stream(child_stdout, log, false, verbose);
readers_done_ref.fetch_add(1, Ordering::Release);
buf
});
let err_handle = s.spawn(move || {
let buf = tee_stream(child_stderr, log, true, verbose);
readers_done_ref.fetch_add(1, Ordering::Release);
buf
});
let watchdog =
timeout.map(|t| s.spawn(move || wait_or_kill(child_ref, readers_done_ref, 2, t, tree)));
let heartbeat_stop = crate::progress::heartbeat_period(log).map(|interval| {
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
let start = Instant::now();
let action = format!("running {label}");
s.spawn(move || {
crate::progress::run_ticker(&stop_rx, interval, || {
log.heartbeat(&crate::progress::heartbeat_message(&action, start));
});
});
stop_tx
});
out_buf = join_capture(out_handle, log, "stdout");
err_buf = join_capture(err_handle, log, "stderr");
if let Some(h) = watchdog {
match h.join() {
Ok(Ok(Some(_status))) => {} Ok(Ok(None)) => timed_out = true,
Ok(Err(e)) => watchdog_err = Some(e),
Err(_) => log.warn(&format!("{label}: timeout watchdog thread panicked")),
}
}
if let Some(h) = stdin_handle {
match h.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => stdin_err = Some(e),
Err(_) => log.warn(&format!("{label}: stdin writer thread panicked")),
}
}
drop(heartbeat_stop);
});
let reaped = {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
guard.wait()
};
if let Some(e) = watchdog_err {
return Err(anyhow::Error::new(e).context(format!("{label}: failed to wait for child")));
}
if timed_out {
let secs = timeout.map(|t| t.as_secs_f64()).unwrap_or_default();
return Err(anyhow::Error::new(Retriable::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("{label}: child did not exit within {secs:.0}s; killed"),
))));
}
if let Some(e) = stdin_err
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(anyhow::Error::new(e).context(format!("{label}: failed to write stdin")));
}
let status = reaped.with_context(|| format!("{label}: failed to wait for child"))?;
Ok(Output {
status,
stdout: out_buf,
stderr: err_buf,
})
}
fn run_inner(
cmd: &mut Command,
stdin: Option<&[u8]>,
log: &StageLogger,
label: &str,
timeout: Option<Duration>,
) -> Result<Output> {
let output = capture_inner(cmd, stdin, log, label, timeout)?;
if log.is_verbose() {
log.check_output_streamed(output, label)
} else {
log.check_output(output, label)
}
}
pub fn run_capture_timeout(
cmd: &mut Command,
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
capture_impl(cmd, log, label, Some(timeout))
}
pub fn run_capture(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
capture_impl(cmd, log, label, None)
}
fn capture_impl(
cmd: &mut Command,
log: &StageLogger,
label: &str,
timeout: Option<Duration>,
) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
capture_inner(cmd, None, log, label, timeout)
}
fn join_capture(
handle: std::thread::ScopedJoinHandle<'_, Vec<u8>>,
log: &StageLogger,
stream: &str,
) -> Vec<u8> {
match handle.join() {
Ok(buf) => buf,
Err(_) => {
log.warn(&format!(
"internal: {stream} capture thread panicked; output for this step is lost"
));
Vec::new()
}
}
}
fn tee_stream<R: std::io::Read>(
reader: R,
log: &StageLogger,
is_stderr: bool,
tee: bool,
) -> Vec<u8> {
let mut buf = BufReader::new(reader);
let mut capture: Vec<u8> = Vec::new();
let mut line: Vec<u8> = Vec::new();
loop {
line.clear();
match buf.read_until(b'\n', &mut line) {
Ok(0) => break,
Ok(_) => {
capture.extend_from_slice(&line);
if tee {
let text = String::from_utf8_lossy(&line);
let stripped = text.trim_end_matches(['\n', '\r']);
if is_stderr {
log.stream_child_stderr(stripped);
} else {
log.stream_child_stdout(stripped);
}
}
}
Err(_) => break,
}
}
capture
}