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