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
147type PreparedScope = (
148    Vec<(std::ffi::OsString, std::ffi::OsString)>,
149    Option<std::path::PathBuf>,
150    Option<crate::process_scope::ScopeLauncher>,
151);
152
153impl PtyHandle {
154    /// Spawn a command on a new PTY.
155    ///
156    /// # Arguments
157    /// * `command` — the program (and optional arguments) to run, e.g. `"bash"` or `"ssh user@host"`.
158    /// * `working_dir` — optional working directory for the child process.
159    /// * `env` — additional environment variables (merged on top of inherited env).
160    /// * `rows` / `cols` — initial terminal dimensions.
161    pub fn spawn(
162        command: &str,
163        working_dir: Option<&str>,
164        env: HashMap<String, String>,
165        rows: u16,
166        cols: u16,
167    ) -> Result<Self> {
168        let prepared = Self::prepare_scope(working_dir, &env);
169        Self::spawn_prepared(command, working_dir, rows, cols, prepared)
170    }
171
172    pub(crate) fn prepare_scope(working_dir: Option<&str>, env: &HashMap<String, String>) -> PreparedScope {
173        // Snapshot after TERM and user overrides; probe and child see exactly
174        // the same environment. This synchronous API may probe for up to 3 s.
175        let mut effective_env = crate::process_scope::inherited_env();
176        crate::process_scope::set_env(&mut effective_env, "TERM", "xterm-256color");
177        for (k, v) in env { crate::process_scope::set_env(&mut effective_env, k, v); }
178        let scope_cwd = working_dir.map(std::path::PathBuf::from)
179            .filter(|p| p.is_dir())
180            .or_else(|| effective_env.iter().find(|(k, _)| k == "HOME")
181                .map(|(_, v)| std::path::PathBuf::from(v)));
182        let launcher = scope_cwd.as_deref().and_then(|cwd|
183            crate::process_scope::launcher_for_effective_env(&effective_env, Some(cwd)));
184        (effective_env, scope_cwd, launcher)
185    }
186
187    pub(crate) fn spawn_prepared(
188        command: &str, working_dir: Option<&str>, rows: u16, cols: u16,
189        prepared: PreparedScope,
190    ) -> Result<Self> {
191        // 1. Open a PTY pair with the requested size.
192        let pty_system = native_pty_system();
193        let pair = pty_system
194            .openpty(PtySize {
195                rows,
196                cols,
197                pixel_width: 0,
198                pixel_height: 0,
199            })
200            .map_err(|e| RuntimeError::Tool(format!("Failed to open PTY: {e}")))?;
201
202        // 2. Build the command.
203        //    We split on whitespace for simple cases ("bash -l", "ssh user@host").
204        let parts: Vec<&str> = command.split_whitespace().collect();
205        let program = parts
206            .first()
207            .ok_or_else(|| RuntimeError::Tool("Empty command string".to_string()))?;
208        let (effective_env, scope_cwd, launcher) = prepared;
209        let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = {
210            let program: std::ffi::OsString = (*program).into();
211            let args = parts
212                .iter()
213                .skip(1)
214                .map(std::ffi::OsString::from)
215                .collect::<Vec<_>>();
216            match launcher {
217                Some(launcher) => launcher.wrap("synaps tool: shell", crate::process_scope::resolve_program(program, &effective_env, scope_cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
218                None => (program, args),
219            }
220        };
221        let mut cmd = CommandBuilder::new(program);
222        for arg in &args {
223            cmd.arg(arg);
224        }
225
226        // Set working directory if provided.
227        if let Some(dir) = working_dir {
228            cmd.cwd(dir);
229        }
230
231        cmd.env_clear();
232        for (k, v) in &effective_env { cmd.env(k, v); }
233
234        // 3. Spawn the child on the slave side.
235        let child = pair
236            .slave
237            .spawn_command(cmd)
238            .map_err(|e| RuntimeError::Tool(format!("Failed to spawn command: {e}")))?;
239
240        // Drop the slave — the child process owns its end now.
241        drop(pair.slave);
242
243        // 4. Obtain writer and reader from the master.
244        let writer = pair
245            .master
246            .take_writer()
247            .map_err(|e| RuntimeError::Tool(format!("Failed to take PTY writer: {e}")))?;
248
249        let mut reader = pair
250            .master
251            .try_clone_reader()
252            .map_err(|e| RuntimeError::Tool(format!("Failed to clone PTY reader: {e}")))?;
253
254        // 5. Spawn a blocking reader task that pushes chunks into the channel.
255        let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
256        let accounting = PtyAccounting::new();
257        let reader_accounting = Arc::clone(&accounting);
258        let alive = Arc::new(AtomicBool::new(true));
259        let reader_alive = alive.clone();
260
261        PTY_ACTIVE_READERS.fetch_add(1, Ordering::SeqCst);
262        let reader_task = tokio::task::spawn_blocking(move || {
263            /// Runs on EVERY reader exit path (return or panic): finishes
264            /// the reader's accounting party AFTER its final counter
265            /// updates, then drops the active-reader gauge.
266            struct ReaderGauge(Arc<PtyAccounting>);
267            impl Drop for ReaderGauge {
268                fn drop(&mut self) {
269                    self.0.finish_party();
270                    PTY_ACTIVE_READERS.fetch_sub(1, Ordering::SeqCst);
271                }
272            }
273            let _gauge = ReaderGauge(Arc::clone(&reader_accounting));
274            let mut buf = [0u8; PTY_OUTPUT_MAX_CHUNK_BYTES];
275            loop {
276                match reader.read(&mut buf) {
277                    Ok(0) => {
278                        // EOF — child closed its side.
279                        break;
280                    }
281                    Ok(n) => {
282                        reader_accounting.add_produced(n as u64);
283                        match output_tx.blocking_send(buf[..n].to_vec()) {
284                            Ok(()) => {
285                                #[cfg(test)]
286                                {
287                                    let ms = TEST_PAUSE_AFTER_SEND_MS.load(Ordering::Relaxed);
288                                    if ms > 0 {
289                                        std::thread::sleep(Duration::from_millis(ms));
290                                    }
291                                }
292                                PTY_ACCEPTED_BYTES.fetch_add(n as u64, Ordering::Relaxed);
293                            }
294                            Err(_) => {
295                                reader_accounting.add_dropped(n as u64);
296                                break;
297                            }
298                        }
299                    }
300                    Err(_) => {
301                        // Read error (child exited, fd closed, etc.) — exit cleanly.
302                        break;
303                    }
304                }
305            }
306            reader_alive.store(false, Ordering::SeqCst);
307        });
308
309        // 6. Clone a killer for Drop usage.
310        let killer = child.clone_killer();
311
312        Ok(PtyHandle {
313            master: pair.master,
314            writer,
315            _reader_task: reader_task,
316            output_rx,
317            accounting,
318            child,
319            alive,
320            killer,
321        })
322    }
323
324    /// Write raw bytes to the PTY (reaches the child's stdin).
325    pub fn write(&mut self, input: &[u8]) -> Result<()> {
326        self.writer
327            .write_all(input)
328            .map_err(|e| RuntimeError::Tool(format!("PTY write failed: {e}")))?;
329        self.writer
330            .flush()
331            .map_err(|e| RuntimeError::Tool(format!("PTY flush failed: {e}")))?;
332        Ok(())
333    }
334
335    /// Read all available output from the PTY, waiting up to `timeout` for data.
336    ///
337    /// Behavior:
338    /// 1. Drain everything currently in the channel (non-blocking).
339    /// 2. If nothing was found, wait up to `timeout` for the first chunk.
340    /// 3. After getting something (or timing out), drain any remaining buffered data.
341    ///
342    /// Returns an empty `Vec` if no data arrived within the timeout.
343    pub async fn try_read_output(&mut self, timeout: Duration) -> Vec<u8> {
344        let mut collected = Vec::new();
345
346        // Phase 1: non-blocking drain of everything already queued.
347        while let Ok(chunk) = self.output_rx.try_recv() {
348            self.accounting.add_consumed(chunk.len() as u64);
349            collected.extend_from_slice(&chunk);
350        }
351
352        // Phase 2: if we got nothing, wait up to `timeout` for at least one chunk.
353        if collected.is_empty() {
354            match tokio::time::timeout(timeout, self.output_rx.recv()).await {
355                Ok(Some(chunk)) => {
356                    self.accounting.add_consumed(chunk.len() as u64);
357                    collected.extend_from_slice(&chunk);
358                }
359                Ok(None) | Err(_) => {
360                    // Channel closed or timeout — return whatever we have (empty).
361                    return collected;
362                }
363            }
364
365            // Phase 3: drain any additional chunks that arrived while we waited.
366            while let Ok(chunk) = self.output_rx.try_recv() {
367                self.accounting.add_consumed(chunk.len() as u64);
368                collected.extend_from_slice(&chunk);
369            }
370        }
371
372        collected
373    }
374
375    /// Resize the PTY to new dimensions.
376    pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
377        self.master
378            .resize(PtySize {
379                rows,
380                cols,
381                pixel_width: 0,
382                pixel_height: 0,
383            })
384            .map_err(|e| RuntimeError::Tool(format!("PTY resize failed: {e}")))
385    }
386
387    /// Check whether the child process is still running.
388    ///
389    /// Once the child exits, subsequent calls return `false` without syscalls.
390    pub fn is_alive(&mut self) -> bool {
391        if !self.alive.load(Ordering::SeqCst) {
392            return false;
393        }
394        match self.child.try_wait() {
395            Ok(Some(_status)) => {
396                // Child exited.
397                self.alive.store(false, Ordering::SeqCst);
398                false
399            }
400            Ok(None) => true,
401            Err(_) => {
402                // If we can't query, assume dead.
403                self.alive.store(false, Ordering::SeqCst);
404                false
405            }
406        }
407    }
408}
409
410impl Drop for PtyHandle {
411    fn drop(&mut self) {
412        // Finish the HANDLE's accounting party (fix1 I2c). `consumed` can
413        // never grow after this point (draining needs `&mut self`), so if
414        // the reader has already finished, the release happens here with
415        // final counters; if the reader is still running — even mid-handoff
416        // — IT becomes the last party and releases after its own final
417        // updates. Either way: exactly once, nothing leaked.
418        self.accounting.finish_party();
419        if self.alive.load(Ordering::SeqCst) {
420            let _ = self.killer.kill();
421            // Reap the child after kill — without a wait(), the SIGKILL'd
422            // child lingers as a zombie until the parent process exits.
423            // SIGKILL takes effect near-instantly; bound the reap attempts
424            // so Drop can never hang on a pathological child.
425            for _ in 0..5 {
426                match self.child.try_wait() {
427                    Ok(Some(_)) | Err(_) => break, // reaped (or unreapable)
428                    Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
429                }
430            }
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use serial_test::serial;
439    use std::collections::HashMap;
440
441    /// A fast PTY producer with a deliberately stalled consumer is bounded
442    /// at the reader-thread handoff, and dropping the handle releases it.
443    /// A PTY shell gets its own systemd scope when the machine supports it
444    /// (S348): a long-lived shell running a memory-hungry build is then the
445    /// separate cgroup (not a memory cap or protection from ancestor OOM). It still has a working terminal.
446    #[cfg(target_os = "linux")]
447    #[tokio::test]
448    #[serial]
449    async fn pty_shell_gets_its_own_scope_when_available() {
450        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
451        let own = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
452        let before = pty_output_snapshot();
453        // Typed into an interactive shell, as the tool is used (the spawn
454        // command line is whitespace-split, so no quoted -c script here).
455        let mut handle = PtyHandle::spawn("bash --norc --noprofile", None, HashMap::new(), 24, 80)
456            .expect("spawn");
457        handle
458            .write(b"cat /proc/$$/cgroup; test -t 0 && echo tty=yes; exit\n")
459            .expect("write");
460        let mut out = Vec::new();
461        let deadline = std::time::Instant::now() + Duration::from_secs(5);
462        while std::time::Instant::now() < deadline
463            && !String::from_utf8_lossy(&out).contains("tty=yes\r")
464        {
465            out.extend(handle.try_read_output(Duration::from_millis(200)).await);
466        }
467        // Settle the process-global PTY accounting before the next #[serial]
468        // test snapshots it (same release wait as the retention tests).
469        drop(handle);
470        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
471        while (pty_output_snapshot().active_readers > before.active_readers
472            || pty_output_snapshot().retained_bytes != before.retained_bytes)
473            && tokio::time::Instant::now() < deadline
474        {
475            tokio::time::sleep(Duration::from_millis(10)).await;
476        }
477        let text = String::from_utf8_lossy(&out).replace('\r', "");
478        assert!(
479            text.contains("tty=yes"),
480            "the shell must still have a terminal: {text}"
481        );
482        // A real terminal prefixes lines with control sequences (bracketed
483        // paste etc.); take the cgroup path wherever it starts on its line.
484        let child = text
485            .lines()
486            .filter(|l| !l.contains("cat /proc/")) // the echoed command line
487            .find_map(|l| l.find("0::/").map(|i| l[i..].trim().to_string()))
488            .unwrap_or_default();
489        assert!(child.starts_with("0::/"), "no cgroup line in: {text:?}");
490        if crate::process_scope::launcher_for_env(None).is_some() {
491            assert_ne!(child, own.trim(), "{text}");
492            assert!(child.ends_with(".scope"), "{child}");
493        } else {
494            assert_eq!(child, own.trim(), "unwrapped fallback");
495        }
496    }
497
498    #[tokio::test]
499    #[serial]
500    async fn pty_slow_consumer_retention_is_bounded_and_drop_releases_reader() {
501        let before = pty_output_snapshot();
502        let handle = PtyHandle::spawn(
503            "bash -c 'yes x | head -c 10485760'",
504            None,
505            HashMap::new(),
506            24,
507            80,
508        )
509        .expect("spawn producer");
510        tokio::time::sleep(Duration::from_millis(100)).await;
511        let stalled = pty_output_snapshot();
512        let retained = stalled.retained_bytes.saturating_sub(before.retained_bytes);
513        assert!(
514            retained > 0,
515            "stalled consumer must retain queued PTY bytes"
516        );
517        assert!(
518            retained <= ((PTY_OUTPUT_CHANNEL_CAPACITY + 1) * PTY_OUTPUT_MAX_CHUNK_BYTES) as u64,
519            "retained {retained}"
520        );
521        let readers_before = before.active_readers;
522        drop(handle);
523        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
524        while pty_output_snapshot().active_readers > readers_before
525            && tokio::time::Instant::now() < deadline
526        {
527            tokio::time::sleep(Duration::from_millis(10)).await;
528        }
529        assert!(pty_output_snapshot().active_readers <= readers_before);
530        assert_eq!(
531            pty_output_snapshot().retained_bytes,
532            before.retained_bytes,
533            "drop must conserve queued bytes as cancellation drops"
534        );
535    }
536
537    /// fix1 I2c: dropping a handle at ANY point relative to the reader
538    /// thread must conserve accounting exactly once — bytes accepted after
539    /// Drop's snapshot were previously never released, leaking retained
540    /// bytes. Repeated immediate drops of a flooding producer make that
541    /// window land reliably.
542    #[tokio::test]
543    #[serial]
544    async fn pty_drop_races_reader_and_always_conserves_accounting() {
545        let before = pty_output_snapshot();
546        for _ in 0..5 {
547            let mut handle = PtyHandle::spawn(
548                "bash -c 'yes x | head -c 33554432'",
549                None,
550                HashMap::new(),
551                24,
552                80,
553            )
554            .expect("spawn producer");
555            // Drain some output so the channel has free capacity, then drop
556            // while the producer is still mid-stream: the reader keeps
557            // accepting chunks during Drop's kill/reap window — every one
558            // of them must still be released exactly once.
559            let _ = handle.try_read_output(Duration::from_millis(50)).await;
560            drop(handle);
561        }
562        // Wait for every reader to finish, then the books must balance
563        // EXACTLY — no leaked retained bytes from any drop/reader race.
564        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
565        while pty_output_snapshot().active_readers > before.active_readers
566            && tokio::time::Instant::now() < deadline
567        {
568            tokio::time::sleep(Duration::from_millis(10)).await;
569        }
570        assert_eq!(
571            pty_output_snapshot().active_readers,
572            before.active_readers,
573            "every reader must terminate"
574        );
575        assert_eq!(
576            pty_output_snapshot().retained_bytes,
577            before.retained_bytes,
578            "30 drop/reader races must conserve queued-byte accounting exactly"
579        );
580    }
581
582    /// fix1 I2c (deterministic): the reader is paused IN the gap between
583    /// a successful handoff and its accounting update while the handle is
584    /// dropped — exactly the preemption the parallel suite produced. The
585    /// chunk must still be released exactly once after the reader resumes.
586    #[tokio::test]
587    #[serial]
588    async fn pty_preempted_accounting_across_drop_is_released_exactly_once() {
589        let before = pty_output_snapshot();
590        TEST_PAUSE_AFTER_SEND_MS.store(150, Ordering::Relaxed);
591        let handle = PtyHandle::spawn("bash -c 'echo leak-window'", None, HashMap::new(), 24, 80)
592            .expect("spawn producer");
593        // Let the reader reach the pause with one chunk handed off.
594        tokio::time::sleep(Duration::from_millis(40)).await;
595        drop(handle); // runs entirely inside the reader's paused gap
596        TEST_PAUSE_AFTER_SEND_MS.store(0, Ordering::Relaxed);
597
598        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
599        while pty_output_snapshot().active_readers > before.active_readers
600            && tokio::time::Instant::now() < deadline
601        {
602            tokio::time::sleep(Duration::from_millis(10)).await;
603        }
604        assert_eq!(
605            pty_output_snapshot().active_readers,
606            before.active_readers,
607            "reader must terminate"
608        );
609        assert_eq!(
610            pty_output_snapshot().retained_bytes,
611            before.retained_bytes,
612            "a drop inside the accounting gap must not leak retained bytes"
613        );
614    }
615
616    /// fix1 I2c: concurrent handles (spawned and dropped from parallel
617    /// tasks at varied points) each release their remainder exactly once —
618    /// per-handle scope, no cross-handle interference, exact global
619    /// conservation after all readers exit.
620    #[tokio::test]
621    #[serial]
622    async fn pty_concurrent_handles_release_exactly_once_per_handle() {
623        let before = pty_output_snapshot();
624        let mut tasks = Vec::new();
625        for i in 0..8 {
626            tasks.push(tokio::spawn(async move {
627                let mut handle = PtyHandle::spawn(
628                    "bash -c 'yes x | head -c 262144'",
629                    None,
630                    HashMap::new(),
631                    24,
632                    80,
633                )
634                .expect("spawn producer");
635                match i % 3 {
636                    0 => {} // immediate drop
637                    1 => {
638                        tokio::time::sleep(Duration::from_millis(5)).await;
639                    }
640                    _ => {
641                        // Consume a little first, then drop mid-stream.
642                        let _ = handle.try_read_output(Duration::from_millis(10)).await;
643                    }
644                }
645                drop(handle);
646            }));
647        }
648        for t in tasks {
649            t.await.unwrap();
650        }
651        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
652        while pty_output_snapshot().active_readers > before.active_readers
653            && tokio::time::Instant::now() < deadline
654        {
655            tokio::time::sleep(Duration::from_millis(10)).await;
656        }
657        assert_eq!(
658            pty_output_snapshot().active_readers,
659            before.active_readers,
660            "every reader must terminate"
661        );
662        assert_eq!(
663            pty_output_snapshot().retained_bytes,
664            before.retained_bytes,
665            "concurrent handles must conserve accounting exactly once each"
666        );
667    }
668
669    #[tokio::test]
670    #[serial]
671    async fn test_spawn_echo_hello() {
672        let mut handle = PtyHandle::spawn("echo hello", None, HashMap::new(), 24, 80)
673            .expect("failed to spawn echo");
674
675        // Give the process time to produce output and exit.
676        let output = handle.try_read_output(Duration::from_secs(3)).await;
677
678        let text = String::from_utf8_lossy(&output);
679        assert!(
680            text.contains("hello"),
681            "expected 'hello' in output, got: {text:?}"
682        );
683    }
684
685    #[tokio::test]
686    #[serial]
687    async fn test_cat_echo_back() {
688        let mut handle =
689            PtyHandle::spawn("cat", None, HashMap::new(), 24, 80).expect("failed to spawn cat");
690
691        // Write input — cat will echo it back via the PTY.
692        handle.write(b"test\n").expect("write failed");
693
694        let output = handle.try_read_output(Duration::from_secs(3)).await;
695
696        let text = String::from_utf8_lossy(&output);
697        assert!(
698            text.contains("test"),
699            "expected 'test' in output, got: {text:?}"
700        );
701    }
702
703    #[tokio::test]
704    #[serial]
705    async fn test_exit_code_detection() {
706        let mut handle = PtyHandle::spawn("bash -c exit 42", None, HashMap::new(), 24, 80)
707            .expect("failed to spawn bash exit");
708
709        // Wait for the process to finish — read until EOF / timeout.
710        let _ = handle.try_read_output(Duration::from_secs(3)).await;
711
712        // Small additional delay to let try_wait catch up.
713        tokio::time::sleep(Duration::from_millis(200)).await;
714
715        assert!(!handle.is_alive(), "expected process to have exited");
716    }
717}