1use std::process::Stdio;
8use std::time::{Duration, Instant};
9
10use crate::types::RunnerError;
11use tokio::io::AsyncReadExt;
12use tokio::process::{ChildStderr, ChildStdout, Command};
13use tokio::time::timeout as tokio_timeout;
14use tracing::{debug, warn};
15
16const DEFAULT_MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
18
19#[derive(Debug, Clone)]
21pub struct CliOutput {
22 pub stdout: Vec<u8>,
24 pub stderr: Vec<u8>,
26 pub exit_code: i32,
28 pub duration: Duration,
30}
31
32async fn read_stdout_capped(stream: Option<ChildStdout>, limit: usize) -> Vec<u8> {
34 let mut buf = Vec::new();
35 if let Some(mut reader) = stream {
36 let mut tmp = [0u8; 8192];
37 loop {
38 match reader.read(&mut tmp).await {
39 Ok(0) | Err(_) => break,
40 Ok(n) => {
41 let remaining = limit.saturating_sub(buf.len());
42 buf.extend_from_slice(&tmp[..n.min(remaining)]);
43 if buf.len() >= limit {
44 warn!(limit_bytes = limit, "stdout output truncated at cap");
45 break;
46 }
47 }
48 }
49 }
50 }
51 buf
52}
53
54pub(crate) async fn read_stderr_capped(stream: Option<ChildStderr>, limit: usize) -> Vec<u8> {
56 let mut buf = Vec::new();
57 if let Some(mut reader) = stream {
58 let mut tmp = [0u8; 8192];
59 loop {
60 match reader.read(&mut tmp).await {
61 Ok(0) | Err(_) => break,
62 Ok(n) => {
63 let remaining = limit.saturating_sub(buf.len());
64 buf.extend_from_slice(&tmp[..n.min(remaining)]);
65 if buf.len() >= limit {
66 break;
67 }
68 }
69 }
70 }
71 }
72 buf
73}
74
75pub async fn run_cli_command(
90 cmd: &mut Command,
91 timeout: Duration,
92 max_output_bytes: usize,
93) -> Result<CliOutput, RunnerError> {
94 let effective_max = if max_output_bytes == 0 {
95 DEFAULT_MAX_OUTPUT_BYTES
96 } else {
97 max_output_bytes
98 };
99
100 cmd.stdout(Stdio::piped());
101 cmd.stderr(Stdio::piped());
102
103 let start = Instant::now();
104
105 let mut child = cmd
106 .spawn()
107 .map_err(|e| RunnerError::internal(format!("Failed to spawn CLI process: {e}")))?;
108
109 let stdout_handle = child.stdout.take();
110 let stderr_handle = child.stderr.take();
111
112 let stdout_task = tokio::spawn(read_stdout_capped(stdout_handle, effective_max));
113 let stderr_task = tokio::spawn(read_stderr_capped(stderr_handle, effective_max));
114
115 let wait_result = tokio_timeout(timeout, child.wait()).await;
116
117 let duration = start.elapsed();
118
119 match wait_result {
120 Ok(Ok(status)) => {
121 let exit_code = status.code().unwrap_or(-1);
122 let stdout = stdout_task.await.unwrap_or_default();
123 let stderr = stderr_task.await.unwrap_or_default();
124
125 debug!(exit_code, ?duration, "CLI command completed");
126
127 Ok(CliOutput {
128 stdout,
129 stderr,
130 exit_code,
131 duration,
132 })
133 }
134 Ok(Err(e)) => Err(RunnerError::internal(format!(
135 "Failed to wait for CLI process: {e}"
136 ))),
137 Err(_) => {
138 warn!(?timeout, "CLI command timed out, killing process");
139 let _ = child.kill().await;
140 Err(RunnerError::timeout(format!(
141 "CLI command timed out after {timeout:?}"
142 )))
143 }
144 }
145}