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    /// Kill the child process group without aborting the readers or wait task.
224    ///
225    /// Session owners use this path when they still need to drain output and
226    /// reap the child after termination. Call [`Self::terminate`] when the
227    /// caller is abandoning the session and does not need that final drain.
228    pub fn terminate_process(&self) {
229        if let Ok(mut killer_opt) = self.killer.lock()
230            && let Some(mut killer) = killer_opt.take()
231        {
232            let _ = killer.kill();
233        }
234    }
235
236    /// Internal termination that aborts all tasks.
237    fn terminate_internal(&self) {
238        // Kill the child process
239        if let Ok(mut killer_opt) = self.killer.lock()
240            && let Some(mut killer) = killer_opt.take()
241        {
242            let _ = killer.kill();
243        }
244
245        self.abort_tasks();
246    }
247
248    /// Abort all background tasks associated with this process.
249    fn abort_tasks(&self) {
250        // Abort reader handle
251        if let Ok(mut h) = self.reader_handle.lock()
252            && let Some(handle) = h.take()
253        {
254            handle.abort();
255        }
256
257        // Abort individual reader abort handles
258        if let Ok(mut handles) = self.reader_abort_handles.lock() {
259            for handle in handles.drain(..) {
260                handle.abort();
261            }
262        }
263
264        // Abort writer handle
265        if let Ok(mut h) = self.writer_handle.lock()
266            && let Some(handle) = h.take()
267        {
268            handle.abort();
269        }
270
271        // Abort wait handle
272        if let Ok(mut h) = self.wait_handle.lock()
273            && let Some(handle) = h.take()
274        {
275            handle.abort();
276        }
277    }
278
279    /// Check if the process is still running.
280    #[inline]
281    pub fn is_running(&self) -> bool {
282        !self.has_exited() && !self.is_writer_closed()
283    }
284
285    /// Send bytes to the process stdin.
286    ///
287    /// Returns an error if the stdin channel is closed.
288    pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
289        self.writer_tx.send(bytes.into()).await
290    }
291
292    /// Check if the writer channel is closed.
293    #[inline]
294    pub fn is_writer_closed(&self) -> bool {
295        self.writer_tx.is_closed()
296    }
297}
298
299impl Drop for ProcessHandle {
300    fn drop(&mut self) {
301        // Use the async-drop pattern so cleanup can block on async waits
302        // (e.g. waiting for the OS to reap the child) without blocking the
303        // caller's thread. This mirrors testcontainers-rs's approach for
304        // async resource cleanup from synchronous Drop.
305        //
306        // We must take ownership of the inner values here because the async
307        // block needs to own everything it captures.
308        let killer = self.killer.lock().ok().and_then(|mut g| g.take());
309        let mut reader_handle = self.reader_handle.lock().ok().and_then(|mut g| g.take());
310        let reader_abort_handles = self
311            .reader_abort_handles
312            .lock()
313            .ok()
314            .map(|mut g| g.drain(..).collect::<Vec<_>>());
315        let mut writer_handle = self.writer_handle.lock().ok().and_then(|mut g| g.take());
316        let mut wait_handle = self.wait_handle.lock().ok().and_then(|mut g| g.take());
317
318        async_drop(move || async move {
319            if let Some(mut killer) = killer {
320                let _ = killer.kill();
321            }
322            if let Some(handle) = reader_handle.take() {
323                handle.abort();
324            }
325            if let Some(handle) = writer_handle.take() {
326                handle.abort();
327            }
328            if let Some(handle) = wait_handle.take() {
329                handle.abort();
330            }
331            if let Some(handles) = reader_abort_handles {
332                for handle in handles {
333                    handle.abort();
334                }
335            }
336        });
337    }
338}
339
340/// Return value from spawn helpers (PTY or pipe).
341///
342/// Bundles the process handle with receivers for output and exit notification.
343#[derive(Debug)]
344pub struct SpawnedProcess {
345    /// Handle for interacting with the process.
346    pub session: ProcessHandle,
347    /// Operating-system process identifier for the direct child.
348    pub process_id: u32,
349    /// Receiver for stdout/stderr output chunks.
350    pub output_rx: broadcast::Receiver<Bytes>,
351    /// Bounded, lossless receiver for consumers that must spool complete
352    /// output. Unlike `output_rx`, this channel applies backpressure to the
353    /// child-process readers instead of dropping lagged chunks.
354    pub reliable_output_rx: mpsc::Receiver<Bytes>,
355    /// Whether the producer is connected to `reliable_output_rx`.
356    pub(crate) reliable_output_enabled: bool,
357    /// Receiver for exit code (receives once when process exits).
358    pub exit_rx: oneshot::Receiver<i32>,
359}
360
361impl SpawnedProcess {
362    /// Convenience method to wait for the process to exit and collect output.
363    ///
364    /// Returns (collected_output, exit_code).
365    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
366        if self.reliable_output_enabled {
367            collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
368        } else {
369            collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
370        }
371    }
372}
373
374/// Collect all output from the bounded process stream until exit or timeout.
375async fn collect_reliable_output_until_exit(
376    mut output_rx: mpsc::Receiver<Bytes>,
377    exit_rx: oneshot::Receiver<i32>,
378    timeout_ms: u64,
379) -> (Vec<u8>, i32) {
380    let mut collected = Vec::new();
381    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
382    tokio::pin!(exit_rx);
383
384    loop {
385        tokio::select! {
386            chunk = output_rx.recv() => {
387                if let Some(chunk) = chunk {
388                    collected.extend_from_slice(&chunk);
389                } else {
390                    return (collected, exit_rx.await.unwrap_or(-1));
391                }
392            }
393            res = &mut exit_rx => {
394                let code = res.unwrap_or(-1);
395                // A descendant may inherit stdout/stderr after the direct
396                // child exits. Keep the lossless path bounded just like the
397                // compatibility broadcast path instead of waiting forever
398                // for an inherited pipe descriptor to close.
399                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
400                let max_deadline = tokio::time::Instant::now()
401                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
402                while tokio::time::Instant::now() < max_deadline {
403                    match tokio::time::timeout(quiet, output_rx.recv()).await {
404                        Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
405                        Ok(None) | Err(_) => break,
406                    }
407                }
408                return (collected, code);
409            }
410            _ = tokio::time::sleep_until(deadline) => {
411                return (collected, -1);
412            }
413        }
414    }
415}
416
417/// Collect output from a process until it exits or times out.
418///
419/// This is useful for tests and simple use cases where you want all output.
420pub async fn collect_output_until_exit(
421    mut output_rx: broadcast::Receiver<Bytes>,
422    exit_rx: oneshot::Receiver<i32>,
423    timeout_ms: u64,
424) -> (Vec<u8>, i32) {
425    let mut collected = Vec::new();
426    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
427    tokio::pin!(exit_rx);
428
429    loop {
430        tokio::select! {
431            res = output_rx.recv() => {
432                if let Ok(chunk) = res {
433                    collected.extend_from_slice(&chunk);
434                }
435            }
436            res = &mut exit_rx => {
437                let code = res.unwrap_or(-1);
438                // Drain remaining output briefly after exit
439                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
440                let max_deadline = tokio::time::Instant::now()
441                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
442
443                while tokio::time::Instant::now() < max_deadline {
444                    match tokio::time::timeout(quiet, output_rx.recv()).await {
445                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
446                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
447                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
448                            continue;
449                        }
450                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
451                        Err(_) => break, // Timeout - quiet period reached
452                    }
453                }
454                return (collected, code);
455            }
456            _ = tokio::time::sleep_until(deadline) => {
457                return (collected, -1);
458            }
459        }
460    }
461}
462
463/// Backwards-compatible alias for ProcessHandle.
464pub type ExecCommandSession = ProcessHandle;
465
466/// Backwards-compatible alias for SpawnedProcess.
467pub type SpawnedPty = SpawnedProcess;
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    struct NoopTerminator;
474    impl ChildTerminator for NoopTerminator {
475        fn kill(&mut self) -> io::Result<()> {
476            Ok(())
477        }
478    }
479
480    #[tokio::test]
481    async fn test_process_handle_debug() {
482        // Just verify Debug impl doesn't panic
483        let exit_status = Arc::new(AtomicBool::new(false));
484        let exit_code = Arc::new(StdMutex::new(None));
485
486        let (writer_tx, _) = mpsc::channel(1);
487        let (output_tx, initial_rx) = broadcast::channel(1);
488
489        let (handle, _) = ProcessHandle::new(
490            writer_tx,
491            output_tx,
492            initial_rx,
493            Box::new(NoopTerminator),
494            tokio::spawn(async {}),
495            vec![],
496            tokio::spawn(async {}),
497            tokio::spawn(async {}),
498            exit_status,
499            exit_code,
500            None,
501        );
502
503        let debug_str = format!("{handle:?}");
504        assert!(debug_str.contains("ProcessHandle"));
505    }
506
507    #[tokio::test]
508    async fn test_has_exited() {
509        let exit_status = Arc::new(AtomicBool::new(false));
510        let exit_code = Arc::new(StdMutex::new(None));
511
512        let (writer_tx, _) = mpsc::channel(1);
513        let (output_tx, initial_rx) = broadcast::channel(1);
514
515        let (handle, _) = ProcessHandle::new(
516            writer_tx,
517            output_tx,
518            initial_rx,
519            Box::new(NoopTerminator),
520            tokio::spawn(async {}),
521            vec![],
522            tokio::spawn(async {}),
523            tokio::spawn(async {}),
524            Arc::clone(&exit_status),
525            exit_code,
526            None,
527        );
528
529        assert!(!handle.has_exited());
530        exit_status.store(true, Ordering::SeqCst);
531        assert!(handle.has_exited());
532    }
533}