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(
117        clippy::too_many_arguments,
118        reason = "Intentional compatibility, platform, or test-only suppression."
119    )]
120    pub(crate) fn new(
121        writer_tx: mpsc::Sender<Vec<u8>>,
122        output_tx: broadcast::Sender<Bytes>,
123        initial_output_rx: broadcast::Receiver<Bytes>,
124        killer: Box<dyn ChildTerminator>,
125        reader_handle: JoinHandle<()>,
126        reader_abort_handles: Vec<AbortHandle>,
127        writer_handle: JoinHandle<()>,
128        wait_handle: JoinHandle<()>,
129        exit_status: Arc<AtomicBool>,
130        exit_code: Arc<StdMutex<Option<i32>>>,
131        pty_handles: Option<PtyHandles>,
132    ) -> (Self, broadcast::Receiver<Bytes>) {
133        (
134            Self {
135                writer_tx,
136                output_tx,
137                killer: StdMutex::new(Some(killer)),
138                reader_handle: StdMutex::new(Some(reader_handle)),
139                reader_abort_handles: StdMutex::new(reader_abort_handles),
140                writer_handle: StdMutex::new(Some(writer_handle)),
141                wait_handle: StdMutex::new(Some(wait_handle)),
142                exit_status,
143                exit_code,
144                _pty_handles: StdMutex::new(pty_handles),
145            },
146            initial_output_rx,
147        )
148    }
149
150    /// Returns a channel sender for writing raw bytes to the child stdin.
151    ///
152    /// # Example
153    /// ```ignore
154    /// let writer = handle.writer_sender();
155    /// writer.send(b"input\n".to_vec()).await?;
156    /// ```
157    #[inline]
158    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
159        self.writer_tx.clone()
160    }
161
162    /// Returns a broadcast receiver that yields stdout/stderr chunks.
163    ///
164    /// Multiple receivers can be created; each receives all output from the
165    /// point of subscription.
166    #[inline]
167    pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
168        self.output_tx.subscribe()
169    }
170
171    /// True if the child process has exited.
172    #[inline]
173    pub fn has_exited(&self) -> bool {
174        self.exit_status.load(Ordering::SeqCst)
175    }
176
177    /// Returns the exit code if the process has exited.
178    #[inline]
179    pub fn exit_code(&self) -> Option<i32> {
180        *self.exit_code.lock().unwrap_or_else(|e| e.into_inner())
181    }
182
183    /// True once the stdout/stderr reader task has drained the child streams.
184    #[inline]
185    pub fn is_output_drained(&self) -> bool {
186        self.reader_handle
187            .lock()
188            .ok()
189            .and_then(|guard| guard.as_ref().map(JoinHandle::is_finished))
190            .unwrap_or(true)
191    }
192
193    /// Attempts to kill the child and abort helper tasks.
194    ///
195    /// This is idempotent and safe to call multiple times.
196    pub fn terminate(&self) {
197        self.terminate_internal();
198    }
199
200    /// Internal termination that aborts all tasks.
201    fn terminate_internal(&self) {
202        // Kill the child process
203        if let Ok(mut killer_opt) = self.killer.lock()
204            && let Some(mut killer) = killer_opt.take()
205        {
206            let _ = killer.kill();
207        }
208
209        self.abort_tasks();
210    }
211
212    /// Abort all background tasks associated with this process.
213    fn abort_tasks(&self) {
214        // Abort reader handle
215        if let Ok(mut h) = self.reader_handle.lock()
216            && let Some(handle) = h.take()
217        {
218            handle.abort();
219        }
220
221        // Abort individual reader abort handles
222        if let Ok(mut handles) = self.reader_abort_handles.lock() {
223            for handle in handles.drain(..) {
224                handle.abort();
225            }
226        }
227
228        // Abort writer handle
229        if let Ok(mut h) = self.writer_handle.lock()
230            && let Some(handle) = h.take()
231        {
232            handle.abort();
233        }
234
235        // Abort wait handle
236        if let Ok(mut h) = self.wait_handle.lock()
237            && let Some(handle) = h.take()
238        {
239            handle.abort();
240        }
241    }
242
243    /// Check if the process is still running.
244    #[inline]
245    pub fn is_running(&self) -> bool {
246        !self.has_exited() && !self.is_writer_closed()
247    }
248
249    /// Send bytes to the process stdin.
250    ///
251    /// Returns an error if the stdin channel is closed.
252    pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
253        self.writer_tx.send(bytes.into()).await
254    }
255
256    /// Check if the writer channel is closed.
257    #[inline]
258    pub fn is_writer_closed(&self) -> bool {
259        self.writer_tx.is_closed()
260    }
261}
262
263impl Drop for ProcessHandle {
264    fn drop(&mut self) {
265        // Use the async-drop pattern so cleanup can block on async waits
266        // (e.g. waiting for the OS to reap the child) without blocking the
267        // caller's thread. This mirrors testcontainers-rs's approach for
268        // async resource cleanup from synchronous Drop.
269        //
270        // We must take ownership of the inner values here because the async
271        // block needs to own everything it captures.
272        let killer = self.killer.lock().ok().and_then(|mut g| g.take());
273        let mut reader_handle = self.reader_handle.lock().ok().and_then(|mut g| g.take());
274        let reader_abort_handles = self
275            .reader_abort_handles
276            .lock()
277            .ok()
278            .map(|mut g| g.drain(..).collect::<Vec<_>>());
279        let mut writer_handle = self.writer_handle.lock().ok().and_then(|mut g| g.take());
280        let mut wait_handle = self.wait_handle.lock().ok().and_then(|mut g| g.take());
281
282        async_drop(move || async move {
283            if let Some(mut killer) = killer {
284                let _ = killer.kill();
285            }
286            if let Some(handle) = reader_handle.take() {
287                handle.abort();
288            }
289            if let Some(handle) = writer_handle.take() {
290                handle.abort();
291            }
292            if let Some(handle) = wait_handle.take() {
293                handle.abort();
294            }
295            if let Some(handles) = reader_abort_handles {
296                for handle in handles {
297                    handle.abort();
298                }
299            }
300        });
301    }
302}
303
304/// Return value from spawn helpers (PTY or pipe).
305///
306/// Bundles the process handle with receivers for output and exit notification.
307#[derive(Debug)]
308pub struct SpawnedProcess {
309    /// Handle for interacting with the process.
310    pub session: ProcessHandle,
311    /// Receiver for stdout/stderr output chunks.
312    pub output_rx: broadcast::Receiver<Bytes>,
313    /// Bounded, lossless receiver for consumers that must spool complete
314    /// output. Unlike `output_rx`, this channel applies backpressure to the
315    /// child-process readers instead of dropping lagged chunks.
316    pub reliable_output_rx: mpsc::Receiver<Bytes>,
317    /// Whether the producer is connected to `reliable_output_rx`.
318    pub(crate) reliable_output_enabled: bool,
319    /// Receiver for exit code (receives once when process exits).
320    pub exit_rx: oneshot::Receiver<i32>,
321}
322
323impl SpawnedProcess {
324    /// Convenience method to wait for the process to exit and collect output.
325    ///
326    /// Returns (collected_output, exit_code).
327    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
328        if self.reliable_output_enabled {
329            collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
330        } else {
331            collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
332        }
333    }
334}
335
336/// Collect all output from the bounded process stream until exit or timeout.
337async fn collect_reliable_output_until_exit(
338    mut output_rx: mpsc::Receiver<Bytes>,
339    exit_rx: oneshot::Receiver<i32>,
340    timeout_ms: u64,
341) -> (Vec<u8>, i32) {
342    let mut collected = Vec::new();
343    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
344    tokio::pin!(exit_rx);
345
346    loop {
347        tokio::select! {
348            chunk = output_rx.recv() => {
349                if let Some(chunk) = chunk {
350                    collected.extend_from_slice(&chunk);
351                } else {
352                    return (collected, exit_rx.await.unwrap_or(-1));
353                }
354            }
355            res = &mut exit_rx => {
356                let code = res.unwrap_or(-1);
357                // A descendant may inherit stdout/stderr after the direct
358                // child exits. Keep the lossless path bounded just like the
359                // compatibility broadcast path instead of waiting forever
360                // for an inherited pipe descriptor to close.
361                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
362                let max_deadline = tokio::time::Instant::now()
363                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
364                while tokio::time::Instant::now() < max_deadline {
365                    match tokio::time::timeout(quiet, output_rx.recv()).await {
366                        Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
367                        Ok(None) | Err(_) => break,
368                    }
369                }
370                return (collected, code);
371            }
372            _ = tokio::time::sleep_until(deadline) => {
373                return (collected, -1);
374            }
375        }
376    }
377}
378
379/// Collect output from a process until it exits or times out.
380///
381/// This is useful for tests and simple use cases where you want all output.
382pub async fn collect_output_until_exit(
383    mut output_rx: broadcast::Receiver<Bytes>,
384    exit_rx: oneshot::Receiver<i32>,
385    timeout_ms: u64,
386) -> (Vec<u8>, i32) {
387    let mut collected = Vec::new();
388    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
389    tokio::pin!(exit_rx);
390
391    loop {
392        tokio::select! {
393            res = output_rx.recv() => {
394                if let Ok(chunk) = res {
395                    collected.extend_from_slice(&chunk);
396                }
397            }
398            res = &mut exit_rx => {
399                let code = res.unwrap_or(-1);
400                // Drain remaining output briefly after exit
401                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
402                let max_deadline = tokio::time::Instant::now()
403                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
404
405                while tokio::time::Instant::now() < max_deadline {
406                    match tokio::time::timeout(quiet, output_rx.recv()).await {
407                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
408                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
409                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
410                            continue;
411                        }
412                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
413                        Err(_) => break, // Timeout - quiet period reached
414                    }
415                }
416                return (collected, code);
417            }
418            _ = tokio::time::sleep_until(deadline) => {
419                return (collected, -1);
420            }
421        }
422    }
423}
424
425/// Backwards-compatible alias for ProcessHandle.
426pub type ExecCommandSession = ProcessHandle;
427
428/// Backwards-compatible alias for SpawnedProcess.
429pub type SpawnedPty = SpawnedProcess;
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    struct NoopTerminator;
436    impl ChildTerminator for NoopTerminator {
437        fn kill(&mut self) -> io::Result<()> {
438            Ok(())
439        }
440    }
441
442    #[tokio::test]
443    async fn test_process_handle_debug() {
444        // Just verify Debug impl doesn't panic
445        let exit_status = Arc::new(AtomicBool::new(false));
446        let exit_code = Arc::new(StdMutex::new(None));
447
448        let (writer_tx, _) = mpsc::channel(1);
449        let (output_tx, initial_rx) = broadcast::channel(1);
450
451        let (handle, _) = ProcessHandle::new(
452            writer_tx,
453            output_tx,
454            initial_rx,
455            Box::new(NoopTerminator),
456            tokio::spawn(async {}),
457            vec![],
458            tokio::spawn(async {}),
459            tokio::spawn(async {}),
460            exit_status,
461            exit_code,
462            None,
463        );
464
465        let debug_str = format!("{handle:?}");
466        assert!(debug_str.contains("ProcessHandle"));
467    }
468
469    #[tokio::test]
470    async fn test_has_exited() {
471        let exit_status = Arc::new(AtomicBool::new(false));
472        let exit_code = Arc::new(StdMutex::new(None));
473
474        let (writer_tx, _) = mpsc::channel(1);
475        let (output_tx, initial_rx) = broadcast::channel(1);
476
477        let (handle, _) = ProcessHandle::new(
478            writer_tx,
479            output_tx,
480            initial_rx,
481            Box::new(NoopTerminator),
482            tokio::spawn(async {}),
483            vec![],
484            tokio::spawn(async {}),
485            tokio::spawn(async {}),
486            Arc::clone(&exit_status),
487            exit_code,
488            None,
489        );
490
491        assert!(!handle.has_exited());
492        exit_status.store(true, Ordering::SeqCst);
493        assert!(handle.has_exited());
494    }
495}