Skip to main content

sail/
shell.rs

1//! Interactive terminal session against a `pty` command in a Sailbox.
2//!
3//! [`Sailbox::shell`](crate::Sailbox::shell) is the high-level entry; the CLI
4//! drives [`run_interactive`] directly for its `--tty` flows. It puts the local
5//! terminal in raw mode, forwards keystrokes (so Ctrl-C/Ctrl-D reach the remote
6//! process as signals), renders the merged output, propagates window resizes,
7//! and restores the terminal on exit. Unix-only: on other platforms the calls
8//! return an unsupported error and the build still succeeds.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use crate::error::{RpcStatus, SailError};
14use crate::exec::ExecOptions;
15use crate::sailbox::object::Sailbox;
16
17/// Options for [`Sailbox::shell`].
18#[derive(Debug, Clone, Default)]
19pub struct ShellOptions {
20    /// Login shell to run when no command is given (default: the guest's
21    /// `$SHELL`, else `/bin/bash`). Ignored when a command is given.
22    pub shell: Option<String>,
23    /// `$TERM` for the remote pty (default: the local `$TERM`).
24    pub term: Option<String>,
25    /// Working directory for the session.
26    pub cwd: Option<String>,
27    /// Wall-clock limit for the session; `None` means no limit.
28    pub timeout: Option<Duration>,
29    /// Do not let the box open the user's browser or forward its localhost
30    /// servers (both on by default). Set for an untrusted or automated session.
31    pub no_forward: bool,
32    /// Turn off browser opens only, keeping localhost server forwarding on.
33    /// Ignored when `no_forward` is set.
34    pub no_forward_browser: bool,
35}
36
37/// True when stdin and stdout are both TTYs, required for an interactive PTY.
38#[doc(hidden)]
39pub fn stdio_is_tty() -> bool {
40    use std::io::IsTerminal;
41    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
42}
43
44fn tty_required() -> SailError {
45    SailError::Execution {
46        code: RpcStatus::FailedPrecondition,
47        detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
48            .to_string(),
49    }
50}
51
52impl Sailbox {
53    /// Open an interactive pty session on the Sailbox, driving the local
54    /// terminal. With no `command`, runs a login shell; pass a command to run
55    /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
56    /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
57    /// process, its output renders locally, and terminal resizes propagate.
58    /// Blocks until the remote process exits and returns its exit code.
59    /// Requires an interactive local terminal (stdin and stdout TTYs). While
60    /// the session is open, browser opens and localhost servers in the box
61    /// are forwarded to the local machine; see [`ShellOptions::no_forward`].
62    ///
63    /// Runs on the local machine, which must be Unix (it needs Unix TTY and
64    /// signal APIs).
65    ///
66    /// This is the one process-global API in the crate: for the session's
67    /// duration it owns stdin/stdout, switches the terminal to raw mode, and
68    /// installs a signal handler, restoring them when the session ends. The
69    /// bridge runs on a blocking thread, so cancelling this future does not
70    /// end the session; stop it by exiting the remote process.
71    pub async fn shell(
72        &self,
73        command: Option<&str>,
74        options: ShellOptions,
75    ) -> Result<i32, SailError> {
76        if !stdio_is_tty() {
77            return Err(tty_required());
78        }
79        let command = match command {
80            Some(command) => command.to_string(),
81            None => login_shell_command(options.shell.as_deref()),
82        };
83        let (cols, rows) = terminal_size();
84        // An interactive shell forwards the session's localhost servers and browser
85        // opens to the user's machine unless opted out.
86        let (forward_ports, forward_browser) =
87            crate::exec::forward_flags(options.no_forward, options.no_forward_browser);
88        let proc = self
89            .client()
90            .exec_shell(
91                self.sailbox_id(),
92                &command,
93                ExecOptions {
94                    timeout: options.timeout,
95                    pty: true,
96                    term: options
97                        .term
98                        .or_else(|| std::env::var("TERM").ok())
99                        .unwrap_or_default(),
100                    cols,
101                    rows,
102                    cwd: options.cwd,
103                    forward_ports,
104                    forward_browser,
105                    ..Default::default()
106                },
107            )
108            .await?;
109        let proc = Arc::new(proc);
110        tokio::task::spawn_blocking(move || run_interactive(proc))
111            .await
112            .map_err(|err| SailError::Internal {
113                message: format!("shell bridge task failed: {err}"),
114            })?
115    }
116}
117
118/// The command for an interactive login session: `exec` the login shell so
119/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
120/// with spaces runs as a literal program; the default stays unquoted so the
121/// guest shell expands `$SHELL`.
122fn login_shell_command(shell: Option<&str>) -> String {
123    match shell {
124        Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
125        None => "exec ${SHELL:-/bin/bash} -l".to_string(),
126    }
127}
128
129/// The local terminal size as (cols, rows), defaulting to 80x24.
130#[cfg(unix)]
131#[doc(hidden)]
132pub fn terminal_size() -> (u32, u32) {
133    let mut size = libc::winsize {
134        ws_row: 0,
135        ws_col: 0,
136        ws_xpixel: 0,
137        ws_ypixel: 0,
138    };
139    let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
140    if ok && size.ws_col > 0 && size.ws_row > 0 {
141        (u32::from(size.ws_col), u32::from(size.ws_row))
142    } else {
143        (80, 24)
144    }
145}
146
147/// The local terminal size as (cols, rows), defaulting to 80x24.
148#[cfg(not(unix))]
149#[doc(hidden)]
150pub fn terminal_size() -> (u32, u32) {
151    (80, 24)
152}
153
154/// Interactive PTY sessions need Unix TTY and signal APIs.
155#[cfg(not(unix))]
156#[doc(hidden)]
157pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
158    Err(SailError::Execution {
159        code: RpcStatus::Unimplemented,
160        detail: "interactive PTY sessions are not supported on this platform".to_string(),
161    })
162}
163
164#[cfg(unix)]
165#[doc(hidden)]
166pub use unix::run_interactive;
167
168#[cfg(unix)]
169#[doc(hidden)]
170pub use unix::drive_output_pump;
171
172#[cfg(unix)]
173mod unix {
174    use std::collections::{HashMap, HashSet};
175    use std::io::Write;
176    use std::sync::atomic::{AtomicBool, Ordering};
177    use std::sync::Arc;
178    use std::thread;
179    use std::time::{Duration, Instant};
180
181    use super::terminal_size;
182    use crate::error::{RpcStatus, SailError};
183    use crate::exec::{ExecProcess, ForwardEvent, OutputStream, ReadStep};
184
185    /// Drive a future to completion from this bridge's dedicated thread. On
186    /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
187    /// ambient handle exists and `Handle::block_on` is the correct, safe
188    /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
189    /// back to the crate's shared-runtime `block_on`.
190    fn block_on<F: std::future::Future>(future: F) -> F::Output {
191        match tokio::runtime::Handle::try_current() {
192            Ok(handle) => handle.block_on(future),
193            Err(_) => crate::runtime::block_on(future),
194        }
195    }
196
197    /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
198    static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
199
200    extern "C" fn on_sigwinch(_signum: libc::c_int) {
201        RESIZE_PENDING.store(true, Ordering::Relaxed);
202    }
203
204    /// Drive the local terminal against a PTY exec until the remote process
205    /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
206    /// always restored, even on error.
207    pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
208        let saved = enter_raw_mode()?;
209        let prev_winch = install_sigwinch();
210        let prev_in_flags = set_stdin_nonblocking();
211        // Non-blocking stdout so the output pump is never parked in a write to a
212        // slow terminal: it must stay free to notice the ring dropped and repaint.
213        let prev_out_flags = set_stdout_nonblocking();
214
215        // Seed the remote PTY with the current size.
216        let (cols, rows) = terminal_size();
217        block_on(proc.resize(cols, rows));
218
219        let stop = Arc::new(AtomicBool::new(false));
220        let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
221        let forward = spawn_forward_consumer(Arc::clone(&proc));
222
223        drive_input(&proc, &stop);
224
225        // Tear down in reverse order so the terminal is always usable afterwards.
226        let _ = output.join();
227        let _ = forward.join();
228        restore_stdout_flags(prev_out_flags);
229        restore_stdin_flags(prev_in_flags);
230        restore_sigwinch(prev_winch);
231        restore_terminal(&saved);
232
233        // A witnessed Exit is the command's real result. When the stream ended
234        // without one, the command did not exit; the box was parked (put to
235        // sleep) or otherwise became unreachable mid-session. Report that instead
236        // of calling wait(), which would block forever on an Exit an interactive
237        // shell never emits; the box's session stays intact for a fresh reconnect.
238        match proc.try_wait() {
239            Some(result) => result,
240            None => Err(SailError::Execution {
241                code: RpcStatus::Unavailable,
242                detail: format!(
243                    "the box became unavailable and the shell session ended; \
244                     reconnect with `sail box shell {}`",
245                    proc.sailbox_id(),
246                ),
247            }),
248        }
249    }
250
251    /// Least time between screen-repaint requests while the local terminal is
252    /// too slow to keep up: without a bound a persistently-behind reader would
253    /// ask on every drop and flood the guest with resync RPCs. Capping repaint
254    /// requests to one per 100 ms is plenty to keep the screen current.
255    const RESYNC_MIN_INTERVAL: Duration = Duration::from_millis(100);
256
257    /// Most backlog the pump buffers toward the terminal before it stops draining
258    /// the ring. Holding the cap small means a slow terminal quickly lets the
259    /// ring back up and drop-oldest, which the reader reports as a drop — the
260    /// signal that triggers a repaint. Larger would just make the terminal crawl
261    /// further through stale frames before recovering.
262    const OUTPUT_PENDING_CAP: usize = 256 * 1024;
263
264    /// Spawn the thread that renders merged PTY output to the terminal, then
265    /// signals stop when the stream ends. The terminal fd is already non-blocking
266    /// (set by [`run_interactive`]).
267    fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
268        thread::spawn(move || {
269            let mut reader = proc.reader(OutputStream::Stdout);
270            let mut sink = RawFdWriter(libc::STDOUT_FILENO);
271            drive_output_pump(&mut reader, &mut sink, &proc);
272            stop.store(true, Ordering::Relaxed);
273        })
274    }
275
276    /// Least time between retries of a port that could not be forwarded because
277    /// its local port was busy. The port watcher only re-reports the guest's
278    /// listeners when the set changes, so this retry covers a local port freeing
279    /// up while the guest server keeps running.
280    const FORWARD_RETRY_INTERVAL: Duration = Duration::from_secs(3);
281
282    /// Spawn the thread that acts on the session's local-forwarding events. It
283    /// drains until the stream ends (the accessor then returns `None`). Active
284    /// port forwards are held for the life of the session and dropped on exit.
285    fn spawn_forward_consumer(proc: Arc<ExecProcess>) -> thread::JoinHandle<()> {
286        thread::spawn(move || {
287            let mut forwards: HashMap<u16, crate::forward::PortForward> = HashMap::new();
288            // Ports whose local port was busy, so the forward could not bind. Kept
289            // so the bind is retried on the interval below in case the local port
290            // frees up.
291            let mut conflicts: HashSet<u16> = HashSet::new();
292            loop {
293                // Wait for the next event, but only until the retry interval when
294                // there are conflicts to re-attempt; otherwise wait indefinitely.
295                let next: Result<Option<ForwardEvent>, tokio::time::error::Elapsed> =
296                    if conflicts.is_empty() {
297                        Ok(block_on(proc.next_forward_event()))
298                    } else {
299                        block_on(async {
300                            tokio::time::timeout(FORWARD_RETRY_INTERVAL, proc.next_forward_event())
301                                .await
302                        })
303                    };
304                match next {
305                    Ok(Some(ForwardEvent::OpenUrl(url))) => {
306                        let open = if !is_openable_scheme(&url) {
307                            // open_local_url only opens http(s); skip building a
308                            // forward for a URL it would refuse anyway.
309                            false
310                        } else if let Some(port) = crate::forward::forwardable_local_port(&url) {
311                            // A URL for a server in the box: forward its port, then
312                            // open the bound local address (rewritten below). If it
313                            // can't be forwarded, don't open it against the user's
314                            // own machine. A login's localhost callback is a server
315                            // too, so the port watcher forwards it the same way.
316                            if ensure_forward(&proc, &mut forwards, &mut conflicts, port) {
317                                true
318                            } else {
319                                notify_local_port_busy(&url, port);
320                                false
321                            }
322                        } else if crate::forward::is_unforwardable_loopback_url(&url) {
323                            // A loopback URL the tunnel can't reach: opening it would
324                            // hit the user's own machine, not the sandbox.
325                            notify_loopback_unreachable(&url);
326                            false
327                        } else if let Some(callback) = crate::forward::redirect_callback(&url) {
328                            // An external login URL whose redirect returns to a
329                            // loopback callback. Don't start a login whose redirect,
330                            // carrying the auth code, would hit the user's machine
331                            // rather than the sandbox.
332                            match callback {
333                                // Open only once the callback port is actually
334                                // forwarded. The snapshot precedes this URL, so a
335                                // listening forwardable callback is already in
336                                // `forwards`; a port not there is one whose local
337                                // port is busy or whose server is not listening on a
338                                // reachable address, and an immediate redirect would
339                                // hit the user's own machine.
340                                crate::forward::RedirectCallback::Forwardable(port)
341                                    if forwards.contains_key(&port) =>
342                                {
343                                    true
344                                }
345                                crate::forward::RedirectCallback::Forwardable(port) => {
346                                    notify_callback_unforwarded(&url, port);
347                                    false
348                                }
349                                // A loopback the tunnel cannot dial at all.
350                                crate::forward::RedirectCallback::Unreachable => {
351                                    notify_callback_unreachable(&url);
352                                    false
353                                }
354                            }
355                        } else {
356                            // An external URL with no localhost callback: open it.
357                            true
358                        };
359                        if open {
360                            let url = crate::forward::rewrite_loopback_url(&url, |remote| {
361                                forwards
362                                    .get(&remote)
363                                    .map(crate::forward::PortForward::local_port)
364                            });
365                            open_local_url(&url);
366                        }
367                    }
368                    Ok(Some(ForwardEvent::PortSnapshot(ports))) => {
369                        // Reconcile against the authoritative set: drop forwards and
370                        // conflicts for servers that are gone, then forward the rest.
371                        let listening: HashSet<u16> = ports.iter().copied().collect();
372                        forwards.retain(|port, _| listening.contains(port));
373                        conflicts.retain(|port| listening.contains(port));
374                        for port in ports {
375                            ensure_forward(&proc, &mut forwards, &mut conflicts, port);
376                        }
377                    }
378                    // The stream ended.
379                    Ok(None) => break,
380                    // No event within the interval: retry any port whose local port
381                    // was busy, in case it has since freed up.
382                    Err(_) => {
383                        for port in conflicts.iter().copied().collect::<Vec<_>>() {
384                            ensure_forward(&proc, &mut forwards, &mut conflicts, port);
385                        }
386                    }
387                }
388            }
389        })
390    }
391
392    /// Forward `port` (guest to the same local port) if it is not already
393    /// forwarded. Returns whether the port is now forwarded. The local port
394    /// always matches the guest port and is never remapped: a login callback
395    /// redirect targets that exact port, so binding elsewhere would send the
396    /// browser to whatever already holds the local port rather than the sandbox.
397    /// A busy local port is recorded in `conflicts` and retried on the interval.
398    fn ensure_forward(
399        proc: &Arc<ExecProcess>,
400        forwards: &mut HashMap<u16, crate::forward::PortForward>,
401        conflicts: &mut HashSet<u16>,
402        port: u16,
403    ) -> bool {
404        if forwards.contains_key(&port) {
405            return true;
406        }
407        if let Ok(forward) = block_on(proc.forward_port(port, port)) {
408            forwards.insert(port, forward);
409            conflicts.remove(&port);
410            true
411        } else {
412            conflicts.insert(port);
413            false
414        }
415    }
416
417    /// Notify that a URL the box asked to open targets a loopback address the
418    /// sandbox cannot reach, so it was not opened against the user's own machine.
419    fn notify_loopback_unreachable(url: &str) {
420        let notice = format!(
421            "\r\n[sail] not opening {url}: it targets a loopback address the sandbox cannot reach\r\n"
422        );
423        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
424    }
425
426    /// Notify that a box server was not opened because its port is already in use
427    /// on the local machine, so the forward could not bind it.
428    fn notify_local_port_busy(url: &str, port: u16) {
429        let notice = format!("\r\n[sail] not opening {url}: local port {port} is in use\r\n");
430        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
431    }
432
433    /// Whether `open_local_url` would open this URL (it opens only http(s)).
434    fn is_openable_scheme(url: &str) -> bool {
435        url.starts_with("http://") || url.starts_with("https://")
436    }
437
438    /// Notify that a login was not opened because its localhost callback port is
439    /// not forwarded (its local port is busy, or its server is not listening on a
440    /// reachable address), so the provider's redirect could not reach the sandbox.
441    fn notify_callback_unforwarded(url: &str, port: u16) {
442        let notice = format!(
443            "\r\n[sail] not opening {url}: its login callback port {port} is not forwarded\r\n"
444        );
445        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
446    }
447
448    /// Notify that a login was not opened because its callback is a loopback
449    /// address the sandbox cannot reach, so the redirect would hit the user's
450    /// own machine.
451    fn notify_callback_unreachable(url: &str) {
452        let notice = format!(
453            "\r\n[sail] not opening {url}: its login callback is a loopback address the sandbox cannot reach\r\n"
454        );
455        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
456    }
457
458    /// Open a URL in the user's local browser, best-effort. Only http(s) URLs
459    /// are opened, so a sandbox process cannot drive arbitrary local handlers.
460    /// The child inherits no terminal, so an opener's own output can't corrupt
461    /// the session. Silent on success: the browser tab appearing is the signal.
462    fn open_local_url(url: &str) {
463        if !is_openable_scheme(url) {
464            return;
465        }
466        let _ = local_browser_command(url)
467            .stdin(std::process::Stdio::null())
468            .stdout(std::process::Stdio::null())
469            .stderr(std::process::Stdio::null())
470            .spawn();
471    }
472
473    /// The platform command that opens a URL in the default browser. This
474    /// module is Unix-only, so the choice is macOS `open` or Linux `xdg-open`.
475    fn local_browser_command(url: &str) -> std::process::Command {
476        let program = if cfg!(target_os = "macos") {
477            "open"
478        } else {
479            "xdg-open"
480        };
481        let mut command = std::process::Command::new(program);
482        command.arg(url);
483        command
484    }
485
486    /// Render one live output stream onto a terminal `sink` until the stream
487    /// ends, favoring a current screen over a faithful replay.
488    ///
489    /// The terminal writer must never block the loop: a slow terminal has to keep
490    /// the pump free to notice the ring dropped and ask the guest to repaint the
491    /// current screen ([`ExecProcess::resync`]). So `sink` is written
492    /// non-blockingly, backlog is held to [`OUTPUT_PENDING_CAP`] so the ring
493    /// backs up and drops-oldest when the terminal falls behind, and a reported
494    /// drop discards the torn backlog and requests a repaint rather than crawling
495    /// the slow terminal through stale frames it will never catch. The command is
496    /// detached on the server, so none of this ever blocks it.
497    ///
498    /// Generic over the sink so the drop-to-repaint behavior is testable against a
499    /// deliberately slow writer without a real terminal.
500    #[doc(hidden)]
501    pub fn drive_output_pump<W: Write>(
502        reader: &mut crate::exec::StreamReader,
503        sink: &mut W,
504        proc: &Arc<ExecProcess>,
505    ) {
506        let mut pending: Vec<u8> = Vec::new();
507        let mut last_resync: Option<Instant> = None;
508        // Hold an observed drop until a repaint is actually requested. resync_due
509        // only fires once per RESYNC_MIN_INTERVAL, so a drop seen during that
510        // cooldown would otherwise be forgotten, leaving the screen showing a
511        // torn, partial frame.
512        let mut resync_pending = false;
513        loop {
514            // Push as much backlog as the terminal accepts right now, without
515            // blocking on it.
516            let mut flushed = false;
517            if !pending.is_empty() {
518                let written = write_nonblocking(sink, &pending);
519                if written > 0 {
520                    pending.drain(..written);
521                    flushed = true;
522                }
523            }
524            // Refill from the ring, but only up to the cap: leaving the rest in
525            // the ring lets it back up and drop-oldest when the terminal is slow.
526            let mut progressed = false;
527            if pending.len() < OUTPUT_PENDING_CAP {
528                // Don't wait for new data while there is still backlog to push.
529                let wait = if pending.is_empty() {
530                    Duration::from_millis(50)
531                } else {
532                    Duration::ZERO
533                };
534                match reader.next(wait) {
535                    ReadStep::Chunk(bytes) => {
536                        // A Snapshot reset the ring: `bytes` is the repaint, and
537                        // it supersedes the stale backlog buffered toward the
538                        // terminal. Drop that backlog before queuing the repaint
539                        // so the finished screen renders at once instead of stuck
540                        // behind bytes the slow terminal will never finish
541                        // draining (the bounded end-of-stream flush would give up
542                        // before reaching it).
543                        if reader.took_reset() {
544                            pending.clear();
545                        }
546                        pending.extend_from_slice(&bytes);
547                        progressed = true;
548                    }
549                    ReadStep::Eof => {
550                        flush_blocking(sink, &pending);
551                        return;
552                    }
553                    ReadStep::Pending => {}
554                }
555                while pending.len() < OUTPUT_PENDING_CAP {
556                    match reader.try_next() {
557                        // Honor a reset here too: the repaint can land in this
558                        // batch drain when the Snapshot arrives after next()
559                        // above already returned a stale chunk this iteration.
560                        Some(more) => {
561                            if reader.took_reset() {
562                                pending.clear();
563                            }
564                            pending.extend_from_slice(&more);
565                        }
566                        None => break,
567                    }
568                }
569            }
570            // The ring evicted output we had not shown: the backlog is now a torn
571            // tail, so drop it and repaint the current screen instead.
572            if reader.took_drop() {
573                pending.clear();
574                resync_pending = true;
575            }
576            if resync_pending && resync_due(&mut last_resync) {
577                resync_pending = false;
578                let handle = Arc::clone(proc);
579                crate::runtime::runtime().spawn(async move { handle.resync().await });
580            }
581            // Yield when no new ring data was read and bytes are still queued,
582            // either because the backlog is at the cap (so the ring can back up
583            // and drop-oldest for a slow terminal) or because the terminal is
584            // back-pressured and accepted nothing (so the loop does not spin).
585            // A terminal actively draining a partial backlog is making progress,
586            // so it keeps looping.
587            if !progressed
588                && !pending.is_empty()
589                && (pending.len() >= OUTPUT_PENDING_CAP || !flushed)
590            {
591                thread::sleep(Duration::from_millis(5));
592            }
593        }
594    }
595
596    /// Write what the terminal will take right now, returning the bytes accepted.
597    /// A full terminal (`WouldBlock`), or any transient error, accepts zero and
598    /// the caller keeps the rest rather than propagating a terminal write error.
599    fn write_nonblocking<W: Write>(sink: &mut W, buf: &[u8]) -> usize {
600        sink.write(buf).unwrap_or(0)
601    }
602
603    /// End of stream: land the final bytes even against a non-blocking terminal,
604    /// but bounded so a wedged terminal cannot hang the exit.
605    fn flush_blocking<W: Write>(sink: &mut W, buf: &[u8]) {
606        let mut off = 0;
607        for _ in 0..2000 {
608            if off >= buf.len() {
609                break;
610            }
611            match sink.write(&buf[off..]) {
612                Ok(0) => thread::sleep(Duration::from_millis(1)),
613                Ok(n) => off += n,
614                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
615                    thread::sleep(Duration::from_millis(1));
616                }
617                Err(_) => break,
618            }
619        }
620        let _ = sink.flush();
621    }
622
623    /// A `Write` over a raw fd. On a non-blocking fd a full pipe surfaces as a
624    /// `WouldBlock` error rather than parking the thread.
625    struct RawFdWriter(libc::c_int);
626
627    impl Write for RawFdWriter {
628        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
629            let n = unsafe { libc::write(self.0, buf.as_ptr().cast(), buf.len()) };
630            if n < 0 {
631                Err(std::io::Error::last_os_error())
632            } else {
633                Ok(n as usize)
634            }
635        }
636
637        fn flush(&mut self) -> std::io::Result<()> {
638            Ok(())
639        }
640    }
641
642    /// Whether enough time has passed since the last repaint request to send
643    /// another, stamping the clock when it returns true.
644    fn resync_due(last: &mut Option<Instant>) -> bool {
645        let now = Instant::now();
646        if last.is_none_or(|t| now.duration_since(t) >= RESYNC_MIN_INTERVAL) {
647            *last = Some(now);
648            true
649        } else {
650            false
651        }
652    }
653
654    /// Forward raw stdin bytes to the guest, draining pending resizes, until the
655    /// output stream ends or local stdin closes.
656    fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
657        let mut buf = [0u8; 4096];
658        let mut stdin_open = true;
659        while !stop.load(Ordering::Relaxed) {
660            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
661                let (cols, rows) = terminal_size();
662                block_on(proc.resize(cols, rows));
663            }
664            if !stdin_open {
665                thread::sleep(Duration::from_millis(20));
666                continue;
667            }
668            let n = unsafe {
669                libc::read(
670                    libc::STDIN_FILENO,
671                    buf.as_mut_ptr().cast::<libc::c_void>(),
672                    buf.len(),
673                )
674            };
675            match n.cmp(&0) {
676                std::cmp::Ordering::Greater => {
677                    if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
678                        break; // remote closed stdin or exec ended
679                    }
680                }
681                std::cmp::Ordering::Equal => {
682                    // Local stdin reached EOF: send EOF and stop reading it, but
683                    // keep draining output until the remote process exits.
684                    let _ = block_on(proc.close_stdin());
685                    stdin_open = false;
686                }
687                std::cmp::Ordering::Less => {
688                    // A nonblocking read with no data yet (WouldBlock), or one a
689                    // handled signal such as SIGWINCH interrupted (Interrupted),
690                    // is transient: back off briefly and retry rather than ending
691                    // the input loop, which would wedge stdin until the command
692                    // exits.
693                    let err = std::io::Error::last_os_error();
694                    if matches!(
695                        err.kind(),
696                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
697                    ) {
698                        thread::sleep(Duration::from_millis(10));
699                    } else {
700                        break;
701                    }
702                }
703            }
704        }
705    }
706
707    // --- platform terminal plumbing ---
708
709    /// Put the local terminal into raw mode, returning the saved settings.
710    fn enter_raw_mode() -> Result<libc::termios, SailError> {
711        unsafe {
712            let mut saved: libc::termios = std::mem::zeroed();
713            if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
714                return Err(SailError::Internal {
715                    message: format!(
716                        "could not enter raw terminal mode: {}",
717                        std::io::Error::last_os_error()
718                    ),
719                });
720            }
721            let mut raw = saved;
722            libc::cfmakeraw(&raw mut raw);
723            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
724                return Err(SailError::Internal {
725                    message: format!(
726                        "could not enter raw terminal mode: {}",
727                        std::io::Error::last_os_error()
728                    ),
729                });
730            }
731            Ok(saved)
732        }
733    }
734
735    fn restore_terminal(saved: &libc::termios) {
736        unsafe {
737            let _ = libc::tcsetattr(
738                libc::STDIN_FILENO,
739                libc::TCSADRAIN,
740                std::ptr::from_ref(saved),
741            );
742        }
743    }
744
745    type SigHandler = libc::sighandler_t;
746
747    fn install_sigwinch() -> SigHandler {
748        // `signal` takes the handler as a numeric `sighandler_t`; cast through a
749        // concrete fn pointer first so this is a pointer-to-int cast, not a
750        // fn-item-to-int cast.
751        let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
752        unsafe { libc::signal(libc::SIGWINCH, handler) }
753    }
754
755    fn restore_sigwinch(prev: SigHandler) {
756        unsafe {
757            libc::signal(libc::SIGWINCH, prev);
758        }
759    }
760
761    /// Put stdin into non-blocking mode so the input loop can interleave reads
762    /// with resize handling and the stop flag. Returns the previous fcntl flags.
763    fn set_stdin_nonblocking() -> libc::c_int {
764        unsafe {
765            let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
766            if flags >= 0 {
767                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
768            }
769            flags
770        }
771    }
772
773    fn restore_stdin_flags(flags: libc::c_int) {
774        if flags >= 0 {
775            unsafe {
776                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
777            }
778        }
779    }
780
781    /// Put stdout into non-blocking mode so the output pump is never parked in a
782    /// write to a slow terminal. Returns the previous fcntl flags.
783    fn set_stdout_nonblocking() -> libc::c_int {
784        unsafe {
785            let flags = libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFL);
786            if flags >= 0 {
787                libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
788            }
789            flags
790        }
791    }
792
793    fn restore_stdout_flags(flags: libc::c_int) {
794        if flags >= 0 {
795            unsafe {
796                libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags);
797            }
798        }
799    }
800
801    #[cfg(test)]
802    mod tests {
803        use super::*;
804
805        #[test]
806        fn resync_due_throttles_back_to_back_requests() {
807            let mut last = None;
808            // The first request is always due and stamps the clock.
809            assert!(resync_due(&mut last));
810            // A second request within RESYNC_MIN_INTERVAL is suppressed, so a
811            // persistently-behind reader cannot flood the guest with resync RPCs.
812            assert!(!resync_due(&mut last));
813        }
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn login_shell_quotes_an_explicit_path() {
823        // A path with spaces runs as one literal program.
824        assert_eq!(
825            login_shell_command(Some("/opt/my tools/zsh")),
826            "exec '/opt/my tools/zsh' -l"
827        );
828        // The default stays unquoted so the guest expands $SHELL.
829        assert_eq!(
830            login_shell_command(/* shell */ None),
831            "exec ${SHELL:-/bin/bash} -l"
832        );
833    }
834
835    #[tokio::test]
836    async fn shell_requires_a_tty() {
837        // Test processes have no TTY on stdin/stdout, so the precondition
838        // fires before any network or terminal manipulation.
839        let client = crate::Client::builder("sk_test")
840            .api_url("http://127.0.0.1:1")
841            .sailbox_api_url("http://127.0.0.1:1")
842            .build()
843            .expect("build");
844        let err = client
845            .sailbox("sb_test")
846            .shell(/* command */ None, ShellOptions::default())
847            .await
848            .expect_err("no tty in tests");
849        assert!(err.to_string().contains("interactive terminal"), "{err}");
850    }
851}