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