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
12use std::fmt;
13use std::io;
14use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use bytes::Bytes;
19use tokio::sync::{broadcast, mpsc, oneshot};
20use tokio::task::{AbortHandle, JoinHandle};
21
22/// Trait for process termination strategies.
23///
24/// Different backends (PTY vs pipe) may need different termination approaches.
25pub trait ChildTerminator: Send + Sync {
26    /// Kill the child process.
27    fn kill(&mut self) -> io::Result<()>;
28}
29
30/// Optional PTY-specific handles that must be preserved.
31///
32/// For PTY processes, the slave handle must be kept alive because the process
33/// will receive SIGHUP if it's closed.
34pub struct PtyHandles {
35    /// The slave PTY handle (kept alive to prevent SIGHUP).
36    pub _slave: Option<Box<dyn Send>>,
37    /// The master PTY handle.
38    pub _master: Box<dyn Send>,
39}
40
41impl fmt::Debug for PtyHandles {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_struct("PtyHandles").finish()
44    }
45}
46
47/// Handle for driving an interactive or non-interactive process.
48///
49/// This provides a unified interface for both PTY and pipe-based processes:
50/// - Write to stdin via `writer_sender()`
51/// - Read merged stdout/stderr via `output_receiver()`
52/// - Check exit status via `has_exited()` and `exit_code()`
53/// - Clean up via `terminate()`
54pub struct ProcessHandle {
55    writer_tx: mpsc::Sender<Vec<u8>>,
56    output_tx: broadcast::Sender<Bytes>,
57    killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
58    reader_handle: StdMutex<Option<JoinHandle<()>>>,
59    reader_abort_handles: StdMutex<Vec<AbortHandle>>,
60    writer_handle: StdMutex<Option<JoinHandle<()>>>,
61    wait_handle: StdMutex<Option<JoinHandle<()>>>,
62    exit_status: Arc<AtomicBool>,
63    exit_code: Arc<StdMutex<Option<i32>>>,
64    // PTY handles must be preserved to prevent the process from receiving Control+C
65    _pty_handles: StdMutex<Option<PtyHandles>>,
66}
67
68impl fmt::Debug for ProcessHandle {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.debug_struct("ProcessHandle")
71            .field("has_exited", &self.has_exited())
72            .field("exit_code", &self.exit_code())
73            .finish()
74    }
75}
76
77impl ProcessHandle {
78    /// Create a new process handle with all required components.
79    #[allow(clippy::too_many_arguments)]
80    pub fn new(
81        writer_tx: mpsc::Sender<Vec<u8>>,
82        output_tx: broadcast::Sender<Bytes>,
83        initial_output_rx: broadcast::Receiver<Bytes>,
84        killer: Box<dyn ChildTerminator>,
85        reader_handle: JoinHandle<()>,
86        reader_abort_handles: Vec<AbortHandle>,
87        writer_handle: JoinHandle<()>,
88        wait_handle: JoinHandle<()>,
89        exit_status: Arc<AtomicBool>,
90        exit_code: Arc<StdMutex<Option<i32>>>,
91        pty_handles: Option<PtyHandles>,
92    ) -> (Self, broadcast::Receiver<Bytes>) {
93        (
94            Self {
95                writer_tx,
96                output_tx,
97                killer: StdMutex::new(Some(killer)),
98                reader_handle: StdMutex::new(Some(reader_handle)),
99                reader_abort_handles: StdMutex::new(reader_abort_handles),
100                writer_handle: StdMutex::new(Some(writer_handle)),
101                wait_handle: StdMutex::new(Some(wait_handle)),
102                exit_status,
103                exit_code,
104                _pty_handles: StdMutex::new(pty_handles),
105            },
106            initial_output_rx,
107        )
108    }
109
110    /// Returns a channel sender for writing raw bytes to the child stdin.
111    ///
112    /// # Example
113    /// ```ignore
114    /// let writer = handle.writer_sender();
115    /// writer.send(b"input\n".to_vec()).await?;
116    /// ```
117    #[inline]
118    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
119        self.writer_tx.clone()
120    }
121
122    /// Returns a broadcast receiver that yields stdout/stderr chunks.
123    ///
124    /// Multiple receivers can be created; each receives all output from the
125    /// point of subscription.
126    #[inline]
127    pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
128        self.output_tx.subscribe()
129    }
130
131    /// True if the child process has exited.
132    #[inline]
133    pub fn has_exited(&self) -> bool {
134        self.exit_status.load(Ordering::SeqCst)
135    }
136
137    /// Returns the exit code if the process has exited.
138    #[inline]
139    pub fn exit_code(&self) -> Option<i32> {
140        self.exit_code.lock().ok().and_then(|guard| *guard)
141    }
142
143    /// True once the stdout/stderr reader task has drained the child streams.
144    #[inline]
145    pub fn is_output_drained(&self) -> bool {
146        self.reader_handle
147            .lock()
148            .ok()
149            .and_then(|guard| guard.as_ref().map(JoinHandle::is_finished))
150            .unwrap_or(true)
151    }
152
153    /// Attempts to kill the child and abort helper tasks.
154    ///
155    /// This is idempotent and safe to call multiple times.
156    pub fn terminate(&self) {
157        self.terminate_internal();
158    }
159
160    /// Internal termination that aborts all tasks.
161    fn terminate_internal(&self) {
162        // Kill the child process
163        if let Ok(mut killer_opt) = self.killer.lock()
164            && let Some(mut killer) = killer_opt.take()
165        {
166            let _ = killer.kill();
167        }
168
169        self.abort_tasks();
170    }
171
172    /// Abort all background tasks associated with this process.
173    fn abort_tasks(&self) {
174        // Abort reader handle
175        if let Ok(mut h) = self.reader_handle.lock()
176            && let Some(handle) = h.take()
177        {
178            handle.abort();
179        }
180
181        // Abort individual reader abort handles
182        if let Ok(mut handles) = self.reader_abort_handles.lock() {
183            for handle in handles.drain(..) {
184                handle.abort();
185            }
186        }
187
188        // Abort writer handle
189        if let Ok(mut h) = self.writer_handle.lock()
190            && let Some(handle) = h.take()
191        {
192            handle.abort();
193        }
194
195        // Abort wait handle
196        if let Ok(mut h) = self.wait_handle.lock()
197            && let Some(handle) = h.take()
198        {
199            handle.abort();
200        }
201    }
202
203    /// Check if the process is still running.
204    #[inline]
205    pub fn is_running(&self) -> bool {
206        !self.has_exited() && !self.is_writer_closed()
207    }
208
209    /// Send bytes to the process stdin.
210    ///
211    /// Returns an error if the stdin channel is closed.
212    pub async fn write(
213        &self,
214        bytes: impl Into<Vec<u8>>,
215    ) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
216        self.writer_tx.send(bytes.into()).await
217    }
218
219    /// Check if the writer channel is closed.
220    #[inline]
221    pub fn is_writer_closed(&self) -> bool {
222        self.writer_tx.is_closed()
223    }
224}
225
226impl Drop for ProcessHandle {
227    fn drop(&mut self) {
228        self.terminate_internal();
229    }
230}
231
232/// Return value from spawn helpers (PTY or pipe).
233///
234/// Bundles the process handle with receivers for output and exit notification.
235#[derive(Debug)]
236pub struct SpawnedProcess {
237    /// Handle for interacting with the process.
238    pub session: ProcessHandle,
239    /// Receiver for stdout/stderr output chunks.
240    pub output_rx: broadcast::Receiver<Bytes>,
241    /// Receiver for exit code (receives once when process exits).
242    pub exit_rx: oneshot::Receiver<i32>,
243}
244
245impl SpawnedProcess {
246    /// Convenience method to wait for the process to exit and collect output.
247    ///
248    /// Returns (collected_output, exit_code).
249    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
250        collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
251    }
252}
253
254/// Collect output from a process until it exits or times out.
255///
256/// This is useful for tests and simple use cases where you want all output.
257pub async fn collect_output_until_exit(
258    mut output_rx: broadcast::Receiver<Bytes>,
259    exit_rx: oneshot::Receiver<i32>,
260    timeout_ms: u64,
261) -> (Vec<u8>, i32) {
262    let mut collected = Vec::new();
263    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
264    tokio::pin!(exit_rx);
265
266    loop {
267        tokio::select! {
268            res = output_rx.recv() => {
269                if let Ok(chunk) = res {
270                    collected.extend_from_slice(&chunk);
271                }
272            }
273            res = &mut exit_rx => {
274                let code = res.unwrap_or(-1);
275                // Drain remaining output briefly after exit
276                let quiet = tokio::time::Duration::from_millis(50);
277                let max_deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
278
279                while tokio::time::Instant::now() < max_deadline {
280                    match tokio::time::timeout(quiet, output_rx.recv()).await {
281                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
282                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
283                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
284                            continue;
285                        }
286                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
287                        Err(_) => break, // Timeout - quiet period reached
288                    }
289                }
290                return (collected, code);
291            }
292            _ = tokio::time::sleep_until(deadline) => {
293                return (collected, -1);
294            }
295        }
296    }
297}
298
299/// Backwards-compatible alias for ProcessHandle.
300pub type ExecCommandSession = ProcessHandle;
301
302/// Backwards-compatible alias for SpawnedProcess.
303pub type SpawnedPty = SpawnedProcess;
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    struct NoopTerminator;
310    impl ChildTerminator for NoopTerminator {
311        fn kill(&mut self) -> io::Result<()> {
312            Ok(())
313        }
314    }
315
316    #[tokio::test]
317    async fn test_process_handle_debug() {
318        // Just verify Debug impl doesn't panic
319        let exit_status = Arc::new(AtomicBool::new(false));
320        let exit_code = Arc::new(StdMutex::new(None));
321
322        let (writer_tx, _) = mpsc::channel(1);
323        let (output_tx, initial_rx) = broadcast::channel(1);
324
325        let (handle, _) = ProcessHandle::new(
326            writer_tx,
327            output_tx,
328            initial_rx,
329            Box::new(NoopTerminator),
330            tokio::spawn(async {}),
331            vec![],
332            tokio::spawn(async {}),
333            tokio::spawn(async {}),
334            exit_status,
335            exit_code,
336            None,
337        );
338
339        let debug_str = format!("{handle:?}");
340        assert!(debug_str.contains("ProcessHandle"));
341    }
342
343    #[tokio::test]
344    async fn test_has_exited() {
345        let exit_status = Arc::new(AtomicBool::new(false));
346        let exit_code = Arc::new(StdMutex::new(None));
347
348        let (writer_tx, _) = mpsc::channel(1);
349        let (output_tx, initial_rx) = broadcast::channel(1);
350
351        let (handle, _) = ProcessHandle::new(
352            writer_tx,
353            output_tx,
354            initial_rx,
355            Box::new(NoopTerminator),
356            tokio::spawn(async {}),
357            vec![],
358            tokio::spawn(async {}),
359            tokio::spawn(async {}),
360            Arc::clone(&exit_status),
361            exit_code,
362            None,
363        );
364
365        assert!(!handle.has_exited());
366        exit_status.store(true, Ordering::SeqCst);
367        assert!(handle.has_exited());
368    }
369}