Skip to main content

orchestral_runtime/
pty_process.rs

1//! Run-scoped PTY process ownership for guarded Agent Tools.
2//!
3//! A PTY process is an execution resource, not an Agent Session. Handles are
4//! scoped by `RunId`; callers cannot address another Run's process by name.
5
6use std::collections::{BTreeMap, VecDeque};
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::sync::{Arc, Condvar, Mutex};
10use std::time::{Duration, Instant};
11
12use orchestral_core::agent_protocol::wire::RunId;
13use portable_pty::{native_pty_system, CommandBuilder, PtySize};
14use tokio_util::sync::CancellationToken;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct PtyProcessId(String);
18
19impl PtyProcessId {
20    pub fn new(value: impl Into<String>) -> Result<Self, PtyProcessError> {
21        let value = value.into();
22        if value.trim().is_empty() {
23            return Err(PtyProcessError::Invalid(
24                "PTY process ID must not be empty".to_owned(),
25            ));
26        }
27        Ok(Self(value))
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl std::fmt::Display for PtyProcessId {
36    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        formatter.write_str(self.as_str())
38    }
39}
40
41#[derive(Debug, Clone)]
42pub struct PtySpawnSpec {
43    pub run_id: RunId,
44    pub process_id: PtyProcessId,
45    pub program: String,
46    pub args: Vec<String>,
47    pub cwd: PathBuf,
48    pub environment: BTreeMap<String, String>,
49    pub rows: u16,
50    pub cols: u16,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PtyReadResult {
55    pub output: String,
56    pub dropped_bytes: u64,
57    pub alive: bool,
58    pub exit_code: Option<i32>,
59}
60
61#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum PtyProcessError {
64    #[error("invalid PTY operation: {0}")]
65    Invalid(String),
66    #[error("PTY process already exists in this Run: {0}")]
67    Conflict(PtyProcessId),
68    #[error("PTY process was not found in this Run: {0}")]
69    NotFound(PtyProcessId),
70    #[error("PTY process operation was cancelled")]
71    Cancelled,
72    #[error("PTY process manager state is unavailable")]
73    Unavailable,
74    #[error("PTY process I/O failed: {0}")]
75    Io(String),
76}
77
78type ProcessKey = (RunId, PtyProcessId);
79
80/// Bounded observation of a PTY without changing the process lifetime.
81#[derive(Debug, Clone)]
82pub struct PtyReadOptions {
83    pub timeout: Duration,
84    pub settle: Duration,
85    /// Return buffered output when the Host needs to process new input.
86    pub yield_requested: CancellationToken,
87}
88
89struct PtyOutputBuffer {
90    bytes: VecDeque<u8>,
91    dropped_bytes: u64,
92    generation: u64,
93    closed: bool,
94}
95
96impl PtyOutputBuffer {
97    fn new() -> Self {
98        Self {
99            bytes: VecDeque::new(),
100            dropped_bytes: 0,
101            generation: 0,
102            closed: false,
103        }
104    }
105
106    fn push_bounded(&mut self, bytes: &[u8], max_bytes: usize) {
107        for byte in bytes {
108            if self.bytes.len() == max_bytes {
109                self.bytes.pop_front();
110                self.dropped_bytes = self.dropped_bytes.saturating_add(1);
111            }
112            self.bytes.push_back(*byte);
113        }
114        self.generation = self.generation.saturating_add(1);
115    }
116
117    fn drain(&mut self) -> (Vec<u8>, u64) {
118        let bytes = self.bytes.drain(..).collect();
119        let dropped = std::mem::take(&mut self.dropped_bytes);
120        (bytes, dropped)
121    }
122}
123
124type SharedOutput = Arc<(Mutex<PtyOutputBuffer>, Condvar)>;
125
126struct PtyProcess {
127    writer: Option<Box<dyn Write + Send>>,
128    // Reader/writer clones own pipes on Windows, not the ConPTY itself.
129    #[cfg(windows)]
130    master: Option<Box<dyn portable_pty::MasterPty + Send>>,
131    child: Box<dyn portable_pty::Child + Send + Sync>,
132    #[cfg(unix)]
133    process_group_id: Option<u32>,
134    #[cfg(windows)]
135    job: crate::windows_process_job::ProcessJob,
136    output: SharedOutput,
137    last_activity: Instant,
138    reader_thread: Option<std::thread::JoinHandle<()>>,
139}
140
141impl PtyProcess {
142    fn spawn(spec: &PtySpawnSpec, max_output_bytes: usize) -> Result<Self, PtyProcessError> {
143        if spec.run_id.is_empty()
144            || spec.program.trim().is_empty()
145            || !spec.cwd.is_absolute()
146            || spec.rows == 0
147            || spec.cols == 0
148        {
149            return Err(PtyProcessError::Invalid(
150                "PTY spawn requires run/program/absolute cwd and positive dimensions".to_owned(),
151            ));
152        }
153        let pty_pair = native_pty_system()
154            .openpty(PtySize {
155                rows: spec.rows,
156                cols: spec.cols,
157                pixel_width: 0,
158                pixel_height: 0,
159            })
160            .map_err(|error| PtyProcessError::Io(error.to_string()))?;
161        let mut command = CommandBuilder::new(&spec.program);
162        command.args(&spec.args);
163        command.cwd(&spec.cwd);
164        command.env_clear();
165        for (key, value) in &spec.environment {
166            command.env(key, value);
167        }
168        #[allow(unused_mut)]
169        let mut child = pty_pair
170            .slave
171            .spawn_command(command)
172            .map_err(|error| PtyProcessError::Io(error.to_string()))?;
173        #[cfg(unix)]
174        let process_group_id = child.process_id();
175        #[cfg(windows)]
176        let job = match child
177            .as_raw_handle()
178            .ok_or_else(|| std::io::Error::other("PTY process handle unavailable"))
179            .and_then(crate::windows_process_job::ProcessJob::attach)
180        {
181            Ok(job) => job,
182            Err(error) => {
183                let _ = child.kill();
184                let _ = child.wait();
185                return Err(PtyProcessError::Io(format!(
186                    "could not supervise Windows PTY process tree: {error}"
187                )));
188            }
189        };
190        #[allow(unused_mut)]
191        let mut writer = pty_pair
192            .master
193            .take_writer()
194            .map_err(|error| PtyProcessError::Io(error.to_string()))?;
195        // portable-pty creates ConPTY with INHERIT_CURSOR. This new virtual
196        // terminal starts at row/column one; answer its startup cursor query
197        // before polling output, otherwise the child waits indefinitely.
198        #[cfg(windows)]
199        {
200            writer
201                .write_all(b"\x1b[1;1R")
202                .and_then(|()| writer.flush())
203                .map_err(|error| PtyProcessError::Io(error.to_string()))?;
204        }
205        let mut reader = pty_pair
206            .master
207            .try_clone_reader()
208            .map_err(|error| PtyProcessError::Io(error.to_string()))?;
209        let output = Arc::new((Mutex::new(PtyOutputBuffer::new()), Condvar::new()));
210        let reader_output = output.clone();
211        let reader_thread = std::thread::spawn(move || {
212            let mut chunk = [0_u8; 4096];
213            loop {
214                match reader.read(&mut chunk) {
215                    Ok(0) => break,
216                    Ok(count) => {
217                        let (buffer, changed) = &*reader_output;
218                        if let Ok(mut buffer) = buffer.lock() {
219                            buffer.push_bounded(&chunk[..count], max_output_bytes);
220                            changed.notify_all();
221                        } else {
222                            break;
223                        }
224                    }
225                    Err(_) => break,
226                }
227            }
228            let (buffer, changed) = &*reader_output;
229            if let Ok(mut buffer) = buffer.lock() {
230                buffer.closed = true;
231                buffer.generation = buffer.generation.saturating_add(1);
232                changed.notify_all();
233            }
234        });
235        Ok(Self {
236            writer: Some(writer),
237            #[cfg(windows)]
238            master: Some(pty_pair.master),
239            child,
240            #[cfg(unix)]
241            process_group_id,
242            #[cfg(windows)]
243            job,
244            output,
245            last_activity: Instant::now(),
246            reader_thread: Some(reader_thread),
247        })
248    }
249
250    fn send(&mut self, input: &str) -> Result<(), PtyProcessError> {
251        if input.is_empty() {
252            return Err(PtyProcessError::Invalid(
253                "PTY input must not be empty".to_owned(),
254            ));
255        }
256        if self
257            .child
258            .try_wait()
259            .map_err(|error| PtyProcessError::Io(error.to_string()))?
260            .is_some()
261        {
262            return Err(PtyProcessError::Io(
263                "PTY process has already exited".to_owned(),
264            ));
265        }
266        let writer = self
267            .writer
268            .as_mut()
269            .ok_or_else(|| PtyProcessError::Io("PTY writer is closed".to_owned()))?;
270        writer
271            .write_all(input.as_bytes())
272            .and_then(|_| writer.flush())
273            .map_err(|error| PtyProcessError::Io(error.to_string()))?;
274        self.last_activity = Instant::now();
275        Ok(())
276    }
277
278    fn status(&mut self) -> Result<Option<i32>, PtyProcessError> {
279        self.child
280            .try_wait()
281            .map(|status| status.map(|status| status.exit_code() as i32))
282            .map_err(|error| PtyProcessError::Io(error.to_string()))
283    }
284
285    fn terminate(&mut self) {
286        self.writer.take();
287        #[cfg(windows)]
288        self.job.terminate();
289        #[cfg(unix)]
290        if let Some(process_group_id) = self
291            .process_group_id
292            .filter(|process_group_id| *process_group_id <= i32::MAX as u32)
293        {
294            // portable-pty establishes the child as a session leader, so its
295            // PID is also the process-group ID. Kill the whole tree.
296            unsafe {
297                libc::kill(-(process_group_id as i32), libc::SIGKILL);
298            }
299        }
300        let _ = self.child.kill();
301        let _ = self.child.wait();
302        #[cfg(windows)]
303        self.master.take();
304        if let Some(reader_thread) = self.reader_thread.take() {
305            let _ = reader_thread.join();
306        }
307        let (buffer, changed) = &*self.output;
308        if let Ok(mut buffer) = buffer.lock() {
309            buffer.closed = true;
310            changed.notify_all();
311        }
312    }
313}
314
315impl Drop for PtyProcess {
316    fn drop(&mut self) {
317        self.terminate();
318    }
319}
320
321pub struct PtyProcessManager {
322    processes: Mutex<BTreeMap<ProcessKey, Arc<Mutex<PtyProcess>>>>,
323    max_output_bytes: usize,
324    idle_timeout: Duration,
325}
326
327impl PtyProcessManager {
328    pub fn new(max_output_bytes: usize, idle_timeout: Duration) -> Result<Self, PtyProcessError> {
329        if max_output_bytes == 0 || idle_timeout.is_zero() {
330            return Err(PtyProcessError::Invalid(
331                "PTY output and idle limits must be positive".to_owned(),
332            ));
333        }
334        Ok(Self {
335            processes: Mutex::new(BTreeMap::new()),
336            max_output_bytes,
337            idle_timeout,
338        })
339    }
340
341    pub fn create(&self, spec: PtySpawnSpec) -> Result<PtyProcessId, PtyProcessError> {
342        let key = (spec.run_id.clone(), spec.process_id.clone());
343        let mut processes = self
344            .processes
345            .lock()
346            .map_err(|_| PtyProcessError::Unavailable)?;
347        if processes.contains_key(&key) {
348            return Err(PtyProcessError::Conflict(spec.process_id));
349        }
350        let process = PtyProcess::spawn(&spec, self.max_output_bytes)?;
351        processes.insert(key, Arc::new(Mutex::new(process)));
352        Ok(spec.process_id)
353    }
354
355    pub fn send(
356        &self,
357        run_id: &RunId,
358        process_id: &PtyProcessId,
359        input: &str,
360    ) -> Result<(), PtyProcessError> {
361        let process = self.process(run_id, process_id)?;
362        let result = process
363            .lock()
364            .map_err(|_| PtyProcessError::Unavailable)?
365            .send(input);
366        result
367    }
368
369    pub fn status(
370        &self,
371        run_id: &RunId,
372        process_id: &PtyProcessId,
373    ) -> Result<Option<i32>, PtyProcessError> {
374        let process = self.process(run_id, process_id)?;
375        let status = process
376            .lock()
377            .map_err(|_| PtyProcessError::Unavailable)?
378            .status();
379        status
380    }
381
382    pub fn read(
383        &self,
384        run_id: &RunId,
385        process_id: &PtyProcessId,
386        timeout: Duration,
387        settle: Duration,
388        cancellation: &CancellationToken,
389    ) -> Result<PtyReadResult, PtyProcessError> {
390        self.read_with_options(
391            run_id,
392            process_id,
393            PtyReadOptions {
394                timeout,
395                settle,
396                yield_requested: CancellationToken::new(),
397            },
398            cancellation,
399        )
400    }
401
402    /// Observe buffered output with an optional cooperative Host yield signal.
403    /// Yielding returns the current buffer and leaves the PTY alive.
404    pub fn read_with_options(
405        &self,
406        run_id: &RunId,
407        process_id: &PtyProcessId,
408        options: PtyReadOptions,
409        cancellation: &CancellationToken,
410    ) -> Result<PtyReadResult, PtyProcessError> {
411        let PtyReadOptions {
412            timeout,
413            settle,
414            yield_requested,
415        } = options;
416        if timeout.is_zero() || settle.is_zero() {
417            return Err(PtyProcessError::Invalid(
418                "PTY read timeout and settle duration must be positive".to_owned(),
419            ));
420        }
421        let process = self.process(run_id, process_id)?;
422        let output = process
423            .lock()
424            .map_err(|_| PtyProcessError::Unavailable)?
425            .output
426            .clone();
427        let started = Instant::now();
428        let mut last_change = Instant::now();
429        let mut observed_generation = 0_u64;
430        let (buffer, changed) = &*output;
431        let mut buffer = buffer.lock().map_err(|_| PtyProcessError::Unavailable)?;
432        loop {
433            if cancellation.is_cancelled() {
434                return Err(PtyProcessError::Cancelled);
435            }
436            if buffer.generation != observed_generation {
437                observed_generation = buffer.generation;
438                last_change = Instant::now();
439            }
440            if buffer.closed {
441                // PTY EOF can precede an observable child exit. Do not hold the
442                // output lock while inspecting the process: termination takes
443                // the process lock before closing its output buffer.
444                drop(buffer);
445                let exited = process
446                    .lock()
447                    .map_err(|_| PtyProcessError::Unavailable)?
448                    .status()?
449                    .is_some();
450                buffer = output.0.lock().map_err(|_| PtyProcessError::Unavailable)?;
451                if exited {
452                    break;
453                }
454            }
455            if (!buffer.bytes.is_empty() && last_change.elapsed() >= settle)
456                || yield_requested.is_cancelled()
457                || started.elapsed() >= timeout
458            {
459                break;
460            }
461            let remaining = timeout.saturating_sub(started.elapsed());
462            let wait = remaining.min(Duration::from_millis(50));
463            let (next, _) = changed
464                .wait_timeout(buffer, wait)
465                .map_err(|_| PtyProcessError::Unavailable)?;
466            buffer = next;
467        }
468        let (raw, dropped_bytes) = buffer.drain();
469        drop(buffer);
470        let exit_code = process
471            .lock()
472            .map_err(|_| PtyProcessError::Unavailable)?
473            .status()?;
474        let output = strip_ansi_escapes::strip(&raw);
475        Ok(PtyReadResult {
476            output: String::from_utf8_lossy(&output).replace('\r', ""),
477            dropped_bytes,
478            alive: exit_code.is_none(),
479            exit_code,
480        })
481    }
482
483    pub fn close(&self, run_id: &RunId, process_id: &PtyProcessId) -> Result<(), PtyProcessError> {
484        let process = self
485            .processes
486            .lock()
487            .map_err(|_| PtyProcessError::Unavailable)?
488            .remove(&(run_id.clone(), process_id.clone()))
489            .ok_or_else(|| PtyProcessError::NotFound(process_id.clone()))?;
490        process
491            .lock()
492            .map_err(|_| PtyProcessError::Unavailable)?
493            .terminate();
494        Ok(())
495    }
496
497    pub fn list(&self, run_id: &RunId) -> Result<Vec<PtyProcessId>, PtyProcessError> {
498        let processes = self
499            .processes
500            .lock()
501            .map_err(|_| PtyProcessError::Unavailable)?;
502        Ok(processes
503            .keys()
504            .filter(|(owner, _)| owner == run_id)
505            .map(|(_, process_id)| process_id.clone())
506            .collect())
507    }
508
509    pub fn close_run(&self, run_id: &RunId) -> Result<usize, PtyProcessError> {
510        let owned = {
511            let mut processes = self
512                .processes
513                .lock()
514                .map_err(|_| PtyProcessError::Unavailable)?;
515            let keys = processes
516                .keys()
517                .filter(|(owner, _)| owner == run_id)
518                .cloned()
519                .collect::<Vec<_>>();
520            keys.into_iter()
521                .filter_map(|key| processes.remove(&key))
522                .collect::<Vec<_>>()
523        };
524        let count = owned.len();
525        for process in owned {
526            if let Ok(mut process) = process.lock() {
527                process.terminate();
528            }
529        }
530        Ok(count)
531    }
532
533    pub fn gc(&self) -> Result<usize, PtyProcessError> {
534        let expired = {
535            let processes = self
536                .processes
537                .lock()
538                .map_err(|_| PtyProcessError::Unavailable)?;
539            processes
540                .iter()
541                .filter_map(|(key, process)| {
542                    process
543                        .lock()
544                        .ok()
545                        .filter(|process| process.last_activity.elapsed() > self.idle_timeout)
546                        .map(|_| key.clone())
547                })
548                .collect::<Vec<_>>()
549        };
550        for (run_id, process_id) in &expired {
551            let _ = self.close(run_id, process_id);
552        }
553        Ok(expired.len())
554    }
555
556    fn process(
557        &self,
558        run_id: &RunId,
559        process_id: &PtyProcessId,
560    ) -> Result<Arc<Mutex<PtyProcess>>, PtyProcessError> {
561        self.processes
562            .lock()
563            .map_err(|_| PtyProcessError::Unavailable)?
564            .get(&(run_id.clone(), process_id.clone()))
565            .cloned()
566            .ok_or_else(|| PtyProcessError::NotFound(process_id.clone()))
567    }
568}
569
570impl Drop for PtyProcessManager {
571    fn drop(&mut self) {
572        if let Ok(mut processes) = self.processes.lock() {
573            for (_, process) in processes.iter_mut() {
574                if let Ok(mut process) = process.lock() {
575                    process.terminate();
576                }
577            }
578            processes.clear();
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn process_handles_are_run_scoped() {
589        let manager = PtyProcessManager::new(1024, Duration::from_secs(60)).unwrap();
590        let process_id = PtyProcessId::new("process-1").unwrap();
591        assert!(matches!(
592            manager.send(&RunId::new("another-run"), &process_id, "hello"),
593            Err(PtyProcessError::NotFound(_))
594        ));
595    }
596
597    #[cfg(unix)]
598    #[test]
599    fn closed_output_keeps_waiting_for_the_child_until_the_observation_deadline() {
600        let manager = PtyProcessManager::new(1024, Duration::from_secs(60)).unwrap();
601        let run_id = RunId::new("closed-output-run");
602        let process_id = PtyProcessId::new("closed-output-process").unwrap();
603        manager
604            .create(PtySpawnSpec {
605                run_id: run_id.clone(),
606                process_id: process_id.clone(),
607                program: std::fs::canonicalize("/bin/sh")
608                    .unwrap()
609                    .to_string_lossy()
610                    .into_owned(),
611                args: vec!["-c".to_owned(), "read reply; exit 7".to_owned()],
612                cwd: std::fs::canonicalize(".").unwrap(),
613                environment: BTreeMap::from([("PATH".to_owned(), "/usr/bin:/bin".to_owned())]),
614                rows: 24,
615                cols: 80,
616            })
617            .unwrap();
618        let process = manager.process(&run_id, &process_id).unwrap();
619        let output = process.lock().unwrap().output.clone();
620        // Drive the reader's EOF state independently of the real child. This
621        // isolates the interval before exit status is visible, without relying
622        // on the OS to schedule EOF and child reaping in a particular order.
623        output.0.lock().unwrap().closed = true;
624        assert_eq!(manager.status(&run_id, &process_id).unwrap(), None);
625
626        let deadline = Duration::from_millis(200);
627        let started = Instant::now();
628        let pending = manager
629            .read(
630                &run_id,
631                &process_id,
632                deadline,
633                deadline,
634                &CancellationToken::new(),
635            )
636            .unwrap();
637        assert!(started.elapsed() >= deadline, "EOF ended the wait early");
638        assert!(pending.alive);
639        assert_eq!(pending.exit_code, None);
640        assert!(pending.output.is_empty());
641        assert_eq!(manager.list(&run_id).unwrap(), vec![process_id.clone()]);
642
643        manager.send(&run_id, &process_id, "finish\n").unwrap();
644        let completed = manager
645            .read(
646                &run_id,
647                &process_id,
648                Duration::from_secs(5),
649                Duration::from_secs(5),
650                &CancellationToken::new(),
651            )
652            .unwrap();
653        assert!(!completed.alive);
654        assert_eq!(completed.exit_code, Some(7));
655        manager.close(&run_id, &process_id).unwrap();
656    }
657
658    #[cfg(unix)]
659    #[test]
660    fn create_write_read_and_close_are_bounded_by_the_owning_run() {
661        let manager = PtyProcessManager::new(4 * 1024, Duration::from_secs(60)).unwrap();
662        let run_id = RunId::new("run-1");
663        let process_id = PtyProcessId::new("process-1").unwrap();
664        let program = std::fs::canonicalize("/bin/cat").unwrap();
665        let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
666        manager
667            .create(PtySpawnSpec {
668                run_id: run_id.clone(),
669                process_id: process_id.clone(),
670                program: program.to_string_lossy().to_string(),
671                args: Vec::new(),
672                cwd,
673                environment: BTreeMap::new(),
674                rows: 24,
675                cols: 80,
676            })
677            .unwrap();
678
679        assert!(matches!(
680            manager.send(&RunId::new("run-2"), &process_id, "escape\n"),
681            Err(PtyProcessError::NotFound(_))
682        ));
683        manager.send(&run_id, &process_id, "hello\n").unwrap();
684        let read = manager
685            .read(
686                &run_id,
687                &process_id,
688                Duration::from_secs(2),
689                Duration::from_millis(50),
690                &CancellationToken::new(),
691            )
692            .unwrap();
693        assert!(read.output.contains("hello"));
694        assert_eq!(read.dropped_bytes, 0);
695        assert!(read.alive);
696        assert_eq!(manager.list(&run_id).unwrap(), vec![process_id.clone()]);
697        manager.close(&run_id, &process_id).unwrap();
698        assert!(manager.list(&run_id).unwrap().is_empty());
699    }
700
701    #[cfg(unix)]
702    #[test]
703    fn one_thousand_pty_read_cancellations_leave_no_read_handle_and_have_subsecond_p99() {
704        const CANCELLATION_CASES: usize = 1_000;
705
706        let manager = Arc::new(PtyProcessManager::new(4 * 1024, Duration::from_secs(60)).unwrap());
707        let run_id = RunId::new("pty-read-cancel-run");
708        let process_id = PtyProcessId::new("pty-read-cancel-process").unwrap();
709        manager
710            .create(PtySpawnSpec {
711                run_id: run_id.clone(),
712                process_id: process_id.clone(),
713                program: std::fs::canonicalize("/bin/cat")
714                    .unwrap()
715                    .to_string_lossy()
716                    .into_owned(),
717                args: Vec::new(),
718                cwd: std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(),
719                environment: BTreeMap::new(),
720                rows: 24,
721                cols: 80,
722            })
723            .unwrap();
724
725        const BATCH_SIZE: usize = 50;
726        let mut latencies = Vec::with_capacity(CANCELLATION_CASES);
727        for _ in 0..(CANCELLATION_CASES / BATCH_SIZE) {
728            let cancellation = CancellationToken::new();
729            let barrier = Arc::new(std::sync::Barrier::new(BATCH_SIZE + 1));
730            let mut readers = Vec::with_capacity(BATCH_SIZE);
731            for _ in 0..BATCH_SIZE {
732                let manager = manager.clone();
733                let run_id = run_id.clone();
734                let process_id = process_id.clone();
735                let cancellation = cancellation.clone();
736                let barrier = barrier.clone();
737                readers.push(std::thread::spawn(move || {
738                    barrier.wait();
739                    let result = manager.read(
740                        &run_id,
741                        &process_id,
742                        Duration::from_secs(60),
743                        Duration::from_secs(60),
744                        &cancellation,
745                    );
746                    (result, std::time::Instant::now())
747                }));
748            }
749            barrier.wait();
750            let cancelled_at = std::time::Instant::now();
751            cancellation.cancel();
752            for reader in readers {
753                let (result, finished_at) = reader.join().unwrap();
754                assert!(matches!(result, Err(PtyProcessError::Cancelled)));
755                latencies.push(finished_at.duration_since(cancelled_at));
756            }
757        }
758        latencies.sort_unstable();
759        let p99 = latencies[(CANCELLATION_CASES * 99 / 100).saturating_sub(1)];
760        assert!(
761            p99 <= Duration::from_secs(1),
762            "PTY read cancel p99 was {p99:?}"
763        );
764        assert_eq!(manager.list(&run_id).unwrap(), vec![process_id.clone()]);
765        manager.close(&run_id, &process_id).unwrap();
766        assert!(manager.list(&run_id).unwrap().is_empty());
767    }
768}