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
22const POST_EXIT_DRAIN_QUIET_MS: u64 = 50;
23const POST_EXIT_DRAIN_MAX_MS: u64 = 500;
24
25/// Trait for process termination strategies.
26///
27/// Different backends (PTY vs pipe) may need different termination approaches.
28pub trait ChildTerminator: Send + Sync {
29    /// Kill the child process.
30    fn kill(&mut self) -> io::Result<()>;
31}
32
33/// Keep-alive guard for PTY master/slave handles.
34///
35/// This is a marker trait for opaque OS handles (e.g. `portable-pty` pair
36/// halves) whose only contract is ownership: dropping the handle releases the
37/// underlying resource. It exists so `PtyHandles` can name its vtable instead
38/// of erasing to bare `dyn Send` (which carries an empty vtable and documents
39/// no intent).
40///
41/// Memory layout note: `Box<dyn PtyHandle>` is a wide pointer (data pointer +
42/// vtable pointer, 16 bytes on 64-bit). There is one vtable per concrete
43/// handle type, emitted as external static data and paired with the object at
44/// the construction site — Rust chooses dynamic dispatch at the call site, so
45/// storing the concrete handle type directly (instead of boxing) would use
46/// static dispatch. Boxing is justified here only because PTY backends are
47/// selected at runtime and their handle types are heterogeneous.
48///
49/// The blanket implementation covers every `Send` handle, so existing backends
50/// can wrap their concrete handle with `Box::new(handle) as Box<dyn PtyHandle>`
51/// without additional work.
52pub trait PtyHandle: Send {}
53
54impl<T: Send> PtyHandle for T {}
55
56/// Optional PTY-specific handles that must be preserved.
57///
58/// For PTY processes, the slave handle must be kept alive because the process
59/// will receive SIGHUP if it's closed.
60pub struct PtyHandles {
61    /// The slave PTY handle (kept alive to prevent SIGHUP).
62    pub _slave: Option<Box<dyn PtyHandle>>,
63    /// The master PTY handle.
64    pub _master: Box<dyn PtyHandle>,
65}
66
67impl fmt::Debug for PtyHandles {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_struct("PtyHandles").finish()
70    }
71}
72
73/// Handle for driving an interactive or non-interactive process.
74///
75/// This provides a unified interface for both PTY and pipe-based processes:
76/// - Write to stdin via `writer_sender()`
77/// - Read merged stdout/stderr via `output_receiver()`
78/// - Check exit status via `has_exited()` and `exit_code()`
79/// - Clean up via `terminate()`
80pub struct ProcessHandle {
81    writer_tx: mpsc::Sender<Vec<u8>>,
82    output_tx: broadcast::Sender<Bytes>,
83    killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
84    reader_handle: StdMutex<Option<JoinHandle<()>>>,
85    reader_abort_handles: StdMutex<Vec<AbortHandle>>,
86    writer_handle: StdMutex<Option<JoinHandle<()>>>,
87    wait_handle: StdMutex<Option<JoinHandle<()>>>,
88    exit_status: Arc<AtomicBool>,
89    exit_observed: Arc<AtomicBool>,
90    exit_code: Arc<StdMutex<Option<i32>>>,
91    // PTY handles must be preserved to prevent the process from receiving Control+C
92    _pty_handles: StdMutex<Option<PtyHandles>>,
93}
94
95impl fmt::Debug for ProcessHandle {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.debug_struct("ProcessHandle")
98            .field("has_exited", &self.has_exited())
99            .field("exit_code", &self.exit_code())
100            .finish()
101    }
102}
103
104impl ProcessHandle {
105    /// Create a new process handle with all required components.
106    #[allow(
107        clippy::too_many_arguments,
108        reason = "Intentional compatibility, platform, or test-only suppression."
109    )]
110    pub(crate) fn new(
111        writer_tx: mpsc::Sender<Vec<u8>>,
112        output_tx: broadcast::Sender<Bytes>,
113        initial_output_rx: broadcast::Receiver<Bytes>,
114        killer: Box<dyn ChildTerminator>,
115        reader_handle: JoinHandle<()>,
116        reader_abort_handles: Vec<AbortHandle>,
117        writer_handle: JoinHandle<()>,
118        wait_handle: JoinHandle<()>,
119        exit_status: Arc<AtomicBool>,
120        exit_observed: Arc<AtomicBool>,
121        exit_code: Arc<StdMutex<Option<i32>>>,
122        pty_handles: Option<PtyHandles>,
123    ) -> (Self, broadcast::Receiver<Bytes>) {
124        (
125            Self {
126                writer_tx,
127                output_tx,
128                killer: StdMutex::new(Some(killer)),
129                reader_handle: StdMutex::new(Some(reader_handle)),
130                reader_abort_handles: StdMutex::new(reader_abort_handles),
131                writer_handle: StdMutex::new(Some(writer_handle)),
132                wait_handle: StdMutex::new(Some(wait_handle)),
133                exit_status,
134                exit_observed,
135                exit_code,
136                _pty_handles: StdMutex::new(pty_handles),
137            },
138            initial_output_rx,
139        )
140    }
141
142    /// Returns a channel sender for writing raw bytes to the child stdin.
143    ///
144    /// # Example
145    /// ```ignore
146    /// let writer = handle.writer_sender();
147    /// writer.send(b"input\n".to_vec()).await?;
148    /// ```
149    #[inline]
150    pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
151        self.writer_tx.clone()
152    }
153
154    /// Returns a broadcast receiver that yields stdout/stderr chunks.
155    ///
156    /// Multiple receivers can be created; each receives all output from the
157    /// point of subscription.
158    #[inline]
159    pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
160        self.output_tx.subscribe()
161    }
162
163    /// True when the wait task finished, including when waiting failed.
164    /// Use [`Self::has_observed_exit`] when successful wait evidence matters.
165    #[inline]
166    pub fn has_exited(&self) -> bool {
167        self.exit_status.load(Ordering::SeqCst)
168    }
169
170    /// Whether waiting for the direct child returned an exit status. Unlike
171    /// `has_exited`, this remains false if the operating-system wait failed.
172    #[inline]
173    pub fn has_observed_exit(&self) -> bool {
174        self.exit_observed.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    /// Kill the child process group without aborting the readers or wait task.
201    ///
202    /// Session owners use this path when they still need to drain output and
203    /// reap the child after termination. Call [`Self::terminate`] when the
204    /// caller is abandoning the session and does not need that final drain.
205    pub fn terminate_process(&self) {
206        let _ = self.try_terminate_process();
207    }
208
209    /// Request termination without consuming the handle on failure, so a
210    /// caller that needs a confirmed shutdown can report or retry the error.
211    ///
212    /// # Errors
213    /// Returns the process-kill error or an error if the handle lock is poisoned.
214    pub fn try_terminate_process(&self) -> io::Result<()> {
215        let mut killer = self
216            .killer
217            .lock()
218            .map_err(|_error| io::Error::other("process termination lock poisoned"))?;
219        if let Some(terminator) = killer.as_mut() {
220            terminator.kill()?;
221            *killer = None;
222        }
223        Ok(())
224    }
225
226    /// Internal termination that aborts all tasks.
227    fn terminate_internal(&self) {
228        // Kill the child process
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        self.abort_tasks();
236    }
237
238    /// Abort all background tasks associated with this process.
239    fn abort_tasks(&self) {
240        // Abort reader handle
241        if let Ok(mut h) = self.reader_handle.lock()
242            && let Some(handle) = h.take()
243        {
244            handle.abort();
245        }
246
247        // Abort individual reader abort handles
248        if let Ok(mut handles) = self.reader_abort_handles.lock() {
249            for handle in handles.drain(..) {
250                handle.abort();
251            }
252        }
253
254        // Abort writer handle
255        if let Ok(mut h) = self.writer_handle.lock()
256            && let Some(handle) = h.take()
257        {
258            handle.abort();
259        }
260
261        // Abort wait handle
262        if let Ok(mut h) = self.wait_handle.lock()
263            && let Some(handle) = h.take()
264        {
265            handle.abort();
266        }
267    }
268
269    /// Check if the process is still running.
270    #[inline]
271    pub fn is_running(&self) -> bool {
272        !self.has_exited() && !self.is_writer_closed()
273    }
274
275    /// Send bytes to the process stdin.
276    ///
277    /// Returns an error if the stdin channel is closed.
278    pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
279        self.writer_tx.send(bytes.into()).await
280    }
281
282    /// Check if the writer channel is closed.
283    #[inline]
284    pub fn is_writer_closed(&self) -> bool {
285        self.writer_tx.is_closed()
286    }
287}
288
289impl Drop for ProcessHandle {
290    fn drop(&mut self) {
291        // Synchronous kill + task aborts, reusing the same body as
292        // `terminate_internal`. Both operations are non-blocking (`kill`
293        // signals the process group; `abort` flags the tasks), so Drop never
294        // parks the caller. A previous revision bridged this through a
295        // dedicated thread + Tokio runtime (`async_drop`) for zero async
296        // work — the thread/runtime only added spawn latency per Drop and
297        // blocked the dropping thread, which serialized shutdown storms
298        // (many handles dropped at registry teardown) and could stall a Tokio
299        // worker during TUI exit.
300        self.terminate_internal();
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    /// Operating-system process identifier for the direct child.
312    pub process_id: u32,
313    /// Receiver for stdout/stderr output chunks.
314    pub output_rx: broadcast::Receiver<Bytes>,
315    /// Bounded, lossless receiver for consumers that must spool complete
316    /// output. Unlike `output_rx`, this channel applies backpressure to the
317    /// child-process readers instead of dropping lagged chunks.
318    pub reliable_output_rx: mpsc::Receiver<Bytes>,
319    /// Whether the producer is connected to `reliable_output_rx`.
320    pub(crate) reliable_output_enabled: bool,
321    /// Receiver for exit code (receives once when process exits).
322    pub exit_rx: oneshot::Receiver<i32>,
323}
324
325impl SpawnedProcess {
326    /// Convenience method to wait for the process to exit and collect output.
327    ///
328    /// Returns (collected_output, exit_code).
329    pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
330        if self.reliable_output_enabled {
331            collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
332        } else {
333            collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
334        }
335    }
336}
337
338/// Collect all output from the bounded process stream until exit or timeout.
339async fn collect_reliable_output_until_exit(
340    mut output_rx: mpsc::Receiver<Bytes>,
341    exit_rx: oneshot::Receiver<i32>,
342    timeout_ms: u64,
343) -> (Vec<u8>, i32) {
344    let mut collected = Vec::new();
345    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
346    tokio::pin!(exit_rx);
347
348    loop {
349        tokio::select! {
350            chunk = output_rx.recv() => {
351                if let Some(chunk) = chunk {
352                    collected.extend_from_slice(&chunk);
353                } else {
354                    return (collected, exit_rx.await.unwrap_or(-1));
355                }
356            }
357            res = &mut exit_rx => {
358                let code = res.unwrap_or(-1);
359                // A descendant may inherit stdout/stderr after the direct
360                // child exits. Keep the lossless path bounded just like the
361                // compatibility broadcast path instead of waiting forever
362                // for an inherited pipe descriptor to close.
363                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
364                let max_deadline = tokio::time::Instant::now()
365                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
366                while tokio::time::Instant::now() < max_deadline {
367                    match tokio::time::timeout(quiet, output_rx.recv()).await {
368                        Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
369                        Ok(None) | Err(_) => break,
370                    }
371                }
372                return (collected, code);
373            }
374            _ = tokio::time::sleep_until(deadline) => {
375                return (collected, -1);
376            }
377        }
378    }
379}
380
381/// Collect output from a process until it exits or times out.
382///
383/// This is useful for tests and simple use cases where you want all output.
384pub async fn collect_output_until_exit(
385    mut output_rx: broadcast::Receiver<Bytes>,
386    exit_rx: oneshot::Receiver<i32>,
387    timeout_ms: u64,
388) -> (Vec<u8>, i32) {
389    let mut collected = Vec::new();
390    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
391    tokio::pin!(exit_rx);
392
393    loop {
394        tokio::select! {
395            res = output_rx.recv() => {
396                if let Ok(chunk) = res {
397                    collected.extend_from_slice(&chunk);
398                }
399            }
400            res = &mut exit_rx => {
401                let code = res.unwrap_or(-1);
402                // Drain remaining output briefly after exit
403                let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
404                let max_deadline = tokio::time::Instant::now()
405                    + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
406
407                while tokio::time::Instant::now() < max_deadline {
408                    match tokio::time::timeout(quiet, output_rx.recv()).await {
409                        Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
410                        Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
411                            vtcode_commons::diagnostics::stderr(format_args!("Warning: output stream lagged ({count} dropped)"));
412                            continue;
413                        }
414                        Ok(Err(broadcast::error::RecvError::Closed)) => break,
415                        Err(_) => break, // Timeout - quiet period reached
416                    }
417                }
418                return (collected, code);
419            }
420            _ = tokio::time::sleep_until(deadline) => {
421                return (collected, -1);
422            }
423        }
424    }
425}
426
427/// Backwards-compatible alias for ProcessHandle.
428pub type ExecCommandSession = ProcessHandle;
429
430/// Backwards-compatible alias for SpawnedProcess.
431pub type SpawnedPty = SpawnedProcess;
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    struct NoopTerminator;
438    impl ChildTerminator for NoopTerminator {
439        fn kill(&mut self) -> io::Result<()> {
440            Ok(())
441        }
442    }
443
444    #[tokio::test]
445    async fn test_process_handle_debug() {
446        // Just verify Debug impl doesn't panic
447        let exit_status = Arc::new(AtomicBool::new(false));
448        let exit_code = Arc::new(StdMutex::new(None));
449
450        let (writer_tx, _) = mpsc::channel(1);
451        let (output_tx, initial_rx) = broadcast::channel(1);
452
453        let (handle, _) = ProcessHandle::new(
454            writer_tx,
455            output_tx,
456            initial_rx,
457            Box::new(NoopTerminator),
458            tokio::spawn(async {}),
459            vec![],
460            tokio::spawn(async {}),
461            tokio::spawn(async {}),
462            exit_status,
463            Arc::new(AtomicBool::new(false)),
464            exit_code,
465            None,
466        );
467
468        let debug_str = format!("{handle:?}");
469        assert!(debug_str.contains("ProcessHandle"));
470    }
471
472    #[tokio::test]
473    async fn test_has_exited() {
474        let exit_status = Arc::new(AtomicBool::new(false));
475        let exit_code = Arc::new(StdMutex::new(None));
476
477        let (writer_tx, _) = mpsc::channel(1);
478        let (output_tx, initial_rx) = broadcast::channel(1);
479
480        let (handle, _) = ProcessHandle::new(
481            writer_tx,
482            output_tx,
483            initial_rx,
484            Box::new(NoopTerminator),
485            tokio::spawn(async {}),
486            vec![],
487            tokio::spawn(async {}),
488            tokio::spawn(async {}),
489            Arc::clone(&exit_status),
490            Arc::new(AtomicBool::new(false)),
491            exit_code,
492            None,
493        );
494
495        assert!(!handle.has_exited());
496        exit_status.store(true, Ordering::SeqCst);
497        assert!(handle.has_exited());
498        assert!(!handle.has_observed_exit(), "a completed wait is not necessarily a successful wait");
499    }
500
501    struct RecordingTerminator(Arc<AtomicBool>);
502    impl ChildTerminator for RecordingTerminator {
503        fn kill(&mut self) -> io::Result<()> {
504            self.0.store(true, Ordering::SeqCst);
505            Ok(())
506        }
507    }
508
509    struct FailOnceTerminator(Arc<std::sync::atomic::AtomicUsize>);
510    impl ChildTerminator for FailOnceTerminator {
511        fn kill(&mut self) -> io::Result<()> {
512            if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
513                Err(io::Error::other("simulated kill failure"))
514            } else {
515                Ok(())
516            }
517        }
518    }
519
520    #[tokio::test]
521    async fn failed_termination_retains_the_handle_for_retry() {
522        let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
523        let (writer_tx, _) = mpsc::channel(1);
524        let (output_tx, initial_rx) = broadcast::channel(1);
525        let (handle, _) = ProcessHandle::new(
526            writer_tx,
527            output_tx,
528            initial_rx,
529            Box::new(FailOnceTerminator(Arc::clone(&attempts))),
530            tokio::spawn(async {}),
531            vec![],
532            tokio::spawn(async {}),
533            tokio::spawn(async {}),
534            Arc::new(AtomicBool::new(false)),
535            Arc::new(AtomicBool::new(false)),
536            Arc::new(StdMutex::new(None)),
537            None,
538        );
539        assert!(handle.try_terminate_process().is_err());
540        assert!(handle.try_terminate_process().is_ok());
541        assert!(handle.try_terminate_process().is_ok());
542        assert_eq!(attempts.load(Ordering::SeqCst), 2);
543    }
544
545    /// Drop must kill the child and abort helper tasks synchronously, without
546    /// spawning a bridging thread/runtime (regression: a previous revision
547    /// bridged Drop through `async_drop`, adding spawn latency and blocking
548    /// the dropping thread during shutdown storms).
549    #[tokio::test]
550    async fn drop_kills_child_synchronously_without_bridging_runtime() {
551        let killed = Arc::new(AtomicBool::new(false));
552        let exit_status = Arc::new(AtomicBool::new(false));
553        let exit_code = Arc::new(StdMutex::new(None));
554        let (writer_tx, _) = mpsc::channel(1);
555        let (output_tx, initial_rx) = broadcast::channel(1);
556
557        // Tasks that never complete; Drop must abort them rather than wait.
558        let (handle, _) = ProcessHandle::new(
559            writer_tx,
560            output_tx,
561            initial_rx,
562            Box::new(RecordingTerminator(Arc::clone(&killed))),
563            tokio::spawn(std::future::pending()),
564            vec![],
565            tokio::spawn(std::future::pending()),
566            tokio::spawn(std::future::pending()),
567            exit_status,
568            Arc::new(AtomicBool::new(false)),
569            exit_code,
570            None,
571        );
572
573        let started = std::time::Instant::now();
574        drop(handle);
575        assert!(killed.load(Ordering::SeqCst), "Drop must kill the child");
576        assert!(started.elapsed() < std::time::Duration::from_millis(250), "Drop must not block on async cleanup");
577    }
578}