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(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
213        self.writer_tx.send(bytes.into()).await
214    }
215
216    /// Check if the writer channel is closed.
217    #[inline]
218    pub fn is_writer_closed(&self) -> bool {
219        self.writer_tx.is_closed()
220    }
221}
222
223impl Drop for ProcessHandle {
224    fn drop(&mut self) {
225        self.terminate_internal();
226    }
227}
228
229/// Return value from spawn helpers (PTY or pipe).
230///
231/// Bundles the process handle with receivers for output and exit notification.
232#[derive(Debug)]
233pub struct SpawnedProcess {
234    /// Handle for interacting with the process.
235    pub session: ProcessHandle,
236    /// Receiver for stdout/stderr output chunks.
237    pub output_rx: broadcast::Receiver<Bytes>,
238    /// Receiver for exit code (receives once when process exits).
239    pub exit_rx: oneshot::Receiver<i32>,
240}
241
242impl SpawnedProcess {
243    /// Convenience method to wait for the process to exit and collect output.
244    ///
245    /// Returns (collected_output, exit_code).
246    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
247        collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
248    }
249}
250
251/// Collect output from a process until it exits or times out.
252///
253/// This is useful for tests and simple use cases where you want all output.
254pub async fn collect_output_until_exit(
255    mut output_rx: broadcast::Receiver<Bytes>,
256    exit_rx: oneshot::Receiver<i32>,
257    timeout_ms: u64,
258) -> (Vec<u8>, i32) {
259    let mut collected = Vec::new();
260    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
261    tokio::pin!(exit_rx);
262
263    loop {
264        tokio::select! {
265            res = output_rx.recv() => {
266                if let Ok(chunk) = res {
267                    collected.extend_from_slice(&chunk);
268                }
269            }
270            res = &mut exit_rx => {
271                let code = res.unwrap_or(-1);
272                // Drain remaining output briefly after exit
273                let quiet = tokio::time::Duration::from_millis(50);
274                let max_deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
275
276                while tokio::time::Instant::now() < max_deadline {
277                    match tokio::time::timeout(quiet, output_rx.recv()).await {
278                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
279                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
280                            eprintln!("[vtcode] output stream lagged ({count} dropped)");
281                            continue;
282                        }
283                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
284                        Err(_) => break, // Timeout - quiet period reached
285                    }
286                }
287                return (collected, code);
288            }
289            _ = tokio::time::sleep_until(deadline) => {
290                return (collected, -1);
291            }
292        }
293    }
294}
295
296/// Backwards-compatible alias for ProcessHandle.
297pub type ExecCommandSession = ProcessHandle;
298
299/// Backwards-compatible alias for SpawnedProcess.
300pub type SpawnedPty = SpawnedProcess;
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    struct NoopTerminator;
307    impl ChildTerminator for NoopTerminator {
308        fn kill(&mut self) -> io::Result<()> {
309            Ok(())
310        }
311    }
312
313    #[tokio::test]
314    async fn test_process_handle_debug() {
315        // Just verify Debug impl doesn't panic
316        let exit_status = Arc::new(AtomicBool::new(false));
317        let exit_code = Arc::new(StdMutex::new(None));
318
319        let (writer_tx, _) = mpsc::channel(1);
320        let (output_tx, initial_rx) = broadcast::channel(1);
321
322        let (handle, _) = ProcessHandle::new(
323            writer_tx,
324            output_tx,
325            initial_rx,
326            Box::new(NoopTerminator),
327            tokio::spawn(async {}),
328            vec![],
329            tokio::spawn(async {}),
330            tokio::spawn(async {}),
331            exit_status,
332            exit_code,
333            None,
334        );
335
336        let debug_str = format!("{handle:?}");
337        assert!(debug_str.contains("ProcessHandle"));
338    }
339
340    #[tokio::test]
341    async fn test_has_exited() {
342        let exit_status = Arc::new(AtomicBool::new(false));
343        let exit_code = Arc::new(StdMutex::new(None));
344
345        let (writer_tx, _) = mpsc::channel(1);
346        let (output_tx, initial_rx) = broadcast::channel(1);
347
348        let (handle, _) = ProcessHandle::new(
349            writer_tx,
350            output_tx,
351            initial_rx,
352            Box::new(NoopTerminator),
353            tokio::spawn(async {}),
354            vec![],
355            tokio::spawn(async {}),
356            tokio::spawn(async {}),
357            Arc::clone(&exit_status),
358            exit_code,
359            None,
360        );
361
362        assert!(!handle.has_exited());
363        exit_status.store(true, Ordering::SeqCst);
364        assert!(handle.has_exited());
365    }
366}