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)]
150mod unix {
151    use std::io::Write;
152    use std::sync::atomic::{AtomicBool, Ordering};
153    use std::sync::Arc;
154    use std::thread;
155    use std::time::Duration;
156
157    use super::terminal_size;
158    use crate::error::SailError;
159    use crate::exec::{ExecProcess, OutputStream, ReadStep};
160
161    /// Drive a future to completion from this bridge's dedicated thread. On
162    /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
163    /// ambient handle exists and `Handle::block_on` is the correct, safe
164    /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
165    /// back to the crate's shared-runtime `block_on`.
166    fn block_on<F: std::future::Future>(future: F) -> F::Output {
167        match tokio::runtime::Handle::try_current() {
168            Ok(handle) => handle.block_on(future),
169            Err(_) => crate::runtime::block_on(future),
170        }
171    }
172
173    /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
174    static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
175
176    extern "C" fn on_sigwinch(_signum: libc::c_int) {
177        RESIZE_PENDING.store(true, Ordering::Relaxed);
178    }
179
180    /// Drive the local terminal against a PTY exec until the remote process
181    /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
182    /// always restored, even on error.
183    pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
184        let saved = enter_raw_mode()?;
185        let prev_winch = install_sigwinch();
186        let prev_flags = set_stdin_nonblocking();
187
188        // Seed the remote PTY with the current size.
189        let (cols, rows) = terminal_size();
190        block_on(proc.resize(cols, rows));
191
192        let stop = Arc::new(AtomicBool::new(false));
193        let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
194
195        drive_input(&proc, &stop);
196
197        // Tear down in reverse order so the terminal is always usable afterwards.
198        let _ = output.join();
199        restore_stdin_flags(prev_flags);
200        restore_sigwinch(prev_winch);
201        restore_terminal(&saved);
202
203        let exit = block_on(proc.wait())?;
204        Ok(exit.exit_code)
205    }
206
207    /// Pump merged PTY output to stdout until the stream ends; then signal stop.
208    fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
209        thread::spawn(move || {
210            let mut reader = proc.reader(OutputStream::Stdout);
211            let mut stdout = std::io::stdout();
212            loop {
213                match reader.next(Duration::from_millis(100)) {
214                    ReadStep::Chunk(text) => {
215                        let _ = stdout.write_all(text.as_bytes());
216                        let _ = stdout.flush();
217                    }
218                    ReadStep::Eof => break,
219                    ReadStep::Pending => {}
220                }
221            }
222            stop.store(true, Ordering::Relaxed);
223        })
224    }
225
226    /// Forward raw stdin bytes to the guest, draining pending resizes, until the
227    /// output stream ends or local stdin closes.
228    fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
229        let mut buf = [0u8; 4096];
230        let mut stdin_open = true;
231        while !stop.load(Ordering::Relaxed) {
232            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
233                let (cols, rows) = terminal_size();
234                block_on(proc.resize(cols, rows));
235            }
236            if !stdin_open {
237                thread::sleep(Duration::from_millis(20));
238                continue;
239            }
240            let n = unsafe {
241                libc::read(
242                    libc::STDIN_FILENO,
243                    buf.as_mut_ptr().cast::<libc::c_void>(),
244                    buf.len(),
245                )
246            };
247            match n.cmp(&0) {
248                std::cmp::Ordering::Greater => {
249                    if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
250                        break; // remote closed stdin or exec ended
251                    }
252                }
253                std::cmp::Ordering::Equal => {
254                    // Local stdin reached EOF: send EOF and stop reading it, but
255                    // keep draining output until the remote process exits.
256                    let _ = block_on(proc.close_stdin());
257                    stdin_open = false;
258                }
259                std::cmp::Ordering::Less => {
260                    // A nonblocking read with no data yet (WouldBlock), or one a
261                    // handled signal such as SIGWINCH interrupted (Interrupted),
262                    // is transient: back off briefly and retry rather than ending
263                    // the input loop, which would wedge stdin until the command
264                    // exits.
265                    let err = std::io::Error::last_os_error();
266                    if matches!(
267                        err.kind(),
268                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
269                    ) {
270                        thread::sleep(Duration::from_millis(10));
271                    } else {
272                        break;
273                    }
274                }
275            }
276        }
277    }
278
279    // --- platform terminal plumbing ---
280
281    /// Put the local terminal into raw mode, returning the saved settings.
282    fn enter_raw_mode() -> Result<libc::termios, SailError> {
283        unsafe {
284            let mut saved: libc::termios = std::mem::zeroed();
285            if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
286                return Err(SailError::Internal {
287                    message: format!(
288                        "could not enter raw terminal mode: {}",
289                        std::io::Error::last_os_error()
290                    ),
291                });
292            }
293            let mut raw = saved;
294            libc::cfmakeraw(&raw mut raw);
295            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
296                return Err(SailError::Internal {
297                    message: format!(
298                        "could not enter raw terminal mode: {}",
299                        std::io::Error::last_os_error()
300                    ),
301                });
302            }
303            Ok(saved)
304        }
305    }
306
307    fn restore_terminal(saved: &libc::termios) {
308        unsafe {
309            let _ = libc::tcsetattr(
310                libc::STDIN_FILENO,
311                libc::TCSADRAIN,
312                std::ptr::from_ref(saved),
313            );
314        }
315    }
316
317    type SigHandler = libc::sighandler_t;
318
319    fn install_sigwinch() -> SigHandler {
320        // `signal` takes the handler as a numeric `sighandler_t`; cast through a
321        // concrete fn pointer first so this is a pointer-to-int cast, not a
322        // fn-item-to-int cast.
323        let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
324        unsafe { libc::signal(libc::SIGWINCH, handler) }
325    }
326
327    fn restore_sigwinch(prev: SigHandler) {
328        unsafe {
329            libc::signal(libc::SIGWINCH, prev);
330        }
331    }
332
333    /// Put stdin into non-blocking mode so the input loop can interleave reads
334    /// with resize handling and the stop flag. Returns the previous fcntl flags.
335    fn set_stdin_nonblocking() -> libc::c_int {
336        unsafe {
337            let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
338            if flags >= 0 {
339                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
340            }
341            flags
342        }
343    }
344
345    fn restore_stdin_flags(flags: libc::c_int) {
346        if flags >= 0 {
347            unsafe {
348                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
349            }
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn login_shell_quotes_an_explicit_path() {
360        // A path with spaces runs as one literal program.
361        assert_eq!(
362            login_shell_command(Some("/opt/my tools/zsh")),
363            "exec '/opt/my tools/zsh' -l"
364        );
365        // The default stays unquoted so the guest expands $SHELL.
366        assert_eq!(
367            login_shell_command(/* shell */ None),
368            "exec ${SHELL:-/bin/bash} -l"
369        );
370    }
371
372    #[tokio::test]
373    async fn shell_requires_a_tty() {
374        // Test processes have no TTY on stdin/stdout, so the precondition
375        // fires before any network or terminal manipulation.
376        let client = crate::Client::builder("sk_test")
377            .api_url("http://127.0.0.1:1")
378            .sailbox_api_url("http://127.0.0.1:1")
379            .build()
380            .expect("build");
381        let err = client
382            .sailbox("sb_test")
383            .shell(/* command */ None, ShellOptions::default())
384            .await
385            .expect_err("no tty in tests");
386        assert!(err.to_string().contains("interactive terminal"), "{err}");
387    }
388}