sail/shell.rs
1//! Interactive PTY bridge: the local terminal driven against a `pty` exec.
2//!
3//! The exec RPC, output ring, and resize/stdin calls live in [`crate::exec`];
4//! this module owns the terminal-facing half: putting the local TTY in raw
5//! mode, forwarding raw keystrokes (so the guest's line discipline turns
6//! Ctrl-C/Ctrl-D into signals), pumping merged output, propagating SIGWINCH
7//! as a resize, and restoring the terminal on exit.
8//!
9//! [`Sailbox::shell`](crate::Sailbox::shell) is the high-level entry; the CLI
10//! drives [`run_interactive`] directly for its `--tty` flows. Unix-only: on
11//! other platforms the calls return an unsupported error and the build still
12//! succeeds.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use crate::error::{GrpcCode, SailError};
18use crate::exec::ExecOptions;
19use crate::sailbox::object::Sailbox;
20
21/// Options for [`Sailbox::shell`].
22#[derive(Debug, Clone, Default)]
23pub struct ShellOptions {
24 /// Login shell to run when no command is given (default: the guest's
25 /// `$SHELL`, else `/bin/bash`). Ignored when a command is given.
26 pub shell: Option<String>,
27 /// `$TERM` for the remote pty (default: the local `$TERM`).
28 pub term: Option<String>,
29 /// Working directory for the session.
30 pub cwd: Option<String>,
31 /// Wall-clock limit for the session; `None` means no limit.
32 pub timeout: Option<Duration>,
33}
34
35/// True when stdin and stdout are both TTYs, required for an interactive PTY.
36#[doc(hidden)]
37pub fn stdio_is_tty() -> bool {
38 use std::io::IsTerminal;
39 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
40}
41
42fn tty_required() -> SailError {
43 SailError::Execution {
44 code: GrpcCode::FailedPrecondition,
45 detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
46 .to_string(),
47 }
48}
49
50impl Sailbox {
51 /// Open an interactive pty session on the sailbox, driving the local
52 /// terminal. With no `command`, runs a login shell; pass a command to run
53 /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
54 /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
55 /// process, its output renders locally, and terminal resizes propagate.
56 /// Blocks until the remote process exits and returns its exit code.
57 /// Requires an interactive local terminal (stdin and stdout TTYs).
58 pub async fn shell(
59 &self,
60 command: Option<&str>,
61 options: ShellOptions,
62 ) -> Result<i32, SailError> {
63 if !stdio_is_tty() {
64 return Err(tty_required());
65 }
66 let command = match command {
67 Some(command) => command.to_string(),
68 None => login_shell_command(options.shell.as_deref()),
69 };
70 let (cols, rows) = terminal_size();
71 let proc = self
72 .client()
73 .exec_shell(
74 self.sailbox_id(),
75 &command,
76 ExecOptions {
77 timeout: options.timeout,
78 pty: true,
79 term: options
80 .term
81 .or_else(|| std::env::var("TERM").ok())
82 .unwrap_or_default(),
83 cols,
84 rows,
85 cwd: options.cwd,
86 ..Default::default()
87 },
88 )
89 .await?;
90 let proc = Arc::new(proc);
91 tokio::task::spawn_blocking(move || run_interactive(proc))
92 .await
93 .map_err(|err| SailError::Internal {
94 message: format!("shell bridge task failed: {err}"),
95 })?
96 }
97}
98
99/// The command for an interactive login session: `exec` the login shell so
100/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
101/// with spaces runs as a literal program; the default stays unquoted so the
102/// guest shell expands `$SHELL`.
103fn login_shell_command(shell: Option<&str>) -> String {
104 match shell {
105 Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
106 None => "exec ${SHELL:-/bin/bash} -l".to_string(),
107 }
108}
109
110/// The local terminal size as (cols, rows), defaulting to 80x24.
111#[cfg(unix)]
112#[doc(hidden)]
113pub fn terminal_size() -> (u32, u32) {
114 let mut size = libc::winsize {
115 ws_row: 0,
116 ws_col: 0,
117 ws_xpixel: 0,
118 ws_ypixel: 0,
119 };
120 let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
121 if ok && size.ws_col > 0 && size.ws_row > 0 {
122 (u32::from(size.ws_col), u32::from(size.ws_row))
123 } else {
124 (80, 24)
125 }
126}
127
128/// The local terminal size as (cols, rows), defaulting to 80x24.
129#[cfg(not(unix))]
130#[doc(hidden)]
131pub fn terminal_size() -> (u32, u32) {
132 (80, 24)
133}
134
135/// Interactive PTY sessions need Unix TTY and signal APIs.
136#[cfg(not(unix))]
137#[doc(hidden)]
138pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
139 Err(SailError::Execution {
140 code: GrpcCode::Unimplemented,
141 detail: "interactive PTY sessions are not supported on this platform".to_string(),
142 })
143}
144
145#[cfg(unix)]
146#[doc(hidden)]
147pub use unix::run_interactive;
148
149#[cfg(unix)]
150#[doc(hidden)]
151pub use unix::drive_output_pump;
152
153#[cfg(unix)]
154mod unix {
155 use std::io::Write;
156 use std::sync::atomic::{AtomicBool, Ordering};
157 use std::sync::Arc;
158 use std::thread;
159 use std::time::{Duration, Instant};
160
161 use super::terminal_size;
162 use crate::error::SailError;
163 use crate::exec::{ExecProcess, OutputStream, ReadStep};
164
165 /// Drive a future to completion from this bridge's dedicated thread. On
166 /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
167 /// ambient handle exists and `Handle::block_on` is the correct, safe
168 /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
169 /// back to the crate's shared-runtime `block_on`.
170 fn block_on<F: std::future::Future>(future: F) -> F::Output {
171 match tokio::runtime::Handle::try_current() {
172 Ok(handle) => handle.block_on(future),
173 Err(_) => crate::runtime::block_on(future),
174 }
175 }
176
177 /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
178 static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
179
180 extern "C" fn on_sigwinch(_signum: libc::c_int) {
181 RESIZE_PENDING.store(true, Ordering::Relaxed);
182 }
183
184 /// Drive the local terminal against a PTY exec until the remote process
185 /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
186 /// always restored, even on error.
187 pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
188 let saved = enter_raw_mode()?;
189 let prev_winch = install_sigwinch();
190 let prev_in_flags = set_stdin_nonblocking();
191 // Non-blocking stdout so the output pump is never parked in a write to a
192 // slow terminal: it must stay free to notice the ring dropped and repaint.
193 let prev_out_flags = set_stdout_nonblocking();
194
195 // Seed the remote PTY with the current size.
196 let (cols, rows) = terminal_size();
197 block_on(proc.resize(cols, rows));
198
199 let stop = Arc::new(AtomicBool::new(false));
200 let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
201
202 drive_input(&proc, &stop);
203
204 // Tear down in reverse order so the terminal is always usable afterwards.
205 let _ = output.join();
206 restore_stdout_flags(prev_out_flags);
207 restore_stdin_flags(prev_in_flags);
208 restore_sigwinch(prev_winch);
209 restore_terminal(&saved);
210
211 let exit = block_on(proc.wait())?;
212 Ok(exit.exit_code)
213 }
214
215 /// Least time between screen-repaint requests while the local terminal is
216 /// too slow to keep up: without a bound a persistently-behind reader would
217 /// ask on every drop and flood the guest with resync RPCs. Capping repaint
218 /// requests to one per 100 ms is plenty to keep the screen current.
219 const RESYNC_MIN_INTERVAL: Duration = Duration::from_millis(100);
220
221 /// Most backlog the pump buffers toward the terminal before it stops draining
222 /// the ring. Holding the cap small means a slow terminal quickly lets the
223 /// ring back up and drop-oldest, which the reader reports as a drop — the
224 /// signal that triggers a repaint. Larger would just make the terminal crawl
225 /// further through stale frames before recovering.
226 const OUTPUT_PENDING_CAP: usize = 256 * 1024;
227
228 /// Spawn the thread that renders merged PTY output to the terminal, then
229 /// signals stop when the stream ends. The terminal fd is already non-blocking
230 /// (set by [`run_interactive`]).
231 fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
232 thread::spawn(move || {
233 let mut reader = proc.reader(OutputStream::Stdout);
234 let mut sink = RawFdWriter(libc::STDOUT_FILENO);
235 drive_output_pump(&mut reader, &mut sink, &proc);
236 stop.store(true, Ordering::Relaxed);
237 })
238 }
239
240 /// Render one live output stream onto a terminal `sink` until the stream
241 /// ends, favoring a current screen over a faithful replay.
242 ///
243 /// The terminal writer must never block the loop: a slow terminal has to keep
244 /// the pump free to notice the ring dropped and ask the guest to repaint the
245 /// current screen ([`ExecProcess::resync`]). So `sink` is written
246 /// non-blockingly, backlog is held to [`OUTPUT_PENDING_CAP`] so the ring
247 /// backs up and drops-oldest when the terminal falls behind, and a reported
248 /// drop discards the torn backlog and requests a repaint rather than crawling
249 /// the slow terminal through stale frames it will never catch. The command is
250 /// detached on the server, so none of this ever blocks it.
251 ///
252 /// Generic over the sink so the drop-to-repaint behavior is testable against a
253 /// deliberately slow writer without a real terminal.
254 #[doc(hidden)]
255 pub fn drive_output_pump<W: Write>(
256 reader: &mut crate::exec::StreamReader,
257 sink: &mut W,
258 proc: &Arc<ExecProcess>,
259 ) {
260 let mut pending: Vec<u8> = Vec::new();
261 let mut last_resync: Option<Instant> = None;
262 // Hold an observed drop until a repaint is actually requested. resync_due
263 // only fires once per RESYNC_MIN_INTERVAL, so a drop seen during that
264 // cooldown would otherwise be forgotten, leaving the screen showing a
265 // torn, partial frame.
266 let mut resync_pending = false;
267 loop {
268 // Push as much backlog as the terminal accepts right now, without
269 // blocking on it.
270 let mut flushed = false;
271 if !pending.is_empty() {
272 let written = write_nonblocking(sink, &pending);
273 if written > 0 {
274 pending.drain(..written);
275 flushed = true;
276 }
277 }
278 // Refill from the ring, but only up to the cap: leaving the rest in
279 // the ring lets it back up and drop-oldest when the terminal is slow.
280 let mut progressed = false;
281 if pending.len() < OUTPUT_PENDING_CAP {
282 // Don't wait for new data while there is still backlog to push.
283 let wait = if pending.is_empty() {
284 Duration::from_millis(50)
285 } else {
286 Duration::ZERO
287 };
288 match reader.next(wait) {
289 ReadStep::Chunk(bytes) => {
290 // A Snapshot reset the ring: `bytes` is the repaint, and
291 // it supersedes the stale backlog buffered toward the
292 // terminal. Drop that backlog before queuing the repaint
293 // so the finished screen renders at once instead of stuck
294 // behind bytes the slow terminal will never finish
295 // draining (the bounded end-of-stream flush would give up
296 // before reaching it).
297 if reader.took_reset() {
298 pending.clear();
299 }
300 pending.extend_from_slice(&bytes);
301 progressed = true;
302 }
303 ReadStep::Eof => {
304 flush_blocking(sink, &pending);
305 return;
306 }
307 ReadStep::Pending => {}
308 }
309 while pending.len() < OUTPUT_PENDING_CAP {
310 match reader.try_next() {
311 // Honor a reset here too: the repaint can land in this
312 // batch drain when the Snapshot arrives after next()
313 // above already returned a stale chunk this iteration.
314 Some(more) => {
315 if reader.took_reset() {
316 pending.clear();
317 }
318 pending.extend_from_slice(&more);
319 }
320 None => break,
321 }
322 }
323 }
324 // The ring evicted output we had not shown: the backlog is now a torn
325 // tail, so drop it and repaint the current screen instead.
326 if reader.took_drop() {
327 pending.clear();
328 resync_pending = true;
329 }
330 if resync_pending && resync_due(&mut last_resync) {
331 resync_pending = false;
332 let handle = Arc::clone(proc);
333 crate::runtime::runtime().spawn(async move { handle.resync().await });
334 }
335 // Yield when no new ring data was read and bytes are still queued,
336 // either because the backlog is at the cap (so the ring can back up
337 // and drop-oldest for a slow terminal) or because the terminal is
338 // back-pressured and accepted nothing (so the loop does not spin).
339 // A terminal actively draining a partial backlog is making progress,
340 // so it keeps looping.
341 if !progressed
342 && !pending.is_empty()
343 && (pending.len() >= OUTPUT_PENDING_CAP || !flushed)
344 {
345 thread::sleep(Duration::from_millis(5));
346 }
347 }
348 }
349
350 /// Write what the terminal will take right now, returning the bytes accepted.
351 /// A full terminal (`WouldBlock`), or any transient error, accepts zero and
352 /// the caller keeps the rest rather than propagating a terminal write error.
353 fn write_nonblocking<W: Write>(sink: &mut W, buf: &[u8]) -> usize {
354 sink.write(buf).unwrap_or(0)
355 }
356
357 /// End of stream: land the final bytes even against a non-blocking terminal,
358 /// but bounded so a wedged terminal cannot hang the exit.
359 fn flush_blocking<W: Write>(sink: &mut W, buf: &[u8]) {
360 let mut off = 0;
361 for _ in 0..2000 {
362 if off >= buf.len() {
363 break;
364 }
365 match sink.write(&buf[off..]) {
366 Ok(0) => thread::sleep(Duration::from_millis(1)),
367 Ok(n) => off += n,
368 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
369 thread::sleep(Duration::from_millis(1));
370 }
371 Err(_) => break,
372 }
373 }
374 let _ = sink.flush();
375 }
376
377 /// A `Write` over a raw fd. On a non-blocking fd a full pipe surfaces as a
378 /// `WouldBlock` error rather than parking the thread.
379 struct RawFdWriter(libc::c_int);
380
381 impl Write for RawFdWriter {
382 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
383 let n = unsafe { libc::write(self.0, buf.as_ptr().cast(), buf.len()) };
384 if n < 0 {
385 Err(std::io::Error::last_os_error())
386 } else {
387 Ok(n as usize)
388 }
389 }
390
391 fn flush(&mut self) -> std::io::Result<()> {
392 Ok(())
393 }
394 }
395
396 /// Whether enough time has passed since the last repaint request to send
397 /// another, stamping the clock when it returns true.
398 fn resync_due(last: &mut Option<Instant>) -> bool {
399 let now = Instant::now();
400 if last.is_none_or(|t| now.duration_since(t) >= RESYNC_MIN_INTERVAL) {
401 *last = Some(now);
402 true
403 } else {
404 false
405 }
406 }
407
408 /// Forward raw stdin bytes to the guest, draining pending resizes, until the
409 /// output stream ends or local stdin closes.
410 fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
411 let mut buf = [0u8; 4096];
412 let mut stdin_open = true;
413 while !stop.load(Ordering::Relaxed) {
414 if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
415 let (cols, rows) = terminal_size();
416 block_on(proc.resize(cols, rows));
417 }
418 if !stdin_open {
419 thread::sleep(Duration::from_millis(20));
420 continue;
421 }
422 let n = unsafe {
423 libc::read(
424 libc::STDIN_FILENO,
425 buf.as_mut_ptr().cast::<libc::c_void>(),
426 buf.len(),
427 )
428 };
429 match n.cmp(&0) {
430 std::cmp::Ordering::Greater => {
431 if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
432 break; // remote closed stdin or exec ended
433 }
434 }
435 std::cmp::Ordering::Equal => {
436 // Local stdin reached EOF: send EOF and stop reading it, but
437 // keep draining output until the remote process exits.
438 let _ = block_on(proc.close_stdin());
439 stdin_open = false;
440 }
441 std::cmp::Ordering::Less => {
442 // A nonblocking read with no data yet (WouldBlock), or one a
443 // handled signal such as SIGWINCH interrupted (Interrupted),
444 // is transient: back off briefly and retry rather than ending
445 // the input loop, which would wedge stdin until the command
446 // exits.
447 let err = std::io::Error::last_os_error();
448 if matches!(
449 err.kind(),
450 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
451 ) {
452 thread::sleep(Duration::from_millis(10));
453 } else {
454 break;
455 }
456 }
457 }
458 }
459 }
460
461 // --- platform terminal plumbing ---
462
463 /// Put the local terminal into raw mode, returning the saved settings.
464 fn enter_raw_mode() -> Result<libc::termios, SailError> {
465 unsafe {
466 let mut saved: libc::termios = std::mem::zeroed();
467 if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
468 return Err(SailError::Internal {
469 message: format!(
470 "could not enter raw terminal mode: {}",
471 std::io::Error::last_os_error()
472 ),
473 });
474 }
475 let mut raw = saved;
476 libc::cfmakeraw(&raw mut raw);
477 if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
478 return Err(SailError::Internal {
479 message: format!(
480 "could not enter raw terminal mode: {}",
481 std::io::Error::last_os_error()
482 ),
483 });
484 }
485 Ok(saved)
486 }
487 }
488
489 fn restore_terminal(saved: &libc::termios) {
490 unsafe {
491 let _ = libc::tcsetattr(
492 libc::STDIN_FILENO,
493 libc::TCSADRAIN,
494 std::ptr::from_ref(saved),
495 );
496 }
497 }
498
499 type SigHandler = libc::sighandler_t;
500
501 fn install_sigwinch() -> SigHandler {
502 // `signal` takes the handler as a numeric `sighandler_t`; cast through a
503 // concrete fn pointer first so this is a pointer-to-int cast, not a
504 // fn-item-to-int cast.
505 let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
506 unsafe { libc::signal(libc::SIGWINCH, handler) }
507 }
508
509 fn restore_sigwinch(prev: SigHandler) {
510 unsafe {
511 libc::signal(libc::SIGWINCH, prev);
512 }
513 }
514
515 /// Put stdin into non-blocking mode so the input loop can interleave reads
516 /// with resize handling and the stop flag. Returns the previous fcntl flags.
517 fn set_stdin_nonblocking() -> libc::c_int {
518 unsafe {
519 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
520 if flags >= 0 {
521 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
522 }
523 flags
524 }
525 }
526
527 fn restore_stdin_flags(flags: libc::c_int) {
528 if flags >= 0 {
529 unsafe {
530 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
531 }
532 }
533 }
534
535 /// Put stdout into non-blocking mode so the output pump is never parked in a
536 /// write to a slow terminal. Returns the previous fcntl flags.
537 fn set_stdout_nonblocking() -> libc::c_int {
538 unsafe {
539 let flags = libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFL);
540 if flags >= 0 {
541 libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
542 }
543 flags
544 }
545 }
546
547 fn restore_stdout_flags(flags: libc::c_int) {
548 if flags >= 0 {
549 unsafe {
550 libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags);
551 }
552 }
553 }
554
555 #[cfg(test)]
556 mod tests {
557 use super::*;
558
559 #[test]
560 fn resync_due_throttles_back_to_back_requests() {
561 let mut last = None;
562 // The first request is always due and stamps the clock.
563 assert!(resync_due(&mut last));
564 // A second request within RESYNC_MIN_INTERVAL is suppressed, so a
565 // persistently-behind reader cannot flood the guest with resync RPCs.
566 assert!(!resync_due(&mut last));
567 }
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn login_shell_quotes_an_explicit_path() {
577 // A path with spaces runs as one literal program.
578 assert_eq!(
579 login_shell_command(Some("/opt/my tools/zsh")),
580 "exec '/opt/my tools/zsh' -l"
581 );
582 // The default stays unquoted so the guest expands $SHELL.
583 assert_eq!(
584 login_shell_command(/* shell */ None),
585 "exec ${SHELL:-/bin/bash} -l"
586 );
587 }
588
589 #[tokio::test]
590 async fn shell_requires_a_tty() {
591 // Test processes have no TTY on stdin/stdout, so the precondition
592 // fires before any network or terminal manipulation.
593 let client = crate::Client::builder("sk_test")
594 .api_url("http://127.0.0.1:1")
595 .sailbox_api_url("http://127.0.0.1:1")
596 .build()
597 .expect("build");
598 let err = client
599 .sailbox("sb_test")
600 .shell(/* command */ None, ShellOptions::default())
601 .await
602 .expect_err("no tty in tests");
603 assert!(err.to_string().contains("interactive terminal"), "{err}");
604 }
605}