1mod buffer;
30mod sanitize;
31mod session;
32
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::{Arc, Mutex, Weak};
37use std::time::{Duration, Instant};
38
39use bytes::Bytes;
40use zyris::{Blob, ErrorCode, Streaming, WireError};
41
42use zyris_caps::{
43 resolve_under, ExecOutput, PtyChunk, PtyId, PtyOpened, PtyRead, PtyScreen, Settle, Terminal,
44};
45
46use buffer::trim_incomplete_tail;
47use sanitize::{strip_controls, trim_incomplete_escape};
48use session::{gone, Sessions, POLL_INTERVAL};
49
50const STREAM_CHUNK_MAX: usize = 64 * 1024;
52
53const PTY_READ_MAX: usize = 128 * 1024;
56
57const MAX_SESSIONS: usize = 8;
60
61const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
68
69pub struct PtyTerminal {
70 default_shell: String,
71 root: PathBuf,
72 next_id: AtomicU64,
73 sessions: Sessions,
74 idle_timeout: Duration,
75 sweeper_started: AtomicBool,
76}
77
78impl PtyTerminal {
79 pub fn rooted(root: impl Into<PathBuf>) -> Self {
80 PtyTerminal { root: root.into(), ..PtyTerminal::default() }
81 }
82
83 pub fn with_idle_timeout(mut self, d: Duration) -> Self {
85 self.idle_timeout = d;
86 self
87 }
88
89 fn ensure_sweeper(&self) {
99 if self.sweeper_started.swap(true, Ordering::Relaxed) {
100 return;
101 }
102 let weak: Weak<Mutex<HashMap<String, session::PtySession>>> = Arc::downgrade(&self.sessions);
103 let idle = self.idle_timeout;
104 let tick = (idle / 4).max(Duration::from_millis(10));
105 tokio::spawn(async move {
106 loop {
107 tokio::time::sleep(tick).await;
108 let Some(sessions) = weak.upgrade() else { return };
109 sessions.lock().unwrap().retain(|_, s| s.last_touch.elapsed() < idle);
112 }
113 });
114 }
115
116 fn new_session(&self, shell: Option<String>, cols: u16, rows: u16) -> zyris::Result<String> {
117 self.ensure_sweeper();
118 if self.sessions.lock().unwrap().len() >= MAX_SESSIONS {
119 return Err(WireError::new(
120 ErrorCode::Other("too_many_ptys".into()),
121 format!("at most {MAX_SESSIONS} PTYs at a time; close one first"),
122 ));
123 }
124 let id = format!("pty-{}", self.next_id.fetch_add(1, Ordering::Relaxed));
125 session::spawn(
126 &self.sessions,
127 id.clone(),
128 shell.unwrap_or_else(|| self.default_shell.clone()),
129 &self.root,
130 cols,
131 rows,
132 )?;
133 Ok(id)
134 }
135
136 async fn settle(
154 &self,
155 pty: &PtyId,
156 input: Option<String>,
157 settle: Option<Settle>,
158 ) -> zyris::Result<()> {
159 let Settle { quiet_ms, timeout_ms } = settle.unwrap_or_default();
160 let gated = input.is_some();
161
162 let baseline = {
163 let mut sessions = self.sessions.lock().unwrap();
164 let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
165 s.touch();
166 if let Some(text) = input {
167 s.write_input(text.as_bytes())?;
168 }
169 s.buf.total_written()
170 };
171
172 if quiet_ms == 0 {
173 return Ok(());
174 }
175
176 let quiet = Duration::from_millis(quiet_ms);
177 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
178 loop {
179 tokio::time::sleep(POLL_INTERVAL).await;
180 {
181 let sessions = self.sessions.lock().unwrap();
182 let s = sessions.get(&pty.0).ok_or_else(gone)?;
183 let is_quiet = s.last_byte_at.elapsed() >= quiet;
184 if is_quiet && s.exited.is_some() {
186 return Ok(());
187 }
188 let gate_open = !gated || s.buf.total_written() > baseline;
192 if is_quiet && gate_open {
193 return Ok(());
194 }
195 }
196 if Instant::now() >= deadline {
197 return Ok(());
198 }
199 }
200 }
201}
202
203fn subscribe(
208 sessions: Sessions,
209 id: String,
210) -> impl futures_util::Stream<Item = zyris::Result<PtyChunk>> {
211 futures_util::stream::unfold((sessions, id, 0u64), |(sessions, id, mut cursor)| async move {
212 loop {
213 {
214 let guard = sessions.lock().unwrap();
215 let s = guard.get(&id)?;
216 let (bytes, _dropped) = s.buf.read_at(&mut cursor, STREAM_CHUNK_MAX);
217 if !bytes.is_empty() {
218 let chunk = PtyChunk { data: Blob::from_bytes(Bytes::from(bytes)) };
219 drop(guard);
220 return Some((Ok(chunk), (sessions, id, cursor)));
221 }
222 if s.exited.is_some() {
223 return None;
224 }
225 }
226 tokio::time::sleep(POLL_INTERVAL).await;
227 }
228 })
229}
230
231impl Default for PtyTerminal {
232 fn default() -> Self {
233 let default_shell = if cfg!(windows) {
234 "powershell.exe".to_string()
235 } else {
236 std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
237 };
238 PtyTerminal {
239 default_shell,
240 root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
241 next_id: AtomicU64::new(1),
242 sessions: Arc::new(Mutex::new(HashMap::new())),
243 idle_timeout: IDLE_TIMEOUT,
244 sweeper_started: AtomicBool::new(false),
245 }
246 }
247}
248
249const EXEC_OUTPUT_CAP: usize = 1024 * 1024;
255
256#[derive(Default)]
257struct OutputAcc {
258 buf: Vec<u8>,
259 truncated: bool,
260}
261
262async fn drain_capped<R>(mut reader: R, acc: Arc<Mutex<OutputAcc>>, cap: usize)
267where
268 R: tokio::io::AsyncRead + Unpin,
269{
270 use tokio::io::AsyncReadExt;
271
272 let mut chunk = [0u8; 8192];
273 loop {
274 let n = match reader.read(&mut chunk).await {
275 Ok(0) | Err(_) => break,
276 Ok(n) => n,
277 };
278 let mut acc = acc.lock().unwrap();
279 let room = cap.saturating_sub(acc.buf.len());
280 if room > 0 {
281 acc.buf.extend_from_slice(&chunk[..room.min(n)]);
282 }
283 if n > room {
284 acc.truncated = true;
285 }
286 }
287}
288
289#[cfg(unix)]
296fn kill_tree(pid: Option<u32>) {
297 use std::thread;
298 use std::time::Duration;
299
300 let Some(pid) = pid else { return };
301 let target = -(pid as i32);
302 for _ in 0..50 {
303 if unsafe { libc::kill(target, libc::SIGKILL) } == 0 {
306 return; }
308 if unsafe { libc::kill(pid as i32, 0) } != 0 {
310 return;
311 }
312 thread::sleep(Duration::from_millis(10));
313 }
314}
315
316#[cfg(not(unix))]
319fn kill_tree(pid: Option<u32>) {
320 if let Some(pid) = pid {
321 let _ = std::process::Command::new("taskkill")
322 .args(["/T", "/F", "/PID", &pid.to_string()])
323 .output();
324 }
325}
326
327pub(crate) fn pty_err(msg: impl std::fmt::Display) -> WireError {
328 WireError::new(ErrorCode::Internal, msg.to_string())
329}
330
331#[zyris::async_trait]
332impl Terminal for PtyTerminal {
333 async fn open(&self, shell: Option<String>, cols: u16, rows: u16) -> zyris::Result<PtyOpened> {
334 Ok(PtyOpened { pty: PtyId(self.new_session(shell, cols, rows)?) })
335 }
336
337 async fn open_stream(
338 &self,
339 shell: Option<String>,
340 cols: u16,
341 rows: u16,
342 ) -> zyris::Result<Streaming<PtyOpened, PtyChunk>> {
343 let id = self.new_session(shell, cols, rows)?;
344 let items = subscribe(self.sessions.clone(), id.clone());
345 Ok(Streaming::new(PtyOpened { pty: PtyId(id) }, items))
346 }
347
348 async fn read(
349 &self,
350 pty: PtyId,
351 input: Option<String>,
352 settle: Option<Settle>,
353 ) -> zyris::Result<PtyRead> {
354 self.settle(&pty, input, settle).await?;
355
356 let mut sessions = self.sessions.lock().unwrap();
357 let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
358 let mut cursor = s.read_cursor;
359 let (bytes, dropped) = s.buf.read_at(&mut cursor, PTY_READ_MAX);
360
361 let at_end = s.exited.is_some() && cursor == s.buf.total_written();
365 let keep = if at_end {
366 bytes.len()
367 } else {
368 trim_incomplete_tail(&bytes).min(trim_incomplete_escape(&bytes))
369 };
370 s.read_cursor = cursor - (bytes.len() - keep) as u64;
371
372 Ok(PtyRead {
373 content: strip_controls(&String::from_utf8_lossy(&bytes[..keep])),
374 more: s.read_cursor < s.buf.total_written(),
375 dropped,
376 exited: s.exited,
377 })
378 }
379
380 async fn screen(
381 &self,
382 pty: PtyId,
383 input: Option<String>,
384 settle: Option<Settle>,
385 ) -> zyris::Result<PtyScreen> {
386 self.settle(&pty, input, settle).await?;
387
388 let sessions = self.sessions.lock().unwrap();
389 let s = sessions.get(&pty.0).ok_or_else(gone)?;
390 let screen = s.parser.screen();
392 let (_, cols) = screen.size();
393 let (cursor_row, cursor_col) = screen.cursor_position();
394 Ok(PtyScreen {
395 lines: screen.rows(0, cols).collect(),
396 cursor_row,
397 cursor_col,
398 exited: s.exited,
399 })
400 }
401
402 async fn write(&self, pty: PtyId, data: Blob) -> zyris::Result<()> {
403 let bytes = match data {
404 Blob::Inline(b) => b,
405 Blob::Attachment(_) => {
406 return Err(WireError::invalid_params("attachment blobs unsupported for pty write"))
407 }
408 };
409 let mut sessions = self.sessions.lock().unwrap();
410 let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
411 s.touch();
412 s.write_input(&bytes)
413 }
414
415 async fn resize(&self, pty: PtyId, cols: u16, rows: u16) -> zyris::Result<()> {
416 let mut sessions = self.sessions.lock().unwrap();
417 let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
418 s.touch();
419 s.resize(cols, rows)
420 }
421
422 async fn close(&self, pty: PtyId) -> zyris::Result<()> {
423 self.sessions.lock().unwrap().remove(&pty.0);
424 Ok(())
425 }
426
427 #[allow(clippy::too_many_arguments)]
428 async fn exec(
429 &self,
430 command: Option<String>,
431 argv: Option<Vec<String>>,
432 cwd: Option<String>,
433 timeout_ms: Option<u64>,
434 stdin: Option<String>,
435 env: Option<HashMap<String, String>>,
436 shell: Option<String>,
437 ) -> zyris::Result<ExecOutput> {
438 use std::process::Stdio;
439 use tokio::io::AsyncWriteExt;
440 use tokio::process::Command;
441
442 let argv = match argv {
443 Some(v) if v.is_empty() => {
444 return Err(WireError::invalid_params("argv must not be empty"))
445 }
446 other => other,
447 };
448 let has_command = matches!(command.as_deref(), Some(c) if !c.trim().is_empty());
449 match (has_command, argv.is_some()) {
450 (false, false) => return Err(WireError::invalid_params("give `command` or `argv`, not neither")),
451 (true, true) => return Err(WireError::invalid_params("give `command` or `argv`, not both")),
452 _ => {}
453 }
454 #[cfg(not(unix))]
456 let _ = &shell;
457
458 let mut cmd = match argv {
459 Some(argv) => {
462 let mut c = Command::new(&argv[0]);
463 c.args(&argv[1..]);
464 c
465 }
466 None => {
467 #[cfg(unix)]
468 {
469 let mut c = Command::new(shell.unwrap_or_else(|| "/bin/sh".to_string()));
470 c.arg("-c").arg(command.unwrap_or_default());
471 c
472 }
473 #[cfg(not(unix))]
474 {
475 let mut c = Command::new("cmd");
477 c.arg("/C").arg(command.unwrap_or_default());
478 c
479 }
480 }
481 };
482
483 cmd.current_dir(match cwd {
484 Some(cwd) => resolve_under(&self.root, &cwd),
485 None => self.root.clone(),
486 });
487 if let Some(env) = env {
488 cmd.envs(env);
489 }
490 cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() });
491 cmd.stdout(Stdio::piped());
492 cmd.stderr(Stdio::piped());
493 #[cfg(unix)]
496 cmd.process_group(0);
497
498 let mut child = cmd.spawn().map_err(pty_err)?;
499 let pid = child.id();
500
501 if let Some(input) = stdin {
502 if let Some(mut sin) = child.stdin.take() {
503 tokio::spawn(async move {
504 let _ = sin.write_all(input.as_bytes()).await;
505 let _ = sin.shutdown().await;
506 });
507 }
508 }
509
510 let stdout = child.stdout.take().expect("stdout was launched as piped");
513 let stderr = child.stderr.take().expect("stderr was launched as piped");
514 let out_acc = Arc::new(Mutex::new(OutputAcc::default()));
515 let err_acc = Arc::new(Mutex::new(OutputAcc::default()));
516 let out_task = tokio::spawn(drain_capped(stdout, out_acc.clone(), EXEC_OUTPUT_CAP));
517 let err_task = tokio::spawn(drain_capped(stderr, err_acc.clone(), EXEC_OUTPUT_CAP));
518
519 let (exit_code, timed_out) = match timeout_ms {
520 Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), child.wait()).await {
521 Ok(status) => (status.map_err(pty_err)?.code().unwrap_or(-1), false),
522 Err(_) => {
525 kill_tree(pid);
526 let _ = child.wait().await;
527 (-1, true)
528 }
529 },
530 None => (child.wait().await.map_err(pty_err)?.code().unwrap_or(-1), false),
531 };
532
533 let _ = out_task.await;
535 let _ = err_task.await;
536
537 let (stdout, stdout_truncated) = {
538 let acc = out_acc.lock().unwrap();
539 (String::from_utf8_lossy(&acc.buf).into_owned(), acc.truncated)
540 };
541 let (stderr, stderr_truncated) = {
542 let acc = err_acc.lock().unwrap();
543 (String::from_utf8_lossy(&acc.buf).into_owned(), acc.truncated)
544 };
545
546 Ok(ExecOutput { exit_code, stdout, stderr, timed_out, stdout_truncated, stderr_truncated })
547 }
548}