agent_engine/tools/shell/
pty.rs1use 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#[cfg(test)]
35static TEST_PAUSE_AFTER_SEND_MS: AtomicU64 = AtomicU64::new(0);
36
37struct PtyAccounting {
49 produced: AtomicU64,
50 consumed: AtomicU64,
51 dropped: AtomicU64,
52 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 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 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
122pub struct PtyHandle {
128 master: Box<dyn MasterPty + Send>,
130 writer: Box<dyn Write + Send>,
132 _reader_task: JoinHandle<()>,
134 output_rx: mpsc::Receiver<Vec<u8>>,
136 accounting: Arc<PtyAccounting>,
139 child: Box<dyn Child + Send + Sync>,
141 alive: Arc<AtomicBool>,
143 killer: Box<dyn ChildKiller + Send + Sync>,
145}
146
147impl PtyHandle {
148 pub fn spawn(
156 command: &str,
157 working_dir: Option<&str>,
158 env: HashMap<String, String>,
159 rows: u16,
160 cols: u16,
161 ) -> Result<Self> {
162 let pty_system = native_pty_system();
164 let pair = pty_system
165 .openpty(PtySize {
166 rows,
167 cols,
168 pixel_width: 0,
169 pixel_height: 0,
170 })
171 .map_err(|e| RuntimeError::Tool(format!("Failed to open PTY: {e}")))?;
172
173 let parts: Vec<&str> = command.split_whitespace().collect();
176 let program = parts
177 .first()
178 .ok_or_else(|| RuntimeError::Tool("Empty command string".to_string()))?;
179 let mut cmd = CommandBuilder::new(program);
180 for arg in parts.iter().skip(1) {
181 cmd.arg(arg);
182 }
183
184 if let Some(dir) = working_dir {
186 cmd.cwd(dir);
187 }
188
189 cmd.env("TERM", "xterm-256color");
191 for (k, v) in &env {
192 cmd.env(k, v);
193 }
194
195 let child = pair
197 .slave
198 .spawn_command(cmd)
199 .map_err(|e| RuntimeError::Tool(format!("Failed to spawn command: {e}")))?;
200
201 drop(pair.slave);
203
204 let writer = pair
206 .master
207 .take_writer()
208 .map_err(|e| RuntimeError::Tool(format!("Failed to take PTY writer: {e}")))?;
209
210 let mut reader = pair
211 .master
212 .try_clone_reader()
213 .map_err(|e| RuntimeError::Tool(format!("Failed to clone PTY reader: {e}")))?;
214
215 let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
217 let accounting = PtyAccounting::new();
218 let reader_accounting = Arc::clone(&accounting);
219 let alive = Arc::new(AtomicBool::new(true));
220 let reader_alive = alive.clone();
221
222 PTY_ACTIVE_READERS.fetch_add(1, Ordering::SeqCst);
223 let reader_task = tokio::task::spawn_blocking(move || {
224 struct ReaderGauge(Arc<PtyAccounting>);
228 impl Drop for ReaderGauge {
229 fn drop(&mut self) {
230 self.0.finish_party();
231 PTY_ACTIVE_READERS.fetch_sub(1, Ordering::SeqCst);
232 }
233 }
234 let _gauge = ReaderGauge(Arc::clone(&reader_accounting));
235 let mut buf = [0u8; PTY_OUTPUT_MAX_CHUNK_BYTES];
236 loop {
237 match reader.read(&mut buf) {
238 Ok(0) => {
239 break;
241 }
242 Ok(n) => {
243 reader_accounting.add_produced(n as u64);
244 match output_tx.blocking_send(buf[..n].to_vec()) {
245 Ok(()) => {
246 #[cfg(test)]
247 {
248 let ms = TEST_PAUSE_AFTER_SEND_MS.load(Ordering::Relaxed);
249 if ms > 0 {
250 std::thread::sleep(Duration::from_millis(ms));
251 }
252 }
253 PTY_ACCEPTED_BYTES.fetch_add(n as u64, Ordering::Relaxed);
254 }
255 Err(_) => {
256 reader_accounting.add_dropped(n as u64);
257 break;
258 }
259 }
260 }
261 Err(_) => {
262 break;
264 }
265 }
266 }
267 reader_alive.store(false, Ordering::SeqCst);
268 });
269
270 let killer = child.clone_killer();
272
273 Ok(PtyHandle {
274 master: pair.master,
275 writer,
276 _reader_task: reader_task,
277 output_rx,
278 accounting,
279 child,
280 alive,
281 killer,
282 })
283 }
284
285 pub fn write(&mut self, input: &[u8]) -> Result<()> {
287 self.writer
288 .write_all(input)
289 .map_err(|e| RuntimeError::Tool(format!("PTY write failed: {e}")))?;
290 self.writer
291 .flush()
292 .map_err(|e| RuntimeError::Tool(format!("PTY flush failed: {e}")))?;
293 Ok(())
294 }
295
296 pub async fn try_read_output(&mut self, timeout: Duration) -> Vec<u8> {
305 let mut collected = Vec::new();
306
307 while let Ok(chunk) = self.output_rx.try_recv() {
309 self.accounting.add_consumed(chunk.len() as u64);
310 collected.extend_from_slice(&chunk);
311 }
312
313 if collected.is_empty() {
315 match tokio::time::timeout(timeout, self.output_rx.recv()).await {
316 Ok(Some(chunk)) => {
317 self.accounting.add_consumed(chunk.len() as u64);
318 collected.extend_from_slice(&chunk);
319 }
320 Ok(None) | Err(_) => {
321 return collected;
323 }
324 }
325
326 while let Ok(chunk) = self.output_rx.try_recv() {
328 self.accounting.add_consumed(chunk.len() as u64);
329 collected.extend_from_slice(&chunk);
330 }
331 }
332
333 collected
334 }
335
336 pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
338 self.master
339 .resize(PtySize {
340 rows,
341 cols,
342 pixel_width: 0,
343 pixel_height: 0,
344 })
345 .map_err(|e| RuntimeError::Tool(format!("PTY resize failed: {e}")))
346 }
347
348 pub fn is_alive(&mut self) -> bool {
352 if !self.alive.load(Ordering::SeqCst) {
353 return false;
354 }
355 match self.child.try_wait() {
356 Ok(Some(_status)) => {
357 self.alive.store(false, Ordering::SeqCst);
359 false
360 }
361 Ok(None) => true,
362 Err(_) => {
363 self.alive.store(false, Ordering::SeqCst);
365 false
366 }
367 }
368 }
369}
370
371impl Drop for PtyHandle {
372 fn drop(&mut self) {
373 self.accounting.finish_party();
380 if self.alive.load(Ordering::SeqCst) {
381 let _ = self.killer.kill();
382 for _ in 0..5 {
387 match self.child.try_wait() {
388 Ok(Some(_)) | Err(_) => break, Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
390 }
391 }
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use serial_test::serial;
400 use std::collections::HashMap;
401
402 #[tokio::test]
405 #[serial]
406 async fn pty_slow_consumer_retention_is_bounded_and_drop_releases_reader() {
407 let before = pty_output_snapshot();
408 let handle = PtyHandle::spawn(
409 "bash -c 'yes x | head -c 10485760'",
410 None,
411 HashMap::new(),
412 24,
413 80,
414 )
415 .expect("spawn producer");
416 tokio::time::sleep(Duration::from_millis(100)).await;
417 let stalled = pty_output_snapshot();
418 let retained = stalled.retained_bytes.saturating_sub(before.retained_bytes);
419 assert!(
420 retained > 0,
421 "stalled consumer must retain queued PTY bytes"
422 );
423 assert!(
424 retained <= ((PTY_OUTPUT_CHANNEL_CAPACITY + 1) * PTY_OUTPUT_MAX_CHUNK_BYTES) as u64,
425 "retained {retained}"
426 );
427 let readers_before = before.active_readers;
428 drop(handle);
429 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
430 while pty_output_snapshot().active_readers > readers_before
431 && tokio::time::Instant::now() < deadline
432 {
433 tokio::time::sleep(Duration::from_millis(10)).await;
434 }
435 assert!(pty_output_snapshot().active_readers <= readers_before);
436 assert_eq!(
437 pty_output_snapshot().retained_bytes,
438 before.retained_bytes,
439 "drop must conserve queued bytes as cancellation drops"
440 );
441 }
442
443 #[tokio::test]
449 #[serial]
450 async fn pty_drop_races_reader_and_always_conserves_accounting() {
451 let before = pty_output_snapshot();
452 for _ in 0..5 {
453 let mut handle = PtyHandle::spawn(
454 "bash -c 'yes x | head -c 33554432'",
455 None,
456 HashMap::new(),
457 24,
458 80,
459 )
460 .expect("spawn producer");
461 let _ = handle.try_read_output(Duration::from_millis(50)).await;
466 drop(handle);
467 }
468 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
471 while pty_output_snapshot().active_readers > before.active_readers
472 && tokio::time::Instant::now() < deadline
473 {
474 tokio::time::sleep(Duration::from_millis(10)).await;
475 }
476 assert_eq!(
477 pty_output_snapshot().active_readers,
478 before.active_readers,
479 "every reader must terminate"
480 );
481 assert_eq!(
482 pty_output_snapshot().retained_bytes,
483 before.retained_bytes,
484 "30 drop/reader races must conserve queued-byte accounting exactly"
485 );
486 }
487
488 #[tokio::test]
493 #[serial]
494 async fn pty_preempted_accounting_across_drop_is_released_exactly_once() {
495 let before = pty_output_snapshot();
496 TEST_PAUSE_AFTER_SEND_MS.store(150, Ordering::Relaxed);
497 let handle = PtyHandle::spawn("bash -c 'echo leak-window'", None, HashMap::new(), 24, 80)
498 .expect("spawn producer");
499 tokio::time::sleep(Duration::from_millis(40)).await;
501 drop(handle); TEST_PAUSE_AFTER_SEND_MS.store(0, Ordering::Relaxed);
503
504 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
505 while pty_output_snapshot().active_readers > before.active_readers
506 && tokio::time::Instant::now() < deadline
507 {
508 tokio::time::sleep(Duration::from_millis(10)).await;
509 }
510 assert_eq!(
511 pty_output_snapshot().active_readers,
512 before.active_readers,
513 "reader must terminate"
514 );
515 assert_eq!(
516 pty_output_snapshot().retained_bytes,
517 before.retained_bytes,
518 "a drop inside the accounting gap must not leak retained bytes"
519 );
520 }
521
522 #[tokio::test]
527 #[serial]
528 async fn pty_concurrent_handles_release_exactly_once_per_handle() {
529 let before = pty_output_snapshot();
530 let mut tasks = Vec::new();
531 for i in 0..8 {
532 tasks.push(tokio::spawn(async move {
533 let mut handle = PtyHandle::spawn(
534 "bash -c 'yes x | head -c 262144'",
535 None,
536 HashMap::new(),
537 24,
538 80,
539 )
540 .expect("spawn producer");
541 match i % 3 {
542 0 => {} 1 => {
544 tokio::time::sleep(Duration::from_millis(5)).await;
545 }
546 _ => {
547 let _ = handle.try_read_output(Duration::from_millis(10)).await;
549 }
550 }
551 drop(handle);
552 }));
553 }
554 for t in tasks {
555 t.await.unwrap();
556 }
557 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
558 while pty_output_snapshot().active_readers > before.active_readers
559 && tokio::time::Instant::now() < deadline
560 {
561 tokio::time::sleep(Duration::from_millis(10)).await;
562 }
563 assert_eq!(
564 pty_output_snapshot().active_readers,
565 before.active_readers,
566 "every reader must terminate"
567 );
568 assert_eq!(
569 pty_output_snapshot().retained_bytes,
570 before.retained_bytes,
571 "concurrent handles must conserve accounting exactly once each"
572 );
573 }
574
575 #[tokio::test]
576 #[serial]
577 async fn test_spawn_echo_hello() {
578 let mut handle = PtyHandle::spawn("echo hello", None, HashMap::new(), 24, 80)
579 .expect("failed to spawn echo");
580
581 let output = handle.try_read_output(Duration::from_secs(3)).await;
583
584 let text = String::from_utf8_lossy(&output);
585 assert!(
586 text.contains("hello"),
587 "expected 'hello' in output, got: {text:?}"
588 );
589 }
590
591 #[tokio::test]
592 #[serial]
593 async fn test_cat_echo_back() {
594 let mut handle =
595 PtyHandle::spawn("cat", None, HashMap::new(), 24, 80).expect("failed to spawn cat");
596
597 handle.write(b"test\n").expect("write failed");
599
600 let output = handle.try_read_output(Duration::from_secs(3)).await;
601
602 let text = String::from_utf8_lossy(&output);
603 assert!(
604 text.contains("test"),
605 "expected 'test' in output, got: {text:?}"
606 );
607 }
608
609 #[tokio::test]
610 #[serial]
611 async fn test_exit_code_detection() {
612 let mut handle = PtyHandle::spawn("bash -c exit 42", None, HashMap::new(), 24, 80)
613 .expect("failed to spawn bash exit");
614
615 let _ = handle.try_read_output(Duration::from_secs(3)).await;
617
618 tokio::time::sleep(Duration::from_millis(200)).await;
620
621 assert!(!handle.is_alive(), "expected process to have exited");
622 }
623}