Skip to main content

agent_engine/tools/shell/
pty.rs

1//! PTY abstraction — spawn processes on a pseudo-terminal, async read/write.
2//!
3//! Wraps `portable-pty` to provide an async-friendly handle with:
4//! - Spawning commands on a PTY (with cwd, env, size)
5//! - Non-blocking reads via a background reader thread + mpsc channel
6//! - Synchronous writes to the PTY master
7//! - Resize support
8//! - Alive-check and graceful cleanup on Drop
9
10use std::collections::HashMap;
11use std::io::Write;
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::Duration;
15
16use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, MasterPty, PtySize};
17use tokio::sync::mpsc;
18use tokio::task::JoinHandle;
19
20use crate::{Result, RuntimeError};
21
22const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 64;
23const PTY_OUTPUT_MAX_CHUNK_BYTES: usize = 4096;
24static PTY_PRODUCED_BYTES: AtomicU64 = AtomicU64::new(0);
25static PTY_ACCEPTED_BYTES: AtomicU64 = AtomicU64::new(0);
26static PTY_CONSUMED_BYTES: AtomicU64 = AtomicU64::new(0);
27static PTY_DROPPED_BYTES: AtomicU64 = AtomicU64::new(0);
28static PTY_ACTIVE_READERS: AtomicU64 = AtomicU64::new(0);
29
30/// Test-only preemption seam (fix1 I2c): milliseconds the reader thread
31/// sleeps between a successful chunk handoff and the accounting update
32/// that follows — deterministically reproducing the scheduler preemption
33/// that let a Drop run in that gap and leak the chunk's accounting.
34#[cfg(test)]
35static TEST_PAUSE_AFTER_SEND_MS: AtomicU64 = AtomicU64::new(0);
36
37/// Per-handle output accounting with EXACT-ONCE final release (fix1 I2c).
38///
39/// Two teardown parties share this state: the blocking reader thread and
40/// the `PtyHandle`. Each calls [`PtyAccounting::finish_party`] exactly once
41/// when it is done mutating its counters (the reader after its final loop
42/// iteration — panic-safe via a Drop guard; the handle at the top of its
43/// own Drop, after which `consumed` can never grow because draining needs
44/// `&mut self`). Whichever party finishes LAST observes final counters and
45/// releases the remainder (`produced − consumed − dropped`) into the
46/// global dropped gauge exactly once — so no interleaving of a Drop with
47/// an in-flight handoff can leak retained bytes.
48struct PtyAccounting {
49    produced: AtomicU64,
50    consumed: AtomicU64,
51    dropped: AtomicU64,
52    /// Countdown of unfinished parties (reader thread + handle).
53    parties: AtomicU64,
54}
55
56impl PtyAccounting {
57    fn new() -> Arc<Self> {
58        Arc::new(Self {
59            produced: AtomicU64::new(0),
60            consumed: AtomicU64::new(0),
61            dropped: AtomicU64::new(0),
62            parties: AtomicU64::new(2),
63        })
64    }
65
66    fn add_produced(&self, n: u64) {
67        self.produced.fetch_add(n, Ordering::Relaxed);
68        PTY_PRODUCED_BYTES.fetch_add(n, Ordering::Relaxed);
69    }
70
71    fn add_consumed(&self, n: u64) {
72        self.consumed.fetch_add(n, Ordering::Relaxed);
73        PTY_CONSUMED_BYTES.fetch_add(n, Ordering::Relaxed);
74    }
75
76    fn add_dropped(&self, n: u64) {
77        self.dropped.fetch_add(n, Ordering::Relaxed);
78        PTY_DROPPED_BYTES.fetch_add(n, Ordering::Relaxed);
79    }
80
81    /// Mark one party finished. The LAST party (AcqRel countdown makes the
82    /// other party's counter updates visible) releases the unaccounted
83    /// remainder exactly once.
84    fn finish_party(&self) {
85        if self.parties.fetch_sub(1, Ordering::AcqRel) == 1 {
86            let produced = self.produced.load(Ordering::Acquire);
87            let consumed = self.consumed.load(Ordering::Acquire);
88            let dropped = self.dropped.load(Ordering::Acquire);
89            let remainder = produced.saturating_sub(consumed).saturating_sub(dropped);
90            if remainder > 0 {
91                self.add_dropped(remainder);
92            }
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy)]
98pub struct PtyOutputSnapshot {
99    pub produced_bytes: u64,
100    /// Chunks/bytes accepted into the bounded handoff. This is not consumer
101    /// delivery; queued bytes remain retained until `try_read_output` drains.
102    pub accepted_bytes: u64,
103    pub dropped_bytes: u64,
104    pub retained_bytes: u64,
105    pub active_readers: u64,
106}
107
108pub fn pty_output_snapshot() -> PtyOutputSnapshot {
109    let produced = PTY_PRODUCED_BYTES.load(Ordering::Relaxed);
110    let accepted = PTY_ACCEPTED_BYTES.load(Ordering::Relaxed);
111    let consumed = PTY_CONSUMED_BYTES.load(Ordering::Relaxed);
112    let dropped = PTY_DROPPED_BYTES.load(Ordering::Relaxed);
113    PtyOutputSnapshot {
114        produced_bytes: produced,
115        accepted_bytes: accepted,
116        dropped_bytes: dropped,
117        retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
118        active_readers: PTY_ACTIVE_READERS.load(Ordering::SeqCst),
119    }
120}
121
122/// Async-friendly wrapper around a PTY master/child pair.
123///
124/// The reader runs on a blocking Tokio thread and pushes raw byte chunks
125/// into an unbounded mpsc channel. Consumers drain the channel via
126/// `try_read_output()`.
127pub struct PtyHandle {
128    /// Master PTY — retained for resize operations.
129    master: Box<dyn MasterPty + Send>,
130    /// Writer end of the PTY master (bytes written here reach the child's stdin).
131    writer: Box<dyn Write + Send>,
132    /// Handle to the blocking reader task (for cleanup tracking).
133    _reader_task: JoinHandle<()>,
134    /// Receiving end of the output channel fed by the reader task.
135    output_rx: mpsc::Receiver<Vec<u8>>,
136    /// Per-handle accounting; the remainder is released EXACTLY ONCE by
137    /// the last of {reader thread, handle drop} (see [`PtyAccounting`]).
138    accounting: Arc<PtyAccounting>,
139    /// Child process handle — used for try_wait / kill.
140    child: Box<dyn Child + Send + Sync>,
141    /// Cached alive flag — once the child exits, this stays false.
142    alive: Arc<AtomicBool>,
143    /// Separate killer handle so Drop can kill even if child is borrowed.
144    killer: Box<dyn ChildKiller + Send + Sync>,
145}
146
147impl PtyHandle {
148    /// Spawn a command on a new PTY.
149    ///
150    /// # Arguments
151    /// * `command` — the program (and optional arguments) to run, e.g. `"bash"` or `"ssh user@host"`.
152    /// * `working_dir` — optional working directory for the child process.
153    /// * `env` — additional environment variables (merged on top of inherited env).
154    /// * `rows` / `cols` — initial terminal dimensions.
155    pub fn spawn(
156        command: &str,
157        working_dir: Option<&str>,
158        env: HashMap<String, String>,
159        rows: u16,
160        cols: u16,
161    ) -> Result<Self> {
162        // 1. Open a PTY pair with the requested size.
163        let pty_system = native_pty_system();
164        let pair = pty_system
165            .openpty(PtySize {
166                rows,
167                cols,
168                pixel_width: 0,
169                pixel_height: 0,
170            })
171            .map_err(|e| RuntimeError::Tool(format!("Failed to open PTY: {e}")))?;
172
173        // 2. Build the command.
174        //    We split on whitespace for simple cases ("bash -l", "ssh user@host").
175        let parts: Vec<&str> = command.split_whitespace().collect();
176        let program = parts
177            .first()
178            .ok_or_else(|| RuntimeError::Tool("Empty command string".to_string()))?;
179        let mut cmd = CommandBuilder::new(program);
180        for arg in parts.iter().skip(1) {
181            cmd.arg(arg);
182        }
183
184        // Set working directory if provided.
185        if let Some(dir) = working_dir {
186            cmd.cwd(dir);
187        }
188
189        // Inject environment variables; always set TERM.
190        cmd.env("TERM", "xterm-256color");
191        for (k, v) in &env {
192            cmd.env(k, v);
193        }
194
195        // 3. Spawn the child on the slave side.
196        let child = pair
197            .slave
198            .spawn_command(cmd)
199            .map_err(|e| RuntimeError::Tool(format!("Failed to spawn command: {e}")))?;
200
201        // Drop the slave — the child process owns its end now.
202        drop(pair.slave);
203
204        // 4. Obtain writer and reader from the master.
205        let writer = pair
206            .master
207            .take_writer()
208            .map_err(|e| RuntimeError::Tool(format!("Failed to take PTY writer: {e}")))?;
209
210        let mut reader = pair
211            .master
212            .try_clone_reader()
213            .map_err(|e| RuntimeError::Tool(format!("Failed to clone PTY reader: {e}")))?;
214
215        // 5. Spawn a blocking reader task that pushes chunks into the channel.
216        let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
217        let accounting = PtyAccounting::new();
218        let reader_accounting = Arc::clone(&accounting);
219        let alive = Arc::new(AtomicBool::new(true));
220        let reader_alive = alive.clone();
221
222        PTY_ACTIVE_READERS.fetch_add(1, Ordering::SeqCst);
223        let reader_task = tokio::task::spawn_blocking(move || {
224            /// Runs on EVERY reader exit path (return or panic): finishes
225            /// the reader's accounting party AFTER its final counter
226            /// updates, then drops the active-reader gauge.
227            struct ReaderGauge(Arc<PtyAccounting>);
228            impl Drop for ReaderGauge {
229                fn drop(&mut self) {
230                    self.0.finish_party();
231                    PTY_ACTIVE_READERS.fetch_sub(1, Ordering::SeqCst);
232                }
233            }
234            let _gauge = ReaderGauge(Arc::clone(&reader_accounting));
235            let mut buf = [0u8; PTY_OUTPUT_MAX_CHUNK_BYTES];
236            loop {
237                match reader.read(&mut buf) {
238                    Ok(0) => {
239                        // EOF — child closed its side.
240                        break;
241                    }
242                    Ok(n) => {
243                        reader_accounting.add_produced(n as u64);
244                        match output_tx.blocking_send(buf[..n].to_vec()) {
245                            Ok(()) => {
246                                #[cfg(test)]
247                                {
248                                    let ms = TEST_PAUSE_AFTER_SEND_MS.load(Ordering::Relaxed);
249                                    if ms > 0 {
250                                        std::thread::sleep(Duration::from_millis(ms));
251                                    }
252                                }
253                                PTY_ACCEPTED_BYTES.fetch_add(n as u64, Ordering::Relaxed);
254                            }
255                            Err(_) => {
256                                reader_accounting.add_dropped(n as u64);
257                                break;
258                            }
259                        }
260                    }
261                    Err(_) => {
262                        // Read error (child exited, fd closed, etc.) — exit cleanly.
263                        break;
264                    }
265                }
266            }
267            reader_alive.store(false, Ordering::SeqCst);
268        });
269
270        // 6. Clone a killer for Drop usage.
271        let killer = child.clone_killer();
272
273        Ok(PtyHandle {
274            master: pair.master,
275            writer,
276            _reader_task: reader_task,
277            output_rx,
278            accounting,
279            child,
280            alive,
281            killer,
282        })
283    }
284
285    /// Write raw bytes to the PTY (reaches the child's stdin).
286    pub fn write(&mut self, input: &[u8]) -> Result<()> {
287        self.writer
288            .write_all(input)
289            .map_err(|e| RuntimeError::Tool(format!("PTY write failed: {e}")))?;
290        self.writer
291            .flush()
292            .map_err(|e| RuntimeError::Tool(format!("PTY flush failed: {e}")))?;
293        Ok(())
294    }
295
296    /// Read all available output from the PTY, waiting up to `timeout` for data.
297    ///
298    /// Behavior:
299    /// 1. Drain everything currently in the channel (non-blocking).
300    /// 2. If nothing was found, wait up to `timeout` for the first chunk.
301    /// 3. After getting something (or timing out), drain any remaining buffered data.
302    ///
303    /// Returns an empty `Vec` if no data arrived within the timeout.
304    pub async fn try_read_output(&mut self, timeout: Duration) -> Vec<u8> {
305        let mut collected = Vec::new();
306
307        // Phase 1: non-blocking drain of everything already queued.
308        while let Ok(chunk) = self.output_rx.try_recv() {
309            self.accounting.add_consumed(chunk.len() as u64);
310            collected.extend_from_slice(&chunk);
311        }
312
313        // Phase 2: if we got nothing, wait up to `timeout` for at least one chunk.
314        if collected.is_empty() {
315            match tokio::time::timeout(timeout, self.output_rx.recv()).await {
316                Ok(Some(chunk)) => {
317                    self.accounting.add_consumed(chunk.len() as u64);
318                    collected.extend_from_slice(&chunk);
319                }
320                Ok(None) | Err(_) => {
321                    // Channel closed or timeout — return whatever we have (empty).
322                    return collected;
323                }
324            }
325
326            // Phase 3: drain any additional chunks that arrived while we waited.
327            while let Ok(chunk) = self.output_rx.try_recv() {
328                self.accounting.add_consumed(chunk.len() as u64);
329                collected.extend_from_slice(&chunk);
330            }
331        }
332
333        collected
334    }
335
336    /// Resize the PTY to new dimensions.
337    pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
338        self.master
339            .resize(PtySize {
340                rows,
341                cols,
342                pixel_width: 0,
343                pixel_height: 0,
344            })
345            .map_err(|e| RuntimeError::Tool(format!("PTY resize failed: {e}")))
346    }
347
348    /// Check whether the child process is still running.
349    ///
350    /// Once the child exits, subsequent calls return `false` without syscalls.
351    pub fn is_alive(&mut self) -> bool {
352        if !self.alive.load(Ordering::SeqCst) {
353            return false;
354        }
355        match self.child.try_wait() {
356            Ok(Some(_status)) => {
357                // Child exited.
358                self.alive.store(false, Ordering::SeqCst);
359                false
360            }
361            Ok(None) => true,
362            Err(_) => {
363                // If we can't query, assume dead.
364                self.alive.store(false, Ordering::SeqCst);
365                false
366            }
367        }
368    }
369}
370
371impl Drop for PtyHandle {
372    fn drop(&mut self) {
373        // Finish the HANDLE's accounting party (fix1 I2c). `consumed` can
374        // never grow after this point (draining needs `&mut self`), so if
375        // the reader has already finished, the release happens here with
376        // final counters; if the reader is still running — even mid-handoff
377        // — IT becomes the last party and releases after its own final
378        // updates. Either way: exactly once, nothing leaked.
379        self.accounting.finish_party();
380        if self.alive.load(Ordering::SeqCst) {
381            let _ = self.killer.kill();
382            // Reap the child after kill — without a wait(), the SIGKILL'd
383            // child lingers as a zombie until the parent process exits.
384            // SIGKILL takes effect near-instantly; bound the reap attempts
385            // so Drop can never hang on a pathological child.
386            for _ in 0..5 {
387                match self.child.try_wait() {
388                    Ok(Some(_)) | Err(_) => break, // reaped (or unreapable)
389                    Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
390                }
391            }
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use serial_test::serial;
400    use std::collections::HashMap;
401
402    /// A fast PTY producer with a deliberately stalled consumer is bounded
403    /// at the reader-thread handoff, and dropping the handle releases it.
404    #[tokio::test]
405    #[serial]
406    async fn pty_slow_consumer_retention_is_bounded_and_drop_releases_reader() {
407        let before = pty_output_snapshot();
408        let handle = PtyHandle::spawn(
409            "bash -c 'yes x | head -c 10485760'",
410            None,
411            HashMap::new(),
412            24,
413            80,
414        )
415        .expect("spawn producer");
416        tokio::time::sleep(Duration::from_millis(100)).await;
417        let stalled = pty_output_snapshot();
418        let retained = stalled.retained_bytes.saturating_sub(before.retained_bytes);
419        assert!(
420            retained > 0,
421            "stalled consumer must retain queued PTY bytes"
422        );
423        assert!(
424            retained <= ((PTY_OUTPUT_CHANNEL_CAPACITY + 1) * PTY_OUTPUT_MAX_CHUNK_BYTES) as u64,
425            "retained {retained}"
426        );
427        let readers_before = before.active_readers;
428        drop(handle);
429        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
430        while pty_output_snapshot().active_readers > readers_before
431            && tokio::time::Instant::now() < deadline
432        {
433            tokio::time::sleep(Duration::from_millis(10)).await;
434        }
435        assert!(pty_output_snapshot().active_readers <= readers_before);
436        assert_eq!(
437            pty_output_snapshot().retained_bytes,
438            before.retained_bytes,
439            "drop must conserve queued bytes as cancellation drops"
440        );
441    }
442
443    /// fix1 I2c: dropping a handle at ANY point relative to the reader
444    /// thread must conserve accounting exactly once — bytes accepted after
445    /// Drop's snapshot were previously never released, leaking retained
446    /// bytes. Repeated immediate drops of a flooding producer make that
447    /// window land reliably.
448    #[tokio::test]
449    #[serial]
450    async fn pty_drop_races_reader_and_always_conserves_accounting() {
451        let before = pty_output_snapshot();
452        for _ in 0..5 {
453            let mut handle = PtyHandle::spawn(
454                "bash -c 'yes x | head -c 33554432'",
455                None,
456                HashMap::new(),
457                24,
458                80,
459            )
460            .expect("spawn producer");
461            // Drain some output so the channel has free capacity, then drop
462            // while the producer is still mid-stream: the reader keeps
463            // accepting chunks during Drop's kill/reap window — every one
464            // of them must still be released exactly once.
465            let _ = handle.try_read_output(Duration::from_millis(50)).await;
466            drop(handle);
467        }
468        // Wait for every reader to finish, then the books must balance
469        // EXACTLY — no leaked retained bytes from any drop/reader race.
470        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
471        while pty_output_snapshot().active_readers > before.active_readers
472            && tokio::time::Instant::now() < deadline
473        {
474            tokio::time::sleep(Duration::from_millis(10)).await;
475        }
476        assert_eq!(
477            pty_output_snapshot().active_readers,
478            before.active_readers,
479            "every reader must terminate"
480        );
481        assert_eq!(
482            pty_output_snapshot().retained_bytes,
483            before.retained_bytes,
484            "30 drop/reader races must conserve queued-byte accounting exactly"
485        );
486    }
487
488    /// fix1 I2c (deterministic): the reader is paused IN the gap between
489    /// a successful handoff and its accounting update while the handle is
490    /// dropped — exactly the preemption the parallel suite produced. The
491    /// chunk must still be released exactly once after the reader resumes.
492    #[tokio::test]
493    #[serial]
494    async fn pty_preempted_accounting_across_drop_is_released_exactly_once() {
495        let before = pty_output_snapshot();
496        TEST_PAUSE_AFTER_SEND_MS.store(150, Ordering::Relaxed);
497        let handle = PtyHandle::spawn("bash -c 'echo leak-window'", None, HashMap::new(), 24, 80)
498            .expect("spawn producer");
499        // Let the reader reach the pause with one chunk handed off.
500        tokio::time::sleep(Duration::from_millis(40)).await;
501        drop(handle); // runs entirely inside the reader's paused gap
502        TEST_PAUSE_AFTER_SEND_MS.store(0, Ordering::Relaxed);
503
504        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
505        while pty_output_snapshot().active_readers > before.active_readers
506            && tokio::time::Instant::now() < deadline
507        {
508            tokio::time::sleep(Duration::from_millis(10)).await;
509        }
510        assert_eq!(
511            pty_output_snapshot().active_readers,
512            before.active_readers,
513            "reader must terminate"
514        );
515        assert_eq!(
516            pty_output_snapshot().retained_bytes,
517            before.retained_bytes,
518            "a drop inside the accounting gap must not leak retained bytes"
519        );
520    }
521
522    /// fix1 I2c: concurrent handles (spawned and dropped from parallel
523    /// tasks at varied points) each release their remainder exactly once —
524    /// per-handle scope, no cross-handle interference, exact global
525    /// conservation after all readers exit.
526    #[tokio::test]
527    #[serial]
528    async fn pty_concurrent_handles_release_exactly_once_per_handle() {
529        let before = pty_output_snapshot();
530        let mut tasks = Vec::new();
531        for i in 0..8 {
532            tasks.push(tokio::spawn(async move {
533                let mut handle = PtyHandle::spawn(
534                    "bash -c 'yes x | head -c 262144'",
535                    None,
536                    HashMap::new(),
537                    24,
538                    80,
539                )
540                .expect("spawn producer");
541                match i % 3 {
542                    0 => {} // immediate drop
543                    1 => {
544                        tokio::time::sleep(Duration::from_millis(5)).await;
545                    }
546                    _ => {
547                        // Consume a little first, then drop mid-stream.
548                        let _ = handle.try_read_output(Duration::from_millis(10)).await;
549                    }
550                }
551                drop(handle);
552            }));
553        }
554        for t in tasks {
555            t.await.unwrap();
556        }
557        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
558        while pty_output_snapshot().active_readers > before.active_readers
559            && tokio::time::Instant::now() < deadline
560        {
561            tokio::time::sleep(Duration::from_millis(10)).await;
562        }
563        assert_eq!(
564            pty_output_snapshot().active_readers,
565            before.active_readers,
566            "every reader must terminate"
567        );
568        assert_eq!(
569            pty_output_snapshot().retained_bytes,
570            before.retained_bytes,
571            "concurrent handles must conserve accounting exactly once each"
572        );
573    }
574
575    #[tokio::test]
576    #[serial]
577    async fn test_spawn_echo_hello() {
578        let mut handle = PtyHandle::spawn("echo hello", None, HashMap::new(), 24, 80)
579            .expect("failed to spawn echo");
580
581        // Give the process time to produce output and exit.
582        let output = handle.try_read_output(Duration::from_secs(3)).await;
583
584        let text = String::from_utf8_lossy(&output);
585        assert!(
586            text.contains("hello"),
587            "expected 'hello' in output, got: {text:?}"
588        );
589    }
590
591    #[tokio::test]
592    #[serial]
593    async fn test_cat_echo_back() {
594        let mut handle =
595            PtyHandle::spawn("cat", None, HashMap::new(), 24, 80).expect("failed to spawn cat");
596
597        // Write input — cat will echo it back via the PTY.
598        handle.write(b"test\n").expect("write failed");
599
600        let output = handle.try_read_output(Duration::from_secs(3)).await;
601
602        let text = String::from_utf8_lossy(&output);
603        assert!(
604            text.contains("test"),
605            "expected 'test' in output, got: {text:?}"
606        );
607    }
608
609    #[tokio::test]
610    #[serial]
611    async fn test_exit_code_detection() {
612        let mut handle = PtyHandle::spawn("bash -c exit 42", None, HashMap::new(), 24, 80)
613            .expect("failed to spawn bash exit");
614
615        // Wait for the process to finish — read until EOF / timeout.
616        let _ = handle.try_read_output(Duration::from_secs(3)).await;
617
618        // Small additional delay to let try_wait catch up.
619        tokio::time::sleep(Duration::from_millis(200)).await;
620
621        assert!(!handle.is_alive(), "expected process to have exited");
622    }
623}