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}