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/// Keep-alive guard for PTY master/slave handles.
68///
69/// This is a marker trait for opaque OS handles (e.g. `portable-pty` pair
70/// halves) whose only contract is ownership: dropping the handle releases the
71/// underlying resource. It exists so `PtyHandles` can name its vtable instead
72/// of erasing to bare `dyn Send` (which carries an empty vtable and documents
73/// no intent).
74///
75/// Memory layout note: `Box<dyn PtyHandle>` is a wide pointer (data pointer +
76/// vtable pointer, 16 bytes on 64-bit). There is one vtable per concrete
77/// handle type, emitted as external static data and paired with the object at
78/// the construction site — Rust chooses dynamic dispatch at the call site, so
79/// storing the concrete handle type directly (instead of boxing) would use
80/// static dispatch. Boxing is justified here only because PTY backends are
81/// selected at runtime and their handle types are heterogeneous.
82///
83/// The blanket implementation covers every `Send` handle, so existing backends
84/// can wrap their concrete handle with `Box::new(handle) as Box<dyn PtyHandle>`
85/// without additional work.
86pub trait PtyHandle: Send {}
87
88impl<T: Send> PtyHandle for T {}
89
90/// Optional PTY-specific handles that must be preserved.
91///
92/// For PTY processes, the slave handle must be kept alive because the process
93/// will receive SIGHUP if it's closed.
94pub struct PtyHandles {
95    /// The slave PTY handle (kept alive to prevent SIGHUP).
96    pub _slave: Option<Box<dyn PtyHandle>>,
97    /// The master PTY handle.
98    pub _master: Box<dyn PtyHandle>,
99}
100
101impl fmt::Debug for PtyHandles {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.debug_struct("PtyHandles").finish()
104    }
105}
106
107/// Handle for driving an interactive or non-interactive process.
108///
109/// This provides a unified interface for both PTY and pipe-based processes:
110/// - Write to stdin via `writer_sender()`
111/// - Read merged stdout/stderr via `output_receiver()`
112/// - Check exit status via `has_exited()` and `exit_code()`
113/// - Clean up via `terminate()`
114pub struct ProcessHandle {
115    writer_tx: mpsc::Sender<Vec<u8>>,
116    output_tx: broadcast::Sender<Bytes>,
117    killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
118    reader_handle: StdMutex<Option<JoinHandle<()>>>,
119    reader_abort_handles: StdMutex<Vec<AbortHandle>>,
120    writer_handle: StdMutex<Option<JoinHandle<()>>>,
121    wait_handle: StdMutex<Option<JoinHandle<()>>>,
122    exit_status: Arc<AtomicBool>,
123    exit_code: Arc<StdMutex<Option<i32>>>,
124    // PTY handles must be preserved to prevent the process from receiving Control+C
125    _pty_handles: StdMutex<Option<PtyHandles>>,
126}
127
128impl fmt::Debug for ProcessHandle {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.debug_struct("ProcessHandle")
131            .field("has_exited", &self.has_exited())
132            .field("exit_code", &self.exit_code())
133            .finish()
134    }
135}
136
137impl ProcessHandle {
138    /// Create a new process handle with all required components.
139    #[allow(
140        clippy::too_many_arguments,
141        reason = "Intentional compatibility, platform, or test-only suppression."
142    )]
143    pub(crate) fn new(
144        writer_tx: mpsc::Sender<Vec<u8>>,
145        output_tx: broadcast::Sender<Bytes>,
146        initial_output_rx: broadcast::Receiver<Bytes>,
147        killer: Box<dyn ChildTerminator>,
148        reader_handle: JoinHandle<()>,
149        reader_abort_handles: Vec<AbortHandle>,
150        writer_handle: JoinHandle<()>,
151        wait_handle: JoinHandle<()>,
152        exit_status: Arc<AtomicBool>,
153        exit_code: Arc<StdMutex<Option<i32>>>,
154        pty_handles: Option<PtyHandles>,
155    ) -> (Self, broadcast::Receiver<Bytes>) {
156        (
157            Self {
158                writer_tx,
159                output_tx,
160                killer: StdMutex::new(Some(killer)),
161                reader_handle: StdMutex::new(Some(reader_handle)),
162                reader_abort_handles: StdMutex::new(reader_abort_handles),
163                writer_handle: StdMutex::new(Some(writer_handle)),
164                wait_handle: StdMutex::new(Some(wait_handle)),
165                exit_status,
166                exit_code,
167                _pty_handles: StdMutex::new(pty_handles),
168            },
169            initial_output_rx,
170        )
171    }
172
173    /// Returns a channel sender for writing raw bytes to the child stdin.
174    ///
175    /// # Example
176    /// ```ignore
177    /// let writer = handle.writer_sender();
178    /// writer.send(b"input\n".to_vec()).await?;
179    /// ```
180    #[inline]
181    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
182        self.writer_tx.clone()
183    }
184
185    /// Returns a broadcast receiver that yields stdout/stderr chunks.
186    ///
187    /// Multiple receivers can be created; each receives all output from the
188    /// point of subscription.
189    #[inline]
190    pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
191        self.output_tx.subscribe()
192    }
193
194    /// True if the child process has exited.
195    #[inline]
196    pub fn has_exited(&self) -> bool {
197        self.exit_status.load(Ordering::SeqCst)
198    }
199
200    /// Returns the exit code if the process has exited.
201    #[inline]
202    pub fn exit_code(&self) -> Option<i32> {
203        *self.exit_code.lock().unwrap_or_else(|e| e.into_inner())
204    }
205
206    /// True once the stdout/stderr reader task has drained the child streams.
207    #[inline]
208    pub fn is_output_drained(&self) -> bool {
209        self.reader_handle
210            .lock()
211            .ok()
212            .and_then(|guard| guard.as_ref().map(JoinHandle::is_finished))
213            .unwrap_or(true)
214    }
215
216    /// Attempts to kill the child and abort helper tasks.
217    ///
218    /// This is idempotent and safe to call multiple times.
219    pub fn terminate(&self) {
220        self.terminate_internal();
221    }
222
223    /// Internal termination that aborts all tasks.
224    fn terminate_internal(&self) {
225        // Kill the child process
226        if let Ok(mut killer_opt) = self.killer.lock()
227            && let Some(mut killer) = killer_opt.take()
228        {
229            let _ = killer.kill();
230        }
231
232        self.abort_tasks();
233    }
234
235    /// Abort all background tasks associated with this process.
236    fn abort_tasks(&self) {
237        // Abort reader handle
238        if let Ok(mut h) = self.reader_handle.lock()
239            && let Some(handle) = h.take()
240        {
241            handle.abort();
242        }
243
244        // Abort individual reader abort handles
245        if let Ok(mut handles) = self.reader_abort_handles.lock() {
246            for handle in handles.drain(..) {
247                handle.abort();
248            }
249        }
250
251        // Abort writer handle
252        if let Ok(mut h) = self.writer_handle.lock()
253            && let Some(handle) = h.take()
254        {
255            handle.abort();
256        }
257
258        // Abort wait handle
259        if let Ok(mut h) = self.wait_handle.lock()
260            && let Some(handle) = h.take()
261        {
262            handle.abort();
263        }
264    }
265
266    /// Check if the process is still running.
267    #[inline]
268    pub fn is_running(&self) -> bool {
269        !self.has_exited() && !self.is_writer_closed()
270    }
271
272    /// Send bytes to the process stdin.
273    ///
274    /// Returns an error if the stdin channel is closed.
275    pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
276        self.writer_tx.send(bytes.into()).await
277    }
278
279    /// Check if the writer channel is closed.
280    #[inline]
281    pub fn is_writer_closed(&self) -> bool {
282        self.writer_tx.is_closed()
283    }
284}
285
286impl Drop for ProcessHandle {
287    fn drop(&mut self) {
288        // Use the async-drop pattern so cleanup can block on async waits
289        // (e.g. waiting for the OS to reap the child) without blocking the
290        // caller's thread. This mirrors testcontainers-rs's approach for
291        // async resource cleanup from synchronous Drop.
292        //
293        // We must take ownership of the inner values here because the async
294        // block needs to own everything it captures.
295        let killer = self.killer.lock().ok().and_then(|mut g| g.take());
296        let mut reader_handle = self.reader_handle.lock().ok().and_then(|mut g| g.take());
297        let reader_abort_handles = self
298            .reader_abort_handles
299            .lock()
300            .ok()
301            .map(|mut g| g.drain(..).collect::<Vec<_>>());
302        let mut writer_handle = self.writer_handle.lock().ok().and_then(|mut g| g.take());
303        let mut wait_handle = self.wait_handle.lock().ok().and_then(|mut g| g.take());
304
305        async_drop(move || async move {
306            if let Some(mut killer) = killer {
307                let _ = killer.kill();
308            }
309            if let Some(handle) = reader_handle.take() {
310                handle.abort();
311            }
312            if let Some(handle) = writer_handle.take() {
313                handle.abort();
314            }
315            if let Some(handle) = wait_handle.take() {
316                handle.abort();
317            }
318            if let Some(handles) = reader_abort_handles {
319                for handle in handles {
320                    handle.abort();
321                }
322            }
323        });
324    }
325}
326
327/// Return value from spawn helpers (PTY or pipe).
328///
329/// Bundles the process handle with receivers for output and exit notification.
330#[derive(Debug)]
331pub struct SpawnedProcess {
332    /// Handle for interacting with the process.
333    pub session: ProcessHandle,
334    /// Receiver for stdout/stderr output chunks.
335    pub output_rx: broadcast::Receiver<Bytes>,
336    /// Bounded, lossless receiver for consumers that must spool complete
337    /// output. Unlike `output_rx`, this channel applies backpressure to the
338    /// child-process readers instead of dropping lagged chunks.
339    pub reliable_output_rx: mpsc::Receiver<Bytes>,
340    /// Whether the producer is connected to `reliable_output_rx`.
341    pub(crate) reliable_output_enabled: bool,
342    /// Receiver for exit code (receives once when process exits).
343    pub exit_rx: oneshot::Receiver<i32>,
344}
345
346impl SpawnedProcess {
347    /// Convenience method to wait for the process to exit and collect output.
348    ///
349    /// Returns (collected_output, exit_code).
350    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
351        if self.reliable_output_enabled {
352            collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
353        } else {
354            collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
355        }
356    }
357}
358
359/// Collect all output from the bounded process stream until exit or timeout.
360async fn collect_reliable_output_until_exit(
361    mut output_rx: mpsc::Receiver<Bytes>,
362    exit_rx: oneshot::Receiver<i32>,
363    timeout_ms: u64,
364) -> (Vec<u8>, i32) {
365    let mut collected = Vec::new();
366    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
367    tokio::pin!(exit_rx);
368
369    loop {
370        tokio::select! {
371            chunk = output_rx.recv() => {
372                if let Some(chunk) = chunk {
373                    collected.extend_from_slice(&chunk);
374                } else {
375                    return (collected, exit_rx.await.unwrap_or(-1));
376                }
377            }
378            res = &mut exit_rx => {
379                let code = res.unwrap_or(-1);
380                // A descendant may inherit stdout/stderr after the direct
381                // child exits. Keep the lossless path bounded just like the
382                // compatibility broadcast path instead of waiting forever
383                // for an inherited pipe descriptor to close.
384                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
385                let max_deadline = tokio::time::Instant::now()
386                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
387                while tokio::time::Instant::now() < max_deadline {
388                    match tokio::time::timeout(quiet, output_rx.recv()).await {
389                        Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
390                        Ok(None) | Err(_) => break,
391                    }
392                }
393                return (collected, code);
394            }
395            _ = tokio::time::sleep_until(deadline) => {
396                return (collected, -1);
397            }
398        }
399    }
400}
401
402/// Collect output from a process until it exits or times out.
403///
404/// This is useful for tests and simple use cases where you want all output.
405pub async fn collect_output_until_exit(
406    mut output_rx: broadcast::Receiver<Bytes>,
407    exit_rx: oneshot::Receiver<i32>,
408    timeout_ms: u64,
409) -> (Vec<u8>, i32) {
410    let mut collected = Vec::new();
411    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
412    tokio::pin!(exit_rx);
413
414    loop {
415        tokio::select! {
416            res = output_rx.recv() => {
417                if let Ok(chunk) = res {
418                    collected.extend_from_slice(&chunk);
419                }
420            }
421            res = &mut exit_rx => {
422                let code = res.unwrap_or(-1);
423                // Drain remaining output briefly after exit
424                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
425                let max_deadline = tokio::time::Instant::now()
426                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
427
428                while tokio::time::Instant::now() < max_deadline {
429                    match tokio::time::timeout(quiet, output_rx.recv()).await {
430                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
431                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
432                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
433                            continue;
434                        }
435                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
436                        Err(_) => break, // Timeout - quiet period reached
437                    }
438                }
439                return (collected, code);
440            }
441            _ = tokio::time::sleep_until(deadline) => {
442                return (collected, -1);
443            }
444        }
445    }
446}
447
448/// Backwards-compatible alias for ProcessHandle.
449pub type ExecCommandSession = ProcessHandle;
450
451/// Backwards-compatible alias for SpawnedProcess.
452pub type SpawnedPty = SpawnedProcess;
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    struct NoopTerminator;
459    impl ChildTerminator for NoopTerminator {
460        fn kill(&mut self) -> io::Result<()> {
461            Ok(())
462        }
463    }
464
465    #[tokio::test]
466    async fn test_process_handle_debug() {
467        // Just verify Debug impl doesn't panic
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            exit_status,
484            exit_code,
485            None,
486        );
487
488        let debug_str = format!("{handle:?}");
489        assert!(debug_str.contains("ProcessHandle"));
490    }
491
492    #[tokio::test]
493    async fn test_has_exited() {
494        let exit_status = Arc::new(AtomicBool::new(false));
495        let exit_code = Arc::new(StdMutex::new(None));
496
497        let (writer_tx, _) = mpsc::channel(1);
498        let (output_tx, initial_rx) = broadcast::channel(1);
499
500        let (handle, _) = ProcessHandle::new(
501            writer_tx,
502            output_tx,
503            initial_rx,
504            Box::new(NoopTerminator),
505            tokio::spawn(async {}),
506            vec![],
507            tokio::spawn(async {}),
508            tokio::spawn(async {}),
509            Arc::clone(&exit_status),
510            exit_code,
511            None,
512        );
513
514        assert!(!handle.has_exited());
515        exit_status.store(true, Ordering::SeqCst);
516        assert!(handle.has_exited());
517    }
518}