Skip to main content

embacle/
process.rs

1// ABOUTME: Subprocess spawning with timeout and output-size safety limits
2// ABOUTME: Wraps tokio::process::Command with structured output and error handling
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use 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
16/// Default maximum output size (10 MiB)
17const DEFAULT_MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
18
19/// Structured output from a CLI command execution
20#[derive(Debug, Clone)]
21pub struct CliOutput {
22    /// Captured standard output bytes
23    pub stdout: Vec<u8>,
24    /// Captured standard error bytes
25    pub stderr: Vec<u8>,
26    /// Process exit code (-1 if the process was killed)
27    pub exit_code: i32,
28    /// Wall-clock duration of the command
29    pub duration: Duration,
30}
31
32/// Read up to `limit` bytes from a `ChildStdout`, returning collected bytes
33async 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
54/// Read up to `limit` bytes from a `ChildStderr`, returning collected bytes
55pub(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
75/// Run a CLI command with timeout and output-size limits
76///
77/// The command is spawned as a child process. If it does not exit within
78/// `timeout`, it is killed and an error is returned. Output is capped at
79/// `max_output_bytes` to prevent unbounded memory consumption.
80///
81/// # Errors
82///
83/// Returns `RunnerError` if:
84/// - The process cannot be spawned
85/// - The process exceeds the timeout (killed and reported)
86///
87/// Note: a non-zero exit code is returned inside `CliOutput::exit_code`
88/// as data, not as an error. Callers decide how to handle it.
89pub 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}