Skip to main content

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}