sail-rs 0.2.18

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Interactive PTY bridge: the local terminal driven against a `pty` exec.
//!
//! The exec RPC, output ring, and resize/stdin calls live in [`crate::exec`];
//! this module owns the terminal-facing half: putting the local TTY in raw
//! mode, forwarding raw keystrokes (so the guest's line discipline turns
//! Ctrl-C/Ctrl-D into signals), pumping merged output, propagating SIGWINCH
//! as a resize, and restoring the terminal on exit.
//!
//! [`Sailbox::shell`](crate::Sailbox::shell) is the high-level entry; the CLI
//! drives [`run_interactive`] directly for its `--tty` flows. Unix-only: on
//! other platforms the calls return an unsupported error and the build still
//! succeeds.

use std::sync::Arc;
use std::time::Duration;

use crate::error::{GrpcCode, SailError};
use crate::exec::ExecOptions;
use crate::sailbox::object::Sailbox;

/// Options for [`Sailbox::shell`].
#[derive(Debug, Clone, Default)]
pub struct ShellOptions {
    /// Login shell to run when no command is given (default: the guest's
    /// `$SHELL`, else `/bin/bash`). Ignored when a command is given.
    pub shell: Option<String>,
    /// `$TERM` for the remote pty (default: the local `$TERM`).
    pub term: Option<String>,
    /// Working directory for the session.
    pub cwd: Option<String>,
    /// Wall-clock limit for the session; `None` means no limit.
    pub timeout: Option<Duration>,
}

/// True when stdin and stdout are both TTYs, required for an interactive PTY.
#[doc(hidden)]
pub fn stdio_is_tty() -> bool {
    use std::io::IsTerminal;
    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}

fn tty_required() -> SailError {
    SailError::Execution {
        code: GrpcCode::FailedPrecondition,
        detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
            .to_string(),
    }
}

impl Sailbox {
    /// Open an interactive pty session on the sailbox, driving the local
    /// terminal. With no `command`, runs a login shell; pass a command to run
    /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
    /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
    /// process, its output renders locally, and terminal resizes propagate.
    /// Blocks until the remote process exits and returns its exit code.
    /// Requires an interactive local terminal (stdin and stdout TTYs).
    pub async fn shell(
        &self,
        command: Option<&str>,
        options: ShellOptions,
    ) -> Result<i32, SailError> {
        if !stdio_is_tty() {
            return Err(tty_required());
        }
        let command = match command {
            Some(command) => command.to_string(),
            None => login_shell_command(options.shell.as_deref()),
        };
        let (cols, rows) = terminal_size();
        let proc = self
            .client()
            .exec_shell(
                self.sailbox_id(),
                &command,
                ExecOptions {
                    timeout: options.timeout,
                    pty: true,
                    term: options
                        .term
                        .or_else(|| std::env::var("TERM").ok())
                        .unwrap_or_default(),
                    cols,
                    rows,
                    cwd: options.cwd,
                    ..Default::default()
                },
            )
            .await?;
        let proc = Arc::new(proc);
        tokio::task::spawn_blocking(move || run_interactive(proc))
            .await
            .map_err(|err| SailError::Internal {
                message: format!("shell bridge task failed: {err}"),
            })?
    }
}

/// The command for an interactive login session: `exec` the login shell so
/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
/// with spaces runs as a literal program; the default stays unquoted so the
/// guest shell expands `$SHELL`.
fn login_shell_command(shell: Option<&str>) -> String {
    match shell {
        Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
        None => "exec ${SHELL:-/bin/bash} -l".to_string(),
    }
}

/// The local terminal size as (cols, rows), defaulting to 80x24.
#[cfg(unix)]
#[doc(hidden)]
pub fn terminal_size() -> (u32, u32) {
    let mut size = libc::winsize {
        ws_row: 0,
        ws_col: 0,
        ws_xpixel: 0,
        ws_ypixel: 0,
    };
    let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
    if ok && size.ws_col > 0 && size.ws_row > 0 {
        (u32::from(size.ws_col), u32::from(size.ws_row))
    } else {
        (80, 24)
    }
}

/// The local terminal size as (cols, rows), defaulting to 80x24.
#[cfg(not(unix))]
#[doc(hidden)]
pub fn terminal_size() -> (u32, u32) {
    (80, 24)
}

/// Interactive PTY sessions need Unix TTY and signal APIs.
#[cfg(not(unix))]
#[doc(hidden)]
pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
    Err(SailError::Execution {
        code: GrpcCode::Unimplemented,
        detail: "interactive PTY sessions are not supported on this platform".to_string(),
    })
}

#[cfg(unix)]
#[doc(hidden)]
pub use unix::run_interactive;

#[cfg(unix)]
mod unix {
    use std::io::Write;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;
    use std::thread;
    use std::time::Duration;

    use super::terminal_size;
    use crate::error::SailError;
    use crate::exec::{ExecProcess, OutputStream, ReadStep};

    /// Drive a future to completion from this bridge's dedicated thread. On
    /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
    /// ambient handle exists and `Handle::block_on` is the correct, safe
    /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
    /// back to the crate's shared-runtime `block_on`.
    fn block_on<F: std::future::Future>(future: F) -> F::Output {
        match tokio::runtime::Handle::try_current() {
            Ok(handle) => handle.block_on(future),
            Err(_) => crate::runtime::block_on(future),
        }
    }

    /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
    static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);

    extern "C" fn on_sigwinch(_signum: libc::c_int) {
        RESIZE_PENDING.store(true, Ordering::Relaxed);
    }

    /// Drive the local terminal against a PTY exec until the remote process
    /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
    /// always restored, even on error.
    pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
        let saved = enter_raw_mode()?;
        let prev_winch = install_sigwinch();
        let prev_flags = set_stdin_nonblocking();

        // Seed the remote PTY with the current size.
        let (cols, rows) = terminal_size();
        block_on(proc.resize(cols, rows));

        let stop = Arc::new(AtomicBool::new(false));
        let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));

        drive_input(&proc, &stop);

        // Tear down in reverse order so the terminal is always usable afterwards.
        let _ = output.join();
        restore_stdin_flags(prev_flags);
        restore_sigwinch(prev_winch);
        restore_terminal(&saved);

        let exit = block_on(proc.wait())?;
        Ok(exit.exit_code)
    }

    /// Pump merged PTY output to stdout until the stream ends; then signal stop.
    fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
        thread::spawn(move || {
            let mut reader = proc.reader(OutputStream::Stdout);
            let mut stdout = std::io::stdout();
            loop {
                match reader.next(Duration::from_millis(100)) {
                    ReadStep::Chunk(text) => {
                        let _ = stdout.write_all(text.as_bytes());
                        let _ = stdout.flush();
                    }
                    ReadStep::Eof => break,
                    ReadStep::Pending => {}
                }
            }
            stop.store(true, Ordering::Relaxed);
        })
    }

    /// Forward raw stdin bytes to the guest, draining pending resizes, until the
    /// output stream ends or local stdin closes.
    fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
        let mut buf = [0u8; 4096];
        let mut stdin_open = true;
        while !stop.load(Ordering::Relaxed) {
            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
                let (cols, rows) = terminal_size();
                block_on(proc.resize(cols, rows));
            }
            if !stdin_open {
                thread::sleep(Duration::from_millis(20));
                continue;
            }
            let n = unsafe {
                libc::read(
                    libc::STDIN_FILENO,
                    buf.as_mut_ptr().cast::<libc::c_void>(),
                    buf.len(),
                )
            };
            match n.cmp(&0) {
                std::cmp::Ordering::Greater => {
                    if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
                        break; // remote closed stdin or exec ended
                    }
                }
                std::cmp::Ordering::Equal => {
                    // Local stdin reached EOF: send EOF and stop reading it, but
                    // keep draining output until the remote process exits.
                    let _ = block_on(proc.close_stdin());
                    stdin_open = false;
                }
                std::cmp::Ordering::Less => {
                    // A nonblocking read with no data yet (WouldBlock), or one a
                    // handled signal such as SIGWINCH interrupted (Interrupted),
                    // is transient: back off briefly and retry rather than ending
                    // the input loop, which would wedge stdin until the command
                    // exits.
                    let err = std::io::Error::last_os_error();
                    if matches!(
                        err.kind(),
                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
                    ) {
                        thread::sleep(Duration::from_millis(10));
                    } else {
                        break;
                    }
                }
            }
        }
    }

    // --- platform terminal plumbing ---

    /// Put the local terminal into raw mode, returning the saved settings.
    fn enter_raw_mode() -> Result<libc::termios, SailError> {
        unsafe {
            let mut saved: libc::termios = std::mem::zeroed();
            if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
                return Err(SailError::Internal {
                    message: format!(
                        "could not enter raw terminal mode: {}",
                        std::io::Error::last_os_error()
                    ),
                });
            }
            let mut raw = saved;
            libc::cfmakeraw(&raw mut raw);
            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
                return Err(SailError::Internal {
                    message: format!(
                        "could not enter raw terminal mode: {}",
                        std::io::Error::last_os_error()
                    ),
                });
            }
            Ok(saved)
        }
    }

    fn restore_terminal(saved: &libc::termios) {
        unsafe {
            let _ = libc::tcsetattr(
                libc::STDIN_FILENO,
                libc::TCSADRAIN,
                std::ptr::from_ref(saved),
            );
        }
    }

    type SigHandler = libc::sighandler_t;

    fn install_sigwinch() -> SigHandler {
        // `signal` takes the handler as a numeric `sighandler_t`; cast through a
        // concrete fn pointer first so this is a pointer-to-int cast, not a
        // fn-item-to-int cast.
        let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
        unsafe { libc::signal(libc::SIGWINCH, handler) }
    }

    fn restore_sigwinch(prev: SigHandler) {
        unsafe {
            libc::signal(libc::SIGWINCH, prev);
        }
    }

    /// Put stdin into non-blocking mode so the input loop can interleave reads
    /// with resize handling and the stop flag. Returns the previous fcntl flags.
    fn set_stdin_nonblocking() -> libc::c_int {
        unsafe {
            let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
            if flags >= 0 {
                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
            }
            flags
        }
    }

    fn restore_stdin_flags(flags: libc::c_int) {
        if flags >= 0 {
            unsafe {
                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn login_shell_quotes_an_explicit_path() {
        // A path with spaces runs as one literal program.
        assert_eq!(
            login_shell_command(Some("/opt/my tools/zsh")),
            "exec '/opt/my tools/zsh' -l"
        );
        // The default stays unquoted so the guest expands $SHELL.
        assert_eq!(
            login_shell_command(/* shell */ None),
            "exec ${SHELL:-/bin/bash} -l"
        );
    }

    #[tokio::test]
    async fn shell_requires_a_tty() {
        // Test processes have no TTY on stdin/stdout, so the precondition
        // fires before any network or terminal manipulation.
        let client = crate::Client::builder("sk_test")
            .api_url("http://127.0.0.1:1")
            .sailbox_api_url("http://127.0.0.1:1")
            .build()
            .expect("build");
        let err = client
            .sailbox("sb_test")
            .shell(/* command */ None, ShellOptions::default())
            .await
            .expect_err("no tty in tests");
        assert!(err.to_string().contains("interactive terminal"), "{err}");
    }
}