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; const POST_KILL_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
36
37#[derive(Debug, Clone)]
39pub struct StreamCaptureConfig {
40 pub capture: bool,
42 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#[derive(Debug, Clone, Default)]
54pub struct ProcessOptions {
55 pub program: String,
57 pub args: Vec<String>,
59 pub env: HashMap<OsString, OsString>,
61 pub current_dir: Option<PathBuf>,
63 pub timeout: Option<Duration>,
65 pub cancellation_token: Option<CancellationToken>,
67 pub stdout: StreamCaptureConfig,
69 pub stderr: StreamCaptureConfig,
71}
72
73#[derive(Debug)]
75pub struct ProcessOutput {
76 pub exit_status: ExitStatus,
78 pub stdout: Vec<u8>,
80 pub stderr: Vec<u8>,
82 pub timed_out: bool,
84 pub cancelled: bool,
86 pub duration: Duration,
88}
89
90pub struct AsyncProcessRunner;
92
93impl AsyncProcessRunner {
94 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 }
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 if exit_status.is_some() && stdout_result.is_some() && stderr_result.is_some() {
190 break Completion::Finished;
191 }
192 };
193
194 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 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 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 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 #[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 #[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 #[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 "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}