Skip to main content

vtcode_core/exec/
async_command.rs

1use hashbrown::HashMap;
2use std::ffi::OsString;
3use std::path::PathBuf;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, anyhow};
9use async_process::{Child, Command as AsyncCommand, ExitStatus, Stdio};
10
11use futures::io::AsyncReadExt;
12use tokio::sync::Mutex;
13use tokio::time::{Sleep, sleep, timeout};
14use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned};
15
16use crate::telemetry::perf;
17use crate::utils::gatekeeper;
18
19const DEFAULT_CAPTURE_LIMIT: usize = 256 * 1024; // 256 KiB
20
21/// Upper bound on how long `run` will wait, after killing the direct child on
22/// the `TimedOut`/`Cancelled` paths, for (a) the child to be reaped and (b) the
23/// stdout/stderr pipes to drain.
24///
25/// Killing the direct child does not guarantee its descendants exit: a
26/// grandchild process (for example a background server, a Python
27/// `subprocess.Popen`, or a `multiprocessing` worker) may have inherited the
28/// stdout/stderr pipe file descriptors and can keep the write end open after
29/// the direct child is gone. In that case the pipe never reaches EOF and an
30/// unbounded drain would block `run` forever, silently defeating the tool's
31/// own timeout/cancellation guarantee. Bounding the wait ensures `run` always
32/// returns; whatever bytes were already captured before the bound elapses are
33/// returned as-is, and any data written afterward is accepted as lost since
34/// the process has already been killed.
35const POST_KILL_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
36
37/// Configuration for capturing a process stream (stdout or stderr).
38#[derive(Debug, Clone)]
39pub struct StreamCaptureConfig {
40    /// Whether to capture this stream.
41    pub capture: bool,
42    /// Maximum number of bytes to capture before truncating.
43    pub max_bytes: usize,
44}
45
46impl Default for StreamCaptureConfig {
47    fn default() -> Self {
48        Self { capture: true, max_bytes: DEFAULT_CAPTURE_LIMIT }
49    }
50}
51
52/// Options for spawning an asynchronous process.
53#[derive(Debug, Clone, Default)]
54pub struct ProcessOptions {
55    /// The program to execute.
56    pub program: String,
57    /// Arguments to pass to the program.
58    pub args: Vec<String>,
59    /// Environment variables for the process.
60    pub env: HashMap<OsString, OsString>,
61    /// Working directory for the process.
62    pub current_dir: Option<PathBuf>,
63    /// Maximum time the process is allowed to run before being killed.
64    pub timeout: Option<Duration>,
65    /// Token to externally cancel the process.
66    pub cancellation_token: Option<CancellationToken>,
67    /// Configuration for stdout capture.
68    pub stdout: StreamCaptureConfig,
69    /// Configuration for stderr capture.
70    pub stderr: StreamCaptureConfig,
71}
72
73/// Output from a completed asynchronous process.
74#[derive(Debug)]
75pub struct ProcessOutput {
76    /// The exit status of the process.
77    pub exit_status: ExitStatus,
78    /// Captured stdout bytes.
79    pub stdout: Vec<u8>,
80    /// Captured stderr bytes.
81    pub stderr: Vec<u8>,
82    /// Whether the process was killed due to a timeout.
83    pub timed_out: bool,
84    /// Whether the process was cancelled via its cancellation token.
85    pub cancelled: bool,
86    /// Wall-clock duration from spawn to completion.
87    pub duration: Duration,
88}
89
90/// Runs a child process asynchronously with timeout and cancellation support.
91pub struct AsyncProcessRunner;
92
93impl AsyncProcessRunner {
94    /// Spawn and await a child process with the given options.
95    ///
96    /// On timeout or cancellation, only the direct child is killed (this
97    /// crate's `async-process` dependency does not expose creating the child
98    /// in its own process group without resorting to unsafe `pre_exec`
99    /// hooks, unlike the `libc`-based group-kill used by
100    /// `vtcode-bash-runner`), so any grandchild process the child spawned may
101    /// survive and keep holding the stdout/stderr pipes open. The post-kill
102    /// stream drain and process reap are therefore bounded by
103    /// `POST_KILL_DRAIN_TIMEOUT` so this function can never hang.
104    pub async fn run(options: ProcessOptions) -> Result<ProcessOutput> {
105        if options.program.is_empty() {
106            return Err(anyhow!("program cannot be empty"));
107        }
108
109        let mut tags = HashMap::new();
110        tags.insert("subsystem".to_string(), "async_command".to_string());
111        tags.insert("program".to_string(), options.program.clone());
112        perf::record_value("vtcode.perf.spawn_count", 1.0, tags);
113
114        gatekeeper::check_quarantine_for_program(&options.program);
115
116        let start = Instant::now();
117        let mut command = AsyncCommand::new(&options.program);
118        command.args(&options.args);
119        if let Some(dir) = &options.current_dir {
120            command.current_dir(dir);
121        }
122        if !options.env.is_empty() {
123            command.envs(&options.env);
124        }
125        command.stdout(Stdio::piped());
126        command.stderr(Stdio::piped());
127
128        let mut child = command
129            .spawn()
130            .with_context(|| format!("failed to spawn '{}' with args {:?}", options.program, options.args))?;
131
132        let stdout_handle = child.stdout.take();
133        let stderr_handle = child.stderr.take();
134        let shared_child = Arc::new(Mutex::new(child));
135
136        let mut stdout_future = Box::pin(read_stream(stdout_handle, options.stdout));
137        let mut stderr_future = Box::pin(read_stream(stderr_handle, options.stderr));
138        let mut wait_future = Box::pin(wait_for_status(shared_child.clone()));
139        let mut timeout_future = options.timeout.map(|dur| Box::pin(sleep(dur)) as Pin<Box<Sleep>>);
140        let mut cancellation_future = options
141            .cancellation_token
142            .as_ref()
143            .map(|token| Box::pin(token.clone().cancelled_owned()) as Pin<Box<WaitForCancellationFutureOwned>>);
144
145        #[derive(Clone, Copy, PartialEq, Eq)]
146        enum Completion {
147            Finished,
148            TimedOut,
149            Cancelled,
150        }
151
152        let mut exit_status: Option<ExitStatus> = None;
153        let mut stdout_result: Option<Result<Vec<u8>>> = None;
154        let mut stderr_result: Option<Result<Vec<u8>>> = None;
155
156        let completion = loop {
157            tokio::select! {
158                res = &mut wait_future, if exit_status.is_none() => {
159                    exit_status = Some(res?);
160                    // Continue to drain streams
161                }
162                res = &mut stdout_future, if stdout_result.is_none() => {
163                    stdout_result = Some(res);
164                }
165                res = &mut stderr_future, if stderr_result.is_none() => {
166                    stderr_result = Some(res);
167                }
168                _ = async {
169                    if let Some(fut) = timeout_future.as_mut() {
170                        fut.as_mut().await;
171                    } else {
172                        futures::future::pending::<()>().await;
173                    }
174                }, if timeout_future.is_some() => {
175                    break Completion::TimedOut;
176                }
177                _ = async {
178                    if let Some(fut) = cancellation_future.as_mut() {
179                        fut.as_mut().await;
180                    } else {
181                        futures::future::pending::<()>().await;
182                    }
183                }, if cancellation_future.is_some() => {
184                    break Completion::Cancelled;
185                }
186            }
187
188            // Check if everything is done
189            if exit_status.is_some() && stdout_result.is_some() && stderr_result.is_some() {
190                break Completion::Finished;
191            }
192        };
193
194        // Once the process has been killed on the TimedOut/Cancelled paths, both
195        // reaping the child and draining its stdout/stderr pipes must be bounded:
196        // a surviving grandchild can hold the pipes open indefinitely, and (much
197        // more rarely) a wedged wait could stall too. The Finished path keeps its
198        // original, unbounded behavior because EOF and exit status are guaranteed
199        // once the process has exited normally.
200        let bounded_drain = matches!(completion, Completion::TimedOut | Completion::Cancelled);
201
202        let (timed_out, cancelled, status) = match completion {
203            Completion::Finished => {
204                let status = match exit_status {
205                    Some(status) => status,
206                    None => wait_future.await?,
207                };
208                (false, false, status)
209            }
210            Completion::TimedOut => {
211                kill_child(shared_child.clone()).await?;
212                let status = match exit_status {
213                    Some(status) => status,
214                    None => match timeout(POST_KILL_DRAIN_TIMEOUT, wait_future.as_mut()).await {
215                        Ok(res) => res?,
216                        Err(_) => {
217                            return Err(anyhow!(
218                                "process timed out and was killed, but could not be reaped within {POST_KILL_DRAIN_TIMEOUT:?}"
219                            ));
220                        }
221                    },
222                };
223                (true, false, status)
224            }
225            Completion::Cancelled => {
226                kill_child(shared_child.clone()).await?;
227                let status = match exit_status {
228                    Some(status) => status,
229                    None => match timeout(POST_KILL_DRAIN_TIMEOUT, wait_future.as_mut()).await {
230                        Ok(res) => res?,
231                        Err(_) => {
232                            return Err(anyhow!(
233                                "process was cancelled and killed, but could not be reaped within {POST_KILL_DRAIN_TIMEOUT:?}"
234                            ));
235                        }
236                    },
237                };
238                (false, true, status)
239            }
240        };
241
242        // Ensure streams are fully read. On timeout/cancellation the drain of any
243        // still-pending stream is bounded: a killed process may have left a
244        // grandchild holding the pipe's write end open, so we accept whatever
245        // was captured so far rather than blocking forever.
246        let stdout = match stdout_result {
247            Some(Ok(data)) => data,
248            Some(Err(e)) => return Err(e),
249            None if bounded_drain => match timeout(POST_KILL_DRAIN_TIMEOUT, stdout_future.as_mut()).await {
250                Ok(res) => res?,
251                Err(_) => Vec::new(),
252            },
253            None => stdout_future.await?,
254        };
255        let stderr = match stderr_result {
256            Some(Ok(data)) => data,
257            Some(Err(e)) => return Err(e),
258            None if bounded_drain => match timeout(POST_KILL_DRAIN_TIMEOUT, stderr_future.as_mut()).await {
259                Ok(res) => res?,
260                Err(_) => Vec::new(),
261            },
262            None => stderr_future.await?,
263        };
264
265        Ok(ProcessOutput {
266            exit_status: status,
267            stdout,
268            stderr,
269            timed_out,
270            cancelled,
271            duration: start.elapsed(),
272        })
273    }
274}
275
276async fn read_stream<R>(reader: Option<R>, config: StreamCaptureConfig) -> Result<Vec<u8>>
277where
278    R: futures::io::AsyncRead + Unpin,
279{
280    if !config.capture {
281        return Ok(Vec::new());
282    }
283
284    let mut reader = match reader {
285        Some(r) => r,
286        None => return Ok(Vec::new()),
287    };
288
289    let mut output = Vec::new();
290    // Reserve a bounded amount of capacity based on the configured capture
291    // limit. We cap the reservation to DEFAULT_CAPTURE_LIMIT to avoid
292    // reserving an unbounded or attacker-controlled amount while still
293    // improving performance for typical capture sizes.
294    let reserve_cap = config.max_bytes.min(DEFAULT_CAPTURE_LIMIT);
295    if reserve_cap > 0 {
296        output.reserve(reserve_cap);
297    }
298
299    let mut buffer = [0u8; 4096];
300    loop {
301        let read = reader.read(&mut buffer).await?;
302        if read == 0 {
303            break;
304        }
305        let remaining = config.max_bytes.saturating_sub(output.len());
306        if remaining > 0 {
307            let to_copy = remaining.min(read);
308            output.extend_from_slice(&buffer[..to_copy]);
309        }
310    }
311
312    Ok(output)
313}
314
315async fn wait_for_status(child: Arc<Mutex<Child>>) -> Result<ExitStatus> {
316    // `Child::status` only needs `&mut Child` momentarily to build its returned
317    // future; the future itself does not borrow the child afterward (it holds
318    // its own clone of async-process's internal handle). The mutex guard is
319    // therefore taken only long enough to obtain that future and is dropped
320    // before awaiting it. Holding the guard across the await instead would
321    // deadlock `kill_child`, which also needs this same mutex: it would never
322    // be able to acquire the lock to kill the process while this future is
323    // pending on exit, and the process cannot exit until it is killed.
324    let status_future = {
325        let mut guard = child.lock().await;
326        guard.status()
327    };
328    let status = status_future.await?;
329    Ok(status)
330}
331
332async fn kill_child(child: Arc<Mutex<Child>>) -> Result<()> {
333    let mut guard = child.lock().await;
334    guard.kill()?;
335    Ok(())
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    /// A timed-out run must return promptly with `timed_out == true` instead
343    /// of hanging on the post-kill stream drain. The whole call is wrapped in
344    /// a generous outer `tokio::time::timeout` so a regression to the old,
345    /// unbounded-drain behavior fails the test instead of hanging the suite.
346    #[tokio::test]
347    async fn run_returns_promptly_after_timeout_kill() {
348        let options = ProcessOptions {
349            program: "sleep".to_string(),
350            args: vec!["30".to_string()],
351            timeout: Some(Duration::from_millis(100)),
352            ..Default::default()
353        };
354
355        let outcome = timeout(Duration::from_secs(10), AsyncProcessRunner::run(options))
356            .await
357            .expect("run() must return well within the outer bound instead of hanging")
358            .expect("timed-out run should still yield a ProcessOutput, not an error");
359
360        assert!(outcome.timed_out, "expected timed_out to be true");
361        assert!(!outcome.cancelled, "cancelled must remain false on timeout");
362    }
363
364    /// Same guarantee as above, but for the cancellation path: a caller
365    /// cancelling a long-running process must get a prompt return with
366    /// `cancelled == true` rather than an indefinite hang.
367    #[tokio::test]
368    async fn run_returns_promptly_after_cancellation_kill() {
369        let token = CancellationToken::new();
370        let cancel_token = token.clone();
371        tokio::spawn(async move {
372            sleep(Duration::from_millis(100)).await;
373            cancel_token.cancel();
374        });
375
376        let options = ProcessOptions {
377            program: "sleep".to_string(),
378            args: vec!["30".to_string()],
379            cancellation_token: Some(token),
380            ..Default::default()
381        };
382
383        let outcome = timeout(Duration::from_secs(10), AsyncProcessRunner::run(options))
384            .await
385            .expect("run() must return well within the outer bound instead of hanging")
386            .expect("cancelled run should still yield a ProcessOutput, not an error");
387
388        assert!(outcome.cancelled, "expected cancelled to be true");
389        assert!(!outcome.timed_out, "timed_out must remain false on cancellation");
390    }
391
392    /// A grandchild process that inherits the stdout pipe and outlives the
393    /// direct child (killed on timeout) must not block `run` forever: the
394    /// bounded drain must let the call return, accepting a partial/empty
395    /// stdout capture instead of hanging.
396    #[tokio::test]
397    async fn run_bounds_drain_when_grandchild_holds_pipe_open() {
398        let options = ProcessOptions {
399            program: "sh".to_string(),
400            args: vec![
401                "-c".to_string(),
402                // The backgrounded `sleep` inherits stdout and keeps the pipe's
403                // write end open well past the parent shell's own timeout-driven
404                // death, simulating a surviving grandchild.
405                "sleep 30 & exit 0".to_string(),
406            ],
407            timeout: Some(Duration::from_millis(100)),
408            ..Default::default()
409        };
410
411        let outcome = timeout(Duration::from_secs(10), AsyncProcessRunner::run(options))
412            .await
413            .expect("run() must return well within the outer bound instead of hanging")
414            .expect("run should still yield a ProcessOutput even with a surviving grandchild");
415
416        assert!(outcome.timed_out, "expected timed_out to be true");
417    }
418}