1use 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
147type PreparedScope = (
148 Vec<(std::ffi::OsString, std::ffi::OsString)>,
149 Option<std::path::PathBuf>,
150 Option<crate::process_scope::ScopeLauncher>,
151);
152
153impl PtyHandle {
154 pub fn spawn(
162 command: &str,
163 working_dir: Option<&str>,
164 env: HashMap<String, String>,
165 rows: u16,
166 cols: u16,
167 ) -> Result<Self> {
168 let prepared = Self::prepare_scope(working_dir, &env);
169 Self::spawn_prepared(command, working_dir, rows, cols, prepared)
170 }
171
172 pub(crate) fn prepare_scope(working_dir: Option<&str>, env: &HashMap<String, String>) -> PreparedScope {
173 let mut effective_env = crate::process_scope::inherited_env();
176 crate::process_scope::set_env(&mut effective_env, "TERM", "xterm-256color");
177 for (k, v) in env { crate::process_scope::set_env(&mut effective_env, k, v); }
178 let scope_cwd = working_dir.map(std::path::PathBuf::from)
179 .filter(|p| p.is_dir())
180 .or_else(|| effective_env.iter().find(|(k, _)| k == "HOME")
181 .map(|(_, v)| std::path::PathBuf::from(v)));
182 let launcher = scope_cwd.as_deref().and_then(|cwd|
183 crate::process_scope::launcher_for_effective_env(&effective_env, Some(cwd)));
184 (effective_env, scope_cwd, launcher)
185 }
186
187 pub(crate) fn spawn_prepared(
188 command: &str, working_dir: Option<&str>, rows: u16, cols: u16,
189 prepared: PreparedScope,
190 ) -> Result<Self> {
191 let pty_system = native_pty_system();
193 let pair = pty_system
194 .openpty(PtySize {
195 rows,
196 cols,
197 pixel_width: 0,
198 pixel_height: 0,
199 })
200 .map_err(|e| RuntimeError::Tool(format!("Failed to open PTY: {e}")))?;
201
202 let parts: Vec<&str> = command.split_whitespace().collect();
205 let program = parts
206 .first()
207 .ok_or_else(|| RuntimeError::Tool("Empty command string".to_string()))?;
208 let (effective_env, scope_cwd, launcher) = prepared;
209 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = {
210 let program: std::ffi::OsString = (*program).into();
211 let args = parts
212 .iter()
213 .skip(1)
214 .map(std::ffi::OsString::from)
215 .collect::<Vec<_>>();
216 match launcher {
217 Some(launcher) => launcher.wrap("synaps tool: shell", crate::process_scope::resolve_program(program, &effective_env, scope_cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
218 None => (program, args),
219 }
220 };
221 let mut cmd = CommandBuilder::new(program);
222 for arg in &args {
223 cmd.arg(arg);
224 }
225
226 if let Some(dir) = working_dir {
228 cmd.cwd(dir);
229 }
230
231 cmd.env_clear();
232 for (k, v) in &effective_env { cmd.env(k, v); }
233
234 let child = pair
236 .slave
237 .spawn_command(cmd)
238 .map_err(|e| RuntimeError::Tool(format!("Failed to spawn command: {e}")))?;
239
240 drop(pair.slave);
242
243 let writer = pair
245 .master
246 .take_writer()
247 .map_err(|e| RuntimeError::Tool(format!("Failed to take PTY writer: {e}")))?;
248
249 let mut reader = pair
250 .master
251 .try_clone_reader()
252 .map_err(|e| RuntimeError::Tool(format!("Failed to clone PTY reader: {e}")))?;
253
254 let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
256 let accounting = PtyAccounting::new();
257 let reader_accounting = Arc::clone(&accounting);
258 let alive = Arc::new(AtomicBool::new(true));
259 let reader_alive = alive.clone();
260
261 PTY_ACTIVE_READERS.fetch_add(1, Ordering::SeqCst);
262 let reader_task = tokio::task::spawn_blocking(move || {
263 struct ReaderGauge(Arc<PtyAccounting>);
267 impl Drop for ReaderGauge {
268 fn drop(&mut self) {
269 self.0.finish_party();
270 PTY_ACTIVE_READERS.fetch_sub(1, Ordering::SeqCst);
271 }
272 }
273 let _gauge = ReaderGauge(Arc::clone(&reader_accounting));
274 let mut buf = [0u8; PTY_OUTPUT_MAX_CHUNK_BYTES];
275 loop {
276 match reader.read(&mut buf) {
277 Ok(0) => {
278 break;
280 }
281 Ok(n) => {
282 reader_accounting.add_produced(n as u64);
283 match output_tx.blocking_send(buf[..n].to_vec()) {
284 Ok(()) => {
285 #[cfg(test)]
286 {
287 let ms = TEST_PAUSE_AFTER_SEND_MS.load(Ordering::Relaxed);
288 if ms > 0 {
289 std::thread::sleep(Duration::from_millis(ms));
290 }
291 }
292 PTY_ACCEPTED_BYTES.fetch_add(n as u64, Ordering::Relaxed);
293 }
294 Err(_) => {
295 reader_accounting.add_dropped(n as u64);
296 break;
297 }
298 }
299 }
300 Err(_) => {
301 break;
303 }
304 }
305 }
306 reader_alive.store(false, Ordering::SeqCst);
307 });
308
309 let killer = child.clone_killer();
311
312 Ok(PtyHandle {
313 master: pair.master,
314 writer,
315 _reader_task: reader_task,
316 output_rx,
317 accounting,
318 child,
319 alive,
320 killer,
321 })
322 }
323
324 pub fn write(&mut self, input: &[u8]) -> Result<()> {
326 self.writer
327 .write_all(input)
328 .map_err(|e| RuntimeError::Tool(format!("PTY write failed: {e}")))?;
329 self.writer
330 .flush()
331 .map_err(|e| RuntimeError::Tool(format!("PTY flush failed: {e}")))?;
332 Ok(())
333 }
334
335 pub async fn try_read_output(&mut self, timeout: Duration) -> Vec<u8> {
344 let mut collected = Vec::new();
345
346 while let Ok(chunk) = self.output_rx.try_recv() {
348 self.accounting.add_consumed(chunk.len() as u64);
349 collected.extend_from_slice(&chunk);
350 }
351
352 if collected.is_empty() {
354 match tokio::time::timeout(timeout, self.output_rx.recv()).await {
355 Ok(Some(chunk)) => {
356 self.accounting.add_consumed(chunk.len() as u64);
357 collected.extend_from_slice(&chunk);
358 }
359 Ok(None) | Err(_) => {
360 return collected;
362 }
363 }
364
365 while let Ok(chunk) = self.output_rx.try_recv() {
367 self.accounting.add_consumed(chunk.len() as u64);
368 collected.extend_from_slice(&chunk);
369 }
370 }
371
372 collected
373 }
374
375 pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
377 self.master
378 .resize(PtySize {
379 rows,
380 cols,
381 pixel_width: 0,
382 pixel_height: 0,
383 })
384 .map_err(|e| RuntimeError::Tool(format!("PTY resize failed: {e}")))
385 }
386
387 pub fn is_alive(&mut self) -> bool {
391 if !self.alive.load(Ordering::SeqCst) {
392 return false;
393 }
394 match self.child.try_wait() {
395 Ok(Some(_status)) => {
396 self.alive.store(false, Ordering::SeqCst);
398 false
399 }
400 Ok(None) => true,
401 Err(_) => {
402 self.alive.store(false, Ordering::SeqCst);
404 false
405 }
406 }
407 }
408}
409
410impl Drop for PtyHandle {
411 fn drop(&mut self) {
412 self.accounting.finish_party();
419 if self.alive.load(Ordering::SeqCst) {
420 let _ = self.killer.kill();
421 for _ in 0..5 {
426 match self.child.try_wait() {
427 Ok(Some(_)) | Err(_) => break, Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
429 }
430 }
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use serial_test::serial;
439 use std::collections::HashMap;
440
441 #[cfg(target_os = "linux")]
447 #[tokio::test]
448 #[serial]
449 async fn pty_shell_gets_its_own_scope_when_available() {
450 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
451 let own = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
452 let before = pty_output_snapshot();
453 let mut handle = PtyHandle::spawn("bash --norc --noprofile", None, HashMap::new(), 24, 80)
456 .expect("spawn");
457 handle
458 .write(b"cat /proc/$$/cgroup; test -t 0 && echo tty=yes; exit\n")
459 .expect("write");
460 let mut out = Vec::new();
461 let deadline = std::time::Instant::now() + Duration::from_secs(5);
462 while std::time::Instant::now() < deadline
463 && !String::from_utf8_lossy(&out).contains("tty=yes\r")
464 {
465 out.extend(handle.try_read_output(Duration::from_millis(200)).await);
466 }
467 drop(handle);
470 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
471 while (pty_output_snapshot().active_readers > before.active_readers
472 || pty_output_snapshot().retained_bytes != before.retained_bytes)
473 && tokio::time::Instant::now() < deadline
474 {
475 tokio::time::sleep(Duration::from_millis(10)).await;
476 }
477 let text = String::from_utf8_lossy(&out).replace('\r', "");
478 assert!(
479 text.contains("tty=yes"),
480 "the shell must still have a terminal: {text}"
481 );
482 let child = text
485 .lines()
486 .filter(|l| !l.contains("cat /proc/")) .find_map(|l| l.find("0::/").map(|i| l[i..].trim().to_string()))
488 .unwrap_or_default();
489 assert!(child.starts_with("0::/"), "no cgroup line in: {text:?}");
490 if crate::process_scope::launcher_for_env(None).is_some() {
491 assert_ne!(child, own.trim(), "{text}");
492 assert!(child.ends_with(".scope"), "{child}");
493 } else {
494 assert_eq!(child, own.trim(), "unwrapped fallback");
495 }
496 }
497
498 #[tokio::test]
499 #[serial]
500 async fn pty_slow_consumer_retention_is_bounded_and_drop_releases_reader() {
501 let before = pty_output_snapshot();
502 let handle = PtyHandle::spawn(
503 "bash -c 'yes x | head -c 10485760'",
504 None,
505 HashMap::new(),
506 24,
507 80,
508 )
509 .expect("spawn producer");
510 tokio::time::sleep(Duration::from_millis(100)).await;
511 let stalled = pty_output_snapshot();
512 let retained = stalled.retained_bytes.saturating_sub(before.retained_bytes);
513 assert!(
514 retained > 0,
515 "stalled consumer must retain queued PTY bytes"
516 );
517 assert!(
518 retained <= ((PTY_OUTPUT_CHANNEL_CAPACITY + 1) * PTY_OUTPUT_MAX_CHUNK_BYTES) as u64,
519 "retained {retained}"
520 );
521 let readers_before = before.active_readers;
522 drop(handle);
523 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
524 while pty_output_snapshot().active_readers > readers_before
525 && tokio::time::Instant::now() < deadline
526 {
527 tokio::time::sleep(Duration::from_millis(10)).await;
528 }
529 assert!(pty_output_snapshot().active_readers <= readers_before);
530 assert_eq!(
531 pty_output_snapshot().retained_bytes,
532 before.retained_bytes,
533 "drop must conserve queued bytes as cancellation drops"
534 );
535 }
536
537 #[tokio::test]
543 #[serial]
544 async fn pty_drop_races_reader_and_always_conserves_accounting() {
545 let before = pty_output_snapshot();
546 for _ in 0..5 {
547 let mut handle = PtyHandle::spawn(
548 "bash -c 'yes x | head -c 33554432'",
549 None,
550 HashMap::new(),
551 24,
552 80,
553 )
554 .expect("spawn producer");
555 let _ = handle.try_read_output(Duration::from_millis(50)).await;
560 drop(handle);
561 }
562 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
565 while pty_output_snapshot().active_readers > before.active_readers
566 && tokio::time::Instant::now() < deadline
567 {
568 tokio::time::sleep(Duration::from_millis(10)).await;
569 }
570 assert_eq!(
571 pty_output_snapshot().active_readers,
572 before.active_readers,
573 "every reader must terminate"
574 );
575 assert_eq!(
576 pty_output_snapshot().retained_bytes,
577 before.retained_bytes,
578 "30 drop/reader races must conserve queued-byte accounting exactly"
579 );
580 }
581
582 #[tokio::test]
587 #[serial]
588 async fn pty_preempted_accounting_across_drop_is_released_exactly_once() {
589 let before = pty_output_snapshot();
590 TEST_PAUSE_AFTER_SEND_MS.store(150, Ordering::Relaxed);
591 let handle = PtyHandle::spawn("bash -c 'echo leak-window'", None, HashMap::new(), 24, 80)
592 .expect("spawn producer");
593 tokio::time::sleep(Duration::from_millis(40)).await;
595 drop(handle); TEST_PAUSE_AFTER_SEND_MS.store(0, Ordering::Relaxed);
597
598 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
599 while pty_output_snapshot().active_readers > before.active_readers
600 && tokio::time::Instant::now() < deadline
601 {
602 tokio::time::sleep(Duration::from_millis(10)).await;
603 }
604 assert_eq!(
605 pty_output_snapshot().active_readers,
606 before.active_readers,
607 "reader must terminate"
608 );
609 assert_eq!(
610 pty_output_snapshot().retained_bytes,
611 before.retained_bytes,
612 "a drop inside the accounting gap must not leak retained bytes"
613 );
614 }
615
616 #[tokio::test]
621 #[serial]
622 async fn pty_concurrent_handles_release_exactly_once_per_handle() {
623 let before = pty_output_snapshot();
624 let mut tasks = Vec::new();
625 for i in 0..8 {
626 tasks.push(tokio::spawn(async move {
627 let mut handle = PtyHandle::spawn(
628 "bash -c 'yes x | head -c 262144'",
629 None,
630 HashMap::new(),
631 24,
632 80,
633 )
634 .expect("spawn producer");
635 match i % 3 {
636 0 => {} 1 => {
638 tokio::time::sleep(Duration::from_millis(5)).await;
639 }
640 _ => {
641 let _ = handle.try_read_output(Duration::from_millis(10)).await;
643 }
644 }
645 drop(handle);
646 }));
647 }
648 for t in tasks {
649 t.await.unwrap();
650 }
651 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
652 while pty_output_snapshot().active_readers > before.active_readers
653 && tokio::time::Instant::now() < deadline
654 {
655 tokio::time::sleep(Duration::from_millis(10)).await;
656 }
657 assert_eq!(
658 pty_output_snapshot().active_readers,
659 before.active_readers,
660 "every reader must terminate"
661 );
662 assert_eq!(
663 pty_output_snapshot().retained_bytes,
664 before.retained_bytes,
665 "concurrent handles must conserve accounting exactly once each"
666 );
667 }
668
669 #[tokio::test]
670 #[serial]
671 async fn test_spawn_echo_hello() {
672 let mut handle = PtyHandle::spawn("echo hello", None, HashMap::new(), 24, 80)
673 .expect("failed to spawn echo");
674
675 let output = handle.try_read_output(Duration::from_secs(3)).await;
677
678 let text = String::from_utf8_lossy(&output);
679 assert!(
680 text.contains("hello"),
681 "expected 'hello' in output, got: {text:?}"
682 );
683 }
684
685 #[tokio::test]
686 #[serial]
687 async fn test_cat_echo_back() {
688 let mut handle =
689 PtyHandle::spawn("cat", None, HashMap::new(), 24, 80).expect("failed to spawn cat");
690
691 handle.write(b"test\n").expect("write failed");
693
694 let output = handle.try_read_output(Duration::from_secs(3)).await;
695
696 let text = String::from_utf8_lossy(&output);
697 assert!(
698 text.contains("test"),
699 "expected 'test' in output, got: {text:?}"
700 );
701 }
702
703 #[tokio::test]
704 #[serial]
705 async fn test_exit_code_detection() {
706 let mut handle = PtyHandle::spawn("bash -c exit 42", None, HashMap::new(), 24, 80)
707 .expect("failed to spawn bash exit");
708
709 let _ = handle.try_read_output(Duration::from_secs(3)).await;
711
712 tokio::time::sleep(Duration::from_millis(200)).await;
714
715 assert!(!handle.is_alive(), "expected process to have exited");
716 }
717}