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