use std::process::Stdio;
use std::time::{Duration, Instant};
use crate::types::RunnerError;
use tokio::io::AsyncReadExt;
use tokio::process::{ChildStderr, ChildStdout, Command};
use tokio::time::timeout as tokio_timeout;
use tracing::{debug, warn};
const DEFAULT_MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct CliOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
pub duration: Duration,
}
async fn read_stdout_capped(stream: Option<ChildStdout>, limit: usize) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut reader) = stream {
let mut tmp = [0u8; 8192];
loop {
match reader.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let remaining = limit.saturating_sub(buf.len());
buf.extend_from_slice(&tmp[..n.min(remaining)]);
if buf.len() >= limit {
warn!(limit_bytes = limit, "stdout output truncated at cap");
break;
}
}
}
}
}
buf
}
pub(crate) async fn read_stderr_capped(stream: Option<ChildStderr>, limit: usize) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut reader) = stream {
let mut tmp = [0u8; 8192];
loop {
match reader.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let remaining = limit.saturating_sub(buf.len());
buf.extend_from_slice(&tmp[..n.min(remaining)]);
if buf.len() >= limit {
break;
}
}
}
}
}
buf
}
pub async fn run_cli_command(
cmd: &mut Command,
timeout: Duration,
max_output_bytes: usize,
) -> Result<CliOutput, RunnerError> {
let effective_max = if max_output_bytes == 0 {
DEFAULT_MAX_OUTPUT_BYTES
} else {
max_output_bytes
};
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let start = Instant::now();
let mut child = cmd
.spawn()
.map_err(|e| RunnerError::internal(format!("Failed to spawn CLI process: {e}")))?;
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_task = tokio::spawn(read_stdout_capped(stdout_handle, effective_max));
let stderr_task = tokio::spawn(read_stderr_capped(stderr_handle, effective_max));
let wait_result = tokio_timeout(timeout, child.wait()).await;
let duration = start.elapsed();
match wait_result {
Ok(Ok(status)) => {
let exit_code = status.code().unwrap_or(-1);
let stdout = stdout_task.await.unwrap_or_default();
let stderr = stderr_task.await.unwrap_or_default();
debug!(exit_code, ?duration, "CLI command completed");
Ok(CliOutput {
stdout,
stderr,
exit_code,
duration,
})
}
Ok(Err(e)) => Err(RunnerError::internal(format!(
"Failed to wait for CLI process: {e}"
))),
Err(_) => {
warn!(?timeout, "CLI command timed out, killing process");
let _ = child.kill().await;
Err(RunnerError::timeout(format!(
"CLI command timed out after {timeout:?}"
)))
}
}
}