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.
36pub fn stdio_is_tty() -> bool {
37    use std::io::IsTerminal;
38    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
39}
40
41fn tty_required() -> SailError {
42    SailError::Execution {
43        code: GrpcCode::FailedPrecondition,
44        detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
45            .to_string(),
46    }
47}
48
49impl Sailbox {
50    /// Open an interactive pty session on the sailbox, driving the local
51    /// terminal. With no `command`, runs a login shell; pass a command to run
52    /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
53    /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
54    /// process, its output renders locally, and terminal resizes propagate.
55    /// Blocks until the remote process exits and returns its exit code.
56    /// Requires an interactive local terminal (stdin and stdout TTYs).
57    pub async fn shell(
58        &self,
59        command: Option<&str>,
60        options: ShellOptions,
61    ) -> Result<i32, SailError> {
62        if !stdio_is_tty() {
63            return Err(tty_required());
64        }
65        let command = match command {
66            Some(command) => command.to_string(),
67            None => login_shell_command(options.shell.as_deref()),
68        };
69        let (cols, rows) = terminal_size();
70        let proc = self
71            .client()
72            .exec_shell(
73                self.sailbox_id(),
74                &command,
75                ExecOptions {
76                    timeout: options.timeout,
77                    pty: true,
78                    term: options
79                        .term
80                        .or_else(|| std::env::var("TERM").ok())
81                        .unwrap_or_default(),
82                    cols,
83                    rows,
84                    cwd: options.cwd,
85                    ..Default::default()
86                },
87            )
88            .await?;
89        let proc = Arc::new(proc);
90        tokio::task::spawn_blocking(move || run_interactive(proc))
91            .await
92            .map_err(|err| SailError::Internal {
93                message: format!("shell bridge task failed: {err}"),
94            })?
95    }
96}
97
98/// The command for an interactive login session: `exec` the login shell so
99/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
100/// with spaces runs as a literal program; the default stays unquoted so the
101/// guest shell expands `$SHELL`.
102fn login_shell_command(shell: Option<&str>) -> String {
103    match shell {
104        Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
105        None => "exec ${SHELL:-/bin/bash} -l".to_string(),
106    }
107}
108
109/// The local terminal size as (cols, rows), defaulting to 80x24.
110#[cfg(unix)]
111pub fn terminal_size() -> (u32, u32) {
112    let mut size = libc::winsize {
113        ws_row: 0,
114        ws_col: 0,
115        ws_xpixel: 0,
116        ws_ypixel: 0,
117    };
118    let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
119    if ok && size.ws_col > 0 && size.ws_row > 0 {
120        (u32::from(size.ws_col), u32::from(size.ws_row))
121    } else {
122        (80, 24)
123    }
124}
125
126/// The local terminal size as (cols, rows), defaulting to 80x24.
127#[cfg(not(unix))]
128pub fn terminal_size() -> (u32, u32) {
129    (80, 24)
130}
131
132/// Interactive PTY sessions need Unix TTY and signal APIs.
133#[cfg(not(unix))]
134pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
135    Err(SailError::Execution {
136        code: GrpcCode::Unimplemented,
137        detail: "interactive PTY sessions are not supported on this platform".to_string(),
138    })
139}
140
141#[cfg(unix)]
142pub use unix::run_interactive;
143
144#[cfg(unix)]
145mod unix {
146    use std::io::Write;
147    use std::sync::atomic::{AtomicBool, Ordering};
148    use std::sync::Arc;
149    use std::thread;
150    use std::time::Duration;
151
152    use super::terminal_size;
153    use crate::error::SailError;
154    use crate::exec::{ExecProcess, OutputStream, ReadStep};
155
156    /// Drive a future to completion from this bridge's dedicated thread. On
157    /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
158    /// ambient handle exists and `Handle::block_on` is the correct, safe
159    /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
160    /// back to the crate's shared-runtime `block_on`.
161    fn block_on<F: std::future::Future>(future: F) -> F::Output {
162        match tokio::runtime::Handle::try_current() {
163            Ok(handle) => handle.block_on(future),
164            Err(_) => crate::runtime::block_on(future),
165        }
166    }
167
168    /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
169    static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
170
171    extern "C" fn on_sigwinch(_signum: libc::c_int) {
172        RESIZE_PENDING.store(true, Ordering::Relaxed);
173    }
174
175    /// Drive the local terminal against a PTY exec until the remote process
176    /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
177    /// always restored, even on error.
178    pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
179        let saved = enter_raw_mode()?;
180        let prev_winch = install_sigwinch();
181        let prev_flags = set_stdin_nonblocking();
182
183        // Seed the remote PTY with the current size.
184        let (cols, rows) = terminal_size();
185        block_on(proc.resize(cols, rows));
186
187        let stop = Arc::new(AtomicBool::new(false));
188        let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
189
190        drive_input(&proc, &stop);
191
192        // Tear down in reverse order so the terminal is always usable afterwards.
193        let _ = output.join();
194        restore_stdin_flags(prev_flags);
195        restore_sigwinch(prev_winch);
196        restore_terminal(&saved);
197
198        let exit = block_on(proc.wait())?;
199        Ok(exit.exit_code)
200    }
201
202    /// Pump merged PTY output to stdout until the stream ends; then signal stop.
203    fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
204        thread::spawn(move || {
205            let mut reader = proc.reader(OutputStream::Stdout);
206            let mut stdout = std::io::stdout();
207            loop {
208                match reader.next(Duration::from_millis(100)) {
209                    ReadStep::Chunk(text) => {
210                        let _ = stdout.write_all(text.as_bytes());
211                        let _ = stdout.flush();
212                    }
213                    ReadStep::Eof => break,
214                    ReadStep::Pending => {}
215                }
216            }
217            stop.store(true, Ordering::Relaxed);
218        })
219    }
220
221    /// Forward raw stdin bytes to the guest, draining pending resizes, until the
222    /// output stream ends or local stdin closes.
223    fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
224        let mut buf = [0u8; 4096];
225        let mut stdin_open = true;
226        while !stop.load(Ordering::Relaxed) {
227            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
228                let (cols, rows) = terminal_size();
229                block_on(proc.resize(cols, rows));
230            }
231            if !stdin_open {
232                thread::sleep(Duration::from_millis(20));
233                continue;
234            }
235            let n = unsafe {
236                libc::read(
237                    libc::STDIN_FILENO,
238                    buf.as_mut_ptr().cast::<libc::c_void>(),
239                    buf.len(),
240                )
241            };
242            match n.cmp(&0) {
243                std::cmp::Ordering::Greater => {
244                    if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
245                        break; // remote closed stdin or exec ended
246                    }
247                }
248                std::cmp::Ordering::Equal => {
249                    // Local stdin reached EOF: send EOF and stop reading it, but
250                    // keep draining output until the remote process exits.
251                    let _ = block_on(proc.close_stdin());
252                    stdin_open = false;
253                }
254                std::cmp::Ordering::Less => {
255                    // A nonblocking read with no data yet (WouldBlock), or one a
256                    // handled signal such as SIGWINCH interrupted (Interrupted),
257                    // is transient: back off briefly and retry rather than ending
258                    // the input loop, which would wedge stdin until the command
259                    // exits.
260                    let err = std::io::Error::last_os_error();
261                    if matches!(
262                        err.kind(),
263                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
264                    ) {
265                        thread::sleep(Duration::from_millis(10));
266                    } else {
267                        break;
268                    }
269                }
270            }
271        }
272    }
273
274    // --- platform terminal plumbing ---
275
276    /// Put the local terminal into raw mode, returning the saved settings.
277    fn enter_raw_mode() -> Result<libc::termios, SailError> {
278        unsafe {
279            let mut saved: libc::termios = std::mem::zeroed();
280            if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
281                return Err(SailError::Internal {
282                    message: format!(
283                        "could not enter raw terminal mode: {}",
284                        std::io::Error::last_os_error()
285                    ),
286                });
287            }
288            let mut raw = saved;
289            libc::cfmakeraw(&raw mut raw);
290            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
291                return Err(SailError::Internal {
292                    message: format!(
293                        "could not enter raw terminal mode: {}",
294                        std::io::Error::last_os_error()
295                    ),
296                });
297            }
298            Ok(saved)
299        }
300    }
301
302    fn restore_terminal(saved: &libc::termios) {
303        unsafe {
304            let _ = libc::tcsetattr(
305                libc::STDIN_FILENO,
306                libc::TCSADRAIN,
307                std::ptr::from_ref(saved),
308            );
309        }
310    }
311
312    type SigHandler = libc::sighandler_t;
313
314    fn install_sigwinch() -> SigHandler {
315        // `signal` takes the handler as a numeric `sighandler_t`; cast through a
316        // concrete fn pointer first so this is a pointer-to-int cast, not a
317        // fn-item-to-int cast.
318        let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
319        unsafe { libc::signal(libc::SIGWINCH, handler) }
320    }
321
322    fn restore_sigwinch(prev: SigHandler) {
323        unsafe {
324            libc::signal(libc::SIGWINCH, prev);
325        }
326    }
327
328    /// Put stdin into non-blocking mode so the input loop can interleave reads
329    /// with resize handling and the stop flag. Returns the previous fcntl flags.
330    fn set_stdin_nonblocking() -> libc::c_int {
331        unsafe {
332            let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
333            if flags >= 0 {
334                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
335            }
336            flags
337        }
338    }
339
340    fn restore_stdin_flags(flags: libc::c_int) {
341        if flags >= 0 {
342            unsafe {
343                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
344            }
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn login_shell_quotes_an_explicit_path() {
355        // A path with spaces runs as one literal program.
356        assert_eq!(
357            login_shell_command(Some("/opt/my tools/zsh")),
358            "exec '/opt/my tools/zsh' -l"
359        );
360        // The default stays unquoted so the guest expands $SHELL.
361        assert_eq!(login_shell_command(None), "exec ${SHELL:-/bin/bash} -l");
362    }
363
364    #[tokio::test]
365    async fn shell_requires_a_tty() {
366        // Test processes have no TTY on stdin/stdout, so the precondition
367        // fires before any network or terminal manipulation.
368        let client = crate::Client::builder("sk_test")
369            .api_url("http://127.0.0.1:1")
370            .sailbox_api_url("http://127.0.0.1:1")
371            .build()
372            .expect("build");
373        let err = client
374            .sailbox("sb_test")
375            .shell(None, ShellOptions::default())
376            .await
377            .expect_err("no tty in tests");
378        assert!(err.to_string().contains("interactive terminal"), "{err}");
379    }
380}