Skip to main content

vtcode_bash_runner/
process.rs

1//! Unified process handle types for PTY and pipe backends.
2//!
3//! This module provides abstractions for interacting with spawned processes
4//! regardless of whether they use a PTY or regular pipes.
5//!
6//! Inspired by [codex-rs] PTY process handle patterns (Apache-2.0).
7//! Copyright 2025 OpenAI. See the repository `THIRD-PARTY-NOTICES` file for
8//! full attribution.
9//!
10//! [codex-rs]: https://github.com/openai/codex
11//!
12//! ## Async-drop pattern
13//!
14//! `Drop` cannot be `async`, but some cleanup requires async operations
15//! (e.g. asking a runtime to stop a process, removing a container, closing
16//! a network connection). The pattern borrowed from `testcontainers-rs` is to
17//! spin a dedicated thread inside `Drop`, create a temporary Tokio runtime on
18//! that thread, and block the `Drop` call until the future resolves. This is
19//! heavier than a true `async drop`, but it lets us bridge sync `Drop` into
20//! async cleanup without requiring nightly Rust.
21
22use std::fmt;
23use std::io;
24use std::sync::Arc;
25use std::sync::Mutex as StdMutex;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use bytes::Bytes;
29use tokio::sync::{broadcast, mpsc, oneshot};
30use tokio::task::{AbortHandle, JoinHandle};
31
32const POST_EXIT_DRAIN_QUIET_MS: u64 = 50;
33const POST_EXIT_DRAIN_MAX_MS: u64 = 500;
34
35/// Run an async cleanup future from synchronous `Drop`.
36///
37/// This bridges the gap between sync `Drop` and async resource cleanup.
38/// A dedicated thread is spawned with its own Tokio runtime so the future
39/// can make full use of async APIs. The `Drop` call blocks until the runtime
40/// shuts down, giving us deterministic cleanup semantics similar to RAII.
41///
42/// Borrowed from the `testcontainers-rs` pattern for async-drop in Rust
43/// (where true `async drop` is still nightly-only).
44pub(crate) fn async_drop<F, Fut>(f: F)
45where
46    F: FnOnce() -> Fut + Send + 'static,
47    Fut: Future<Output = ()> + Send + 'static,
48{
49    let handle = std::thread::spawn(move || {
50        let rt = match tokio::runtime::Runtime::new() {
51            Ok(rt) => rt,
52            Err(_) => return,
53        };
54        rt.block_on(f());
55    });
56    let _ = handle.join();
57}
58
59/// Trait for process termination strategies.
60///
61/// Different backends (PTY vs pipe) may need different termination approaches.
62pub trait ChildTerminator: Send + Sync {
63    /// Kill the child process.
64    fn kill(&mut self) -> io::Result<()>;
65}
66
67/// Optional PTY-specific handles that must be preserved.
68///
69/// For PTY processes, the slave handle must be kept alive because the process
70/// will receive SIGHUP if it's closed.
71pub struct PtyHandles {
72    /// The slave PTY handle (kept alive to prevent SIGHUP).
73    pub _slave: Option<Box<dyn Send>>,
74    /// The master PTY handle.
75    pub _master: Box<dyn Send>,
76}
77
78impl fmt::Debug for PtyHandles {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.debug_struct("PtyHandles").finish()
81    }
82}
83
84/// Handle for driving an interactive or non-interactive process.
85///
86/// This provides a unified interface for both PTY and pipe-based processes:
87/// - Write to stdin via `writer_sender()`
88/// - Read merged stdout/stderr via `output_receiver()`
89/// - Check exit status via `has_exited()` and `exit_code()`
90/// - Clean up via `terminate()`
91pub struct ProcessHandle {
92    writer_tx: mpsc::Sender<Vec<u8>>,
93    output_tx: broadcast::Sender<Bytes>,
94    killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
95    reader_handle: StdMutex<Option<JoinHandle<()>>>,
96    reader_abort_handles: StdMutex<Vec<AbortHandle>>,
97    writer_handle: StdMutex<Option<JoinHandle<()>>>,
98    wait_handle: StdMutex<Option<JoinHandle<()>>>,
99    exit_status: Arc<AtomicBool>,
100    exit_code: Arc<StdMutex<Option<i32>>>,
101    // PTY handles must be preserved to prevent the process from receiving Control+C
102    _pty_handles: StdMutex<Option<PtyHandles>>,
103}
104
105impl fmt::Debug for ProcessHandle {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.debug_struct("ProcessHandle")
108            .field("has_exited", &self.has_exited())
109            .field("exit_code", &self.exit_code())
110            .finish()
111    }
112}
113
114impl ProcessHandle {
115    /// Create a new process handle with all required components.
116    #[allow(clippy::too_many_arguments)]
117    pub(crate) fn new(
118        writer_tx: mpsc::Sender<Vec<u8>>,
119        output_tx: broadcast::Sender<Bytes>,
120        initial_output_rx: broadcast::Receiver<Bytes>,
121        killer: Box<dyn ChildTerminator>,
122        reader_handle: JoinHandle<()>,
123        reader_abort_handles: Vec<AbortHandle>,
124        writer_handle: JoinHandle<()>,
125        wait_handle: JoinHandle<()>,
126        exit_status: Arc<AtomicBool>,
127        exit_code: Arc<StdMutex<Option<i32>>>,
128        pty_handles: Option<PtyHandles>,
129    ) -> (Self, broadcast::Receiver<Bytes>) {
130        (
131            Self {
132                writer_tx,
133                output_tx,
134                killer: StdMutex::new(Some(killer)),
135                reader_handle: StdMutex::new(Some(reader_handle)),
136                reader_abort_handles: StdMutex::new(reader_abort_handles),
137                writer_handle: StdMutex::new(Some(writer_handle)),
138                wait_handle: StdMutex::new(Some(wait_handle)),
139                exit_status,
140                exit_code,
141                _pty_handles: StdMutex::new(pty_handles),
142            },
143            initial_output_rx,
144        )
145    }
146
147    /// Returns a channel sender for writing raw bytes to the child stdin.
148    ///
149    /// # Example
150    /// ```ignore
151    /// let writer = handle.writer_sender();
152    /// writer.send(b"input\n".to_vec()).await?;
153    /// ```
154    #[inline]
155    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
156        self.writer_tx.clone()
157    }
158
159    /// Returns a broadcast receiver that yields stdout/stderr chunks.
160    ///
161    /// Multiple receivers can be created; each receives all output from the
162    /// point of subscription.
163    #[inline]
164    pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
165        self.output_tx.subscribe()
166    }
167
168    /// True if the child process has exited.
169    #[inline]
170    pub fn has_exited(&self) -> bool {
171        self.exit_status.load(Ordering::SeqCst)
172    }
173
174    /// Returns the exit code if the process has exited.
175    #[inline]
176    pub fn exit_code(&self) -> Option<i32> {
177        *self.exit_code.lock().unwrap_or_else(|e| e.into_inner())
178    }
179
180    /// True once the stdout/stderr reader task has drained the child streams.
181    #[inline]
182    pub fn is_output_drained(&self) -> bool {
183        self.reader_handle
184            .lock()
185            .ok()
186            .and_then(|guard| guard.as_ref().map(JoinHandle::is_finished))
187            .unwrap_or(true)
188    }
189
190    /// Attempts to kill the child and abort helper tasks.
191    ///
192    /// This is idempotent and safe to call multiple times.
193    pub fn terminate(&self) {
194        self.terminate_internal();
195    }
196
197    /// Internal termination that aborts all tasks.
198    fn terminate_internal(&self) {
199        // Kill the child process
200        if let Ok(mut killer_opt) = self.killer.lock()
201            && let Some(mut killer) = killer_opt.take()
202        {
203            let _ = killer.kill();
204        }
205
206        self.abort_tasks();
207    }
208
209    /// Abort all background tasks associated with this process.
210    fn abort_tasks(&self) {
211        // Abort reader handle
212        if let Ok(mut h) = self.reader_handle.lock()
213            && let Some(handle) = h.take()
214        {
215            handle.abort();
216        }
217
218        // Abort individual reader abort handles
219        if let Ok(mut handles) = self.reader_abort_handles.lock() {
220            for handle in handles.drain(..) {
221                handle.abort();
222            }
223        }
224
225        // Abort writer handle
226        if let Ok(mut h) = self.writer_handle.lock()
227            && let Some(handle) = h.take()
228        {
229            handle.abort();
230        }
231
232        // Abort wait handle
233        if let Ok(mut h) = self.wait_handle.lock()
234            && let Some(handle) = h.take()
235        {
236            handle.abort();
237        }
238    }
239
240    /// Check if the process is still running.
241    #[inline]
242    pub fn is_running(&self) -> bool {
243        !self.has_exited() && !self.is_writer_closed()
244    }
245
246    /// Send bytes to the process stdin.
247    ///
248    /// Returns an error if the stdin channel is closed.
249    pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
250        self.writer_tx.send(bytes.into()).await
251    }
252
253    /// Check if the writer channel is closed.
254    #[inline]
255    pub fn is_writer_closed(&self) -> bool {
256        self.writer_tx.is_closed()
257    }
258}
259
260impl Drop for ProcessHandle {
261    fn drop(&mut self) {
262        // Use the async-drop pattern so cleanup can block on async waits
263        // (e.g. waiting for the OS to reap the child) without blocking the
264        // caller's thread. This mirrors testcontainers-rs's approach for
265        // async resource cleanup from synchronous Drop.
266        //
267        // We must take ownership of the inner values here because the async
268        // block needs to own everything it captures.
269        let killer = self.killer.lock().ok().and_then(|mut g| g.take());
270        let mut reader_handle = self.reader_handle.lock().ok().and_then(|mut g| g.take());
271        let reader_abort_handles = self
272            .reader_abort_handles
273            .lock()
274            .ok()
275            .map(|mut g| g.drain(..).collect::<Vec<_>>());
276        let mut writer_handle = self.writer_handle.lock().ok().and_then(|mut g| g.take());
277        let mut wait_handle = self.wait_handle.lock().ok().and_then(|mut g| g.take());
278
279        async_drop(move || async move {
280            if let Some(mut killer) = killer {
281                let _ = killer.kill();
282            }
283            if let Some(handle) = reader_handle.take() {
284                handle.abort();
285            }
286            if let Some(handle) = writer_handle.take() {
287                handle.abort();
288            }
289            if let Some(handle) = wait_handle.take() {
290                handle.abort();
291            }
292            if let Some(handles) = reader_abort_handles {
293                for handle in handles {
294                    handle.abort();
295                }
296            }
297        });
298    }
299}
300
301/// Return value from spawn helpers (PTY or pipe).
302///
303/// Bundles the process handle with receivers for output and exit notification.
304#[derive(Debug)]
305pub struct SpawnedProcess {
306    /// Handle for interacting with the process.
307    pub session: ProcessHandle,
308    /// Receiver for stdout/stderr output chunks.
309    pub output_rx: broadcast::Receiver<Bytes>,
310    /// Bounded, lossless receiver for consumers that must spool complete
311    /// output. Unlike `output_rx`, this channel applies backpressure to the
312    /// child-process readers instead of dropping lagged chunks.
313    pub reliable_output_rx: mpsc::Receiver<Bytes>,
314    /// Whether the producer is connected to `reliable_output_rx`.
315    pub(crate) reliable_output_enabled: bool,
316    /// Receiver for exit code (receives once when process exits).
317    pub exit_rx: oneshot::Receiver<i32>,
318}
319
320impl SpawnedProcess {
321    /// Convenience method to wait for the process to exit and collect output.
322    ///
323    /// Returns (collected_output, exit_code).
324    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
325        if self.reliable_output_enabled {
326            collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
327        } else {
328            collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
329        }
330    }
331}
332
333/// Collect all output from the bounded process stream until exit or timeout.
334async fn collect_reliable_output_until_exit(
335    mut output_rx: mpsc::Receiver<Bytes>,
336    exit_rx: oneshot::Receiver<i32>,
337    timeout_ms: u64,
338) -> (Vec<u8>, i32) {
339    let mut collected = Vec::new();
340    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
341    tokio::pin!(exit_rx);
342
343    loop {
344        tokio::select! {
345            chunk = output_rx.recv() => {
346                if let Some(chunk) = chunk {
347                    collected.extend_from_slice(&chunk);
348                } else {
349                    return (collected, exit_rx.await.unwrap_or(-1));
350                }
351            }
352            res = &mut exit_rx => {
353                let code = res.unwrap_or(-1);
354                // A descendant may inherit stdout/stderr after the direct
355                // child exits. Keep the lossless path bounded just like the
356                // compatibility broadcast path instead of waiting forever
357                // for an inherited pipe descriptor to close.
358                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
359                let max_deadline = tokio::time::Instant::now()
360                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
361                while tokio::time::Instant::now() < max_deadline {
362                    match tokio::time::timeout(quiet, output_rx.recv()).await {
363                        Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
364                        Ok(None) | Err(_) => break,
365                    }
366                }
367                return (collected, code);
368            }
369            _ = tokio::time::sleep_until(deadline) => {
370                return (collected, -1);
371            }
372        }
373    }
374}
375
376/// Collect output from a process until it exits or times out.
377///
378/// This is useful for tests and simple use cases where you want all output.
379pub async fn collect_output_until_exit(
380    mut output_rx: broadcast::Receiver<Bytes>,
381    exit_rx: oneshot::Receiver<i32>,
382    timeout_ms: u64,
383) -> (Vec<u8>, i32) {
384    let mut collected = Vec::new();
385    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
386    tokio::pin!(exit_rx);
387
388    loop {
389        tokio::select! {
390            res = output_rx.recv() => {
391                if let Ok(chunk) = res {
392                    collected.extend_from_slice(&chunk);
393                }
394            }
395            res = &mut exit_rx => {
396                let code = res.unwrap_or(-1);
397                // Drain remaining output briefly after exit
398                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
399                let max_deadline = tokio::time::Instant::now()
400                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
401
402                while tokio::time::Instant::now() < max_deadline {
403                    match tokio::time::timeout(quiet, output_rx.recv()).await {
404                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
405                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
406                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
407                            continue;
408                        }
409                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
410                        Err(_) => break, // Timeout - quiet period reached
411                    }
412                }
413                return (collected, code);
414            }
415            _ = tokio::time::sleep_until(deadline) => {
416                return (collected, -1);
417            }
418        }
419    }
420}
421
422/// Backwards-compatible alias for ProcessHandle.
423pub type ExecCommandSession = ProcessHandle;
424
425/// Backwards-compatible alias for SpawnedProcess.
426pub type SpawnedPty = SpawnedProcess;
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    struct NoopTerminator;
433    impl ChildTerminator for NoopTerminator {
434        fn kill(&mut self) -> io::Result<()> {
435            Ok(())
436        }
437    }
438
439    #[tokio::test]
440    async fn test_process_handle_debug() {
441        // Just verify Debug impl doesn't panic
442        let exit_status = Arc::new(AtomicBool::new(false));
443        let exit_code = Arc::new(StdMutex::new(None));
444
445        let (writer_tx, _) = mpsc::channel(1);
446        let (output_tx, initial_rx) = broadcast::channel(1);
447
448        let (handle, _) = ProcessHandle::new(
449            writer_tx,
450            output_tx,
451            initial_rx,
452            Box::new(NoopTerminator),
453            tokio::spawn(async {}),
454            vec![],
455            tokio::spawn(async {}),
456            tokio::spawn(async {}),
457            exit_status,
458            exit_code,
459            None,
460        );
461
462        let debug_str = format!("{handle:?}");
463        assert!(debug_str.contains("ProcessHandle"));
464    }
465
466    #[tokio::test]
467    async fn test_has_exited() {
468        let exit_status = Arc::new(AtomicBool::new(false));
469        let exit_code = Arc::new(StdMutex::new(None));
470
471        let (writer_tx, _) = mpsc::channel(1);
472        let (output_tx, initial_rx) = broadcast::channel(1);
473
474        let (handle, _) = ProcessHandle::new(
475            writer_tx,
476            output_tx,
477            initial_rx,
478            Box::new(NoopTerminator),
479            tokio::spawn(async {}),
480            vec![],
481            tokio::spawn(async {}),
482            tokio::spawn(async {}),
483            Arc::clone(&exit_status),
484            exit_code,
485            None,
486        );
487
488        assert!(!handle.has_exited());
489        exit_status.store(true, Ordering::SeqCst);
490        assert!(handle.has_exited());
491    }
492}