1use 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#[derive(Debug, Clone)]
82pub struct PtyReadOptions {
83 pub timeout: Duration,
84 pub settle: Duration,
85 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 #[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 #[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 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 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 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 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}