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 /// Turn off all local forwarding for the session (on by default): the
30 /// browser opens and localhost servers, plus paste, drag-and-drop, and
31 /// clipboard bridging. Every byte then passes through verbatim.
32 pub no_forward: bool,
33 /// Turn off forwarding the session's browser opens only, keeping everything
34 /// else forwarded. Ignored when `no_forward` is set.
35 pub no_forward_browser: bool,
36}
37
38/// True when stdin and stdout are both TTYs, required for an interactive PTY.
39#[doc(hidden)]
40pub fn stdio_is_tty() -> bool {
41 use std::io::IsTerminal;
42 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
43}
44
45fn tty_required() -> SailError {
46 SailError::Execution {
47 code: RpcStatus::FailedPrecondition,
48 detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
49 .to_string(),
50 }
51}
52
53impl Sailbox {
54 /// Open an interactive pty session on the Sailbox, driving the local
55 /// terminal. With no `command`, runs a login shell; pass a command to run
56 /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
57 /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
58 /// process, its output renders locally, and terminal resizes propagate.
59 /// Blocks until the remote process exits and returns its exit code.
60 /// Requires an interactive local terminal (stdin and stdout TTYs). While
61 /// the session is open, browser opens, localhost servers, paste, and
62 /// drag-and-drop are forwarded to the local machine, and Ctrl+V forwards
63 /// your clipboard (a two-way clipboard on devbox images, upload-and-paste
64 /// elsewhere); see [`ShellOptions::no_forward`].
65 ///
66 /// Runs on the local machine, which must be Unix (it needs Unix TTY and
67 /// signal APIs).
68 ///
69 /// This is the one process-global API in the crate: for the session's
70 /// duration it owns stdin/stdout, switches the terminal to raw mode, and
71 /// installs a signal handler, restoring them when the session ends. The
72 /// bridge runs on a blocking thread, so cancelling this future does not
73 /// end the session; stop it by exiting the remote process.
74 pub async fn shell(
75 &self,
76 command: Option<&str>,
77 options: ShellOptions,
78 ) -> Result<i32, SailError> {
79 if !stdio_is_tty() {
80 return Err(tty_required());
81 }
82 let command = match command {
83 Some(command) => command.to_string(),
84 None => login_shell_command(options.shell.as_deref()),
85 };
86 let (cols, rows) = terminal_size();
87 // An interactive shell forwards the session's localhost servers, browser
88 // opens, and clipboard/paste to the user's machine unless opted out.
89 let (forward_ports, forward_browser, forward_clipboard) =
90 crate::exec::forward_flags(options.no_forward, options.no_forward_browser);
91 let proc = self
92 .client()
93 .exec_shell(
94 self.sailbox_id(),
95 &command,
96 ExecOptions {
97 timeout: options.timeout,
98 pty: true,
99 term: options
100 .term
101 .or_else(|| std::env::var("TERM").ok())
102 .unwrap_or_default(),
103 cols,
104 rows,
105 cwd: options.cwd,
106 forward_ports,
107 forward_browser,
108 forward_clipboard,
109 ..Default::default()
110 },
111 )
112 .await?;
113 let proc = Arc::new(proc);
114 tokio::task::spawn_blocking(move || run_interactive(proc))
115 .await
116 .map_err(|err| SailError::Internal {
117 message: format!("shell bridge task failed: {err}"),
118 })?
119 }
120}
121
122/// The command for an interactive login session: `exec` the login shell so
123/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
124/// with spaces runs as a literal program; the default stays unquoted so the
125/// guest shell expands `$SHELL`.
126fn login_shell_command(shell: Option<&str>) -> String {
127 match shell {
128 Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
129 None => "exec ${SHELL:-/bin/bash} -l".to_string(),
130 }
131}
132
133/// The local terminal size as (cols, rows), defaulting to 80x24.
134#[cfg(unix)]
135#[doc(hidden)]
136pub fn terminal_size() -> (u32, u32) {
137 let mut size = libc::winsize {
138 ws_row: 0,
139 ws_col: 0,
140 ws_xpixel: 0,
141 ws_ypixel: 0,
142 };
143 let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
144 if ok && size.ws_col > 0 && size.ws_row > 0 {
145 (u32::from(size.ws_col), u32::from(size.ws_row))
146 } else {
147 (80, 24)
148 }
149}
150
151/// The local terminal size as (cols, rows), defaulting to 80x24.
152#[cfg(not(unix))]
153#[doc(hidden)]
154pub fn terminal_size() -> (u32, u32) {
155 (80, 24)
156}
157
158/// Interactive PTY sessions need Unix TTY and signal APIs.
159#[cfg(not(unix))]
160#[doc(hidden)]
161pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
162 Err(SailError::Execution {
163 code: RpcStatus::Unimplemented,
164 detail: "interactive PTY sessions are not supported on this platform".to_string(),
165 })
166}
167
168#[cfg(unix)]
169#[doc(hidden)]
170pub use unix::run_interactive;
171
172#[cfg(unix)]
173#[doc(hidden)]
174pub use unix::{drive_output_pump, RenderControl};
175
176#[cfg(unix)]
177mod unix {
178 use std::collections::{HashMap, HashSet};
179 use std::io::Write;
180 use std::path::PathBuf;
181 use std::sync::atomic::{AtomicBool, Ordering};
182 use std::sync::Arc;
183 use std::thread;
184 use std::time::{Duration, Instant};
185
186 use super::terminal_size;
187 use crate::error::{RpcStatus, SailError};
188 use crate::exec::{ExecProcess, ForwardEvent, OutputStream, ReadStep};
189 use crate::shell_input::{
190 dropped_local_files, find, partial_suffix_len, sanitize_drop_name, InputEvent,
191 InputScanner, PASTE_END, PASTE_START,
192 };
193
194 /// Drive a future to completion from this bridge's dedicated thread. On
195 /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
196 /// ambient handle exists and `Handle::block_on` is the correct, safe
197 /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
198 /// back to the crate's shared-runtime `block_on`.
199 fn block_on<F: std::future::Future>(future: F) -> F::Output {
200 match tokio::runtime::Handle::try_current() {
201 Ok(handle) => handle.block_on(future),
202 Err(_) => crate::runtime::block_on(future),
203 }
204 }
205
206 /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
207 static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
208
209 extern "C" fn on_sigwinch(_signum: libc::c_int) {
210 RESIZE_PENDING.store(true, Ordering::Relaxed);
211 }
212
213 /// Drive the local terminal against a PTY exec until the remote process
214 /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
215 /// always restored, even on error. When the session forwards its clipboard
216 /// (`proc.forward_clipboard()`), dragged files and Ctrl+V pastes forward
217 /// into the guest and in-guest copies mirror back to the local clipboard;
218 /// without it every byte passes through verbatim.
219 pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
220 let saved = enter_raw_mode()?;
221 let prev_winch = install_sigwinch();
222 let prev_in_flags = set_stdin_nonblocking();
223 // Non-blocking stdout so the output pump is never parked in a write to a
224 // slow terminal: it must stay free to notice the ring dropped and repaint.
225 let prev_out_flags = set_stdout_nonblocking();
226
227 // Seed the remote PTY with the current size.
228 let (cols, rows) = terminal_size();
229 block_on(proc.resize(cols, rows));
230
231 let stop = Arc::new(AtomicBool::new(false));
232 let render = Arc::new(RenderControl::default());
233 let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop), Arc::clone(&render));
234 // Browser-open and localhost-port forwarding (guest-gated by the launch
235 // flags); idles harmlessly when the session opted out.
236 let forward = spawn_forward_consumer(Arc::clone(&proc));
237 // The clipboard bridge, both directions, rides the same opt-in as the
238 // clipboard launch flag: mirror in-guest copies onto the local
239 // clipboard, and scan stdin for pastes/drags to send the other way.
240 let forward_clipboard = proc.forward_clipboard();
241 let clipboard = forward_clipboard.then(|| spawn_clipboard_consumer(Arc::clone(&proc)));
242
243 if forward_clipboard {
244 drive_input_forwarding(
245 PasteBridge::new(Arc::clone(&proc), Arc::clone(&render)),
246 &stop,
247 );
248 } else {
249 drive_input(&proc, &stop);
250 }
251
252 // Tear down in reverse order so the terminal is always usable afterwards.
253 let _ = output.join();
254 let _ = forward.join();
255 if let Some(consumer) = clipboard {
256 let _ = consumer.join();
257 }
258 restore_stdout_flags(prev_out_flags);
259 restore_stdin_flags(prev_in_flags);
260 restore_sigwinch(prev_winch);
261 restore_terminal(&saved);
262
263 // A witnessed Exit is the command's real result. When the stream ended
264 // without one, the command did not exit; the box was parked (put to
265 // sleep) or otherwise became unreachable mid-session. Report that instead
266 // of calling wait(), which would block forever on an Exit an interactive
267 // shell never emits; the box's session stays intact for a fresh reconnect.
268 match proc.try_wait() {
269 Some(result) => result,
270 None => Err(SailError::Execution {
271 code: RpcStatus::Unavailable,
272 detail: format!(
273 "the box became unavailable and the shell session ended; \
274 reconnect with `sail box shell {}`",
275 proc.sailbox_id(),
276 ),
277 }),
278 }
279 }
280
281 /// Shared switches between the input side and the output pump: `paused`
282 /// stops the pump writing to the terminal while an upload progress line
283 /// owns it, and `bracketed_paste` tracks whether the guest application has
284 /// paste bracketing (DEC mode 2004) on, so injected pastes are framed the
285 /// way the terminal would frame a real one. Public only because the pump
286 /// is driven directly by integration tests.
287 #[doc(hidden)]
288 #[derive(Default)]
289 pub struct RenderControl {
290 paused: AtomicBool,
291 bracketed_paste: AtomicBool,
292 }
293
294 /// Least time between screen-repaint requests while the local terminal is
295 /// too slow to keep up: without a bound a persistently-behind reader would
296 /// ask on every drop and flood the guest with resync RPCs. Capping repaint
297 /// requests to one per 100 ms is plenty to keep the screen current.
298 const RESYNC_MIN_INTERVAL: Duration = Duration::from_millis(100);
299
300 /// Most backlog the pump buffers toward the terminal before it stops draining
301 /// the ring. Holding the cap small means a slow terminal quickly lets the
302 /// ring back up and drop-oldest, which the reader reports as a drop — the
303 /// signal that triggers a repaint. Larger would just make the terminal crawl
304 /// further through stale frames before recovering.
305 const OUTPUT_PENDING_CAP: usize = 256 * 1024;
306
307 /// Spawn the thread that renders merged PTY output to the terminal, then
308 /// signals stop when the stream ends. The terminal fd is already non-blocking
309 /// (set by [`run_interactive`]).
310 fn spawn_output_pump(
311 proc: Arc<ExecProcess>,
312 stop: Arc<AtomicBool>,
313 render: Arc<RenderControl>,
314 ) -> thread::JoinHandle<()> {
315 thread::spawn(move || {
316 let mut reader = proc.reader(OutputStream::Stdout);
317 let mut sink = RawFdWriter(libc::STDOUT_FILENO);
318 drive_output_pump(&mut reader, &mut sink, &proc, &render);
319 stop.store(true, Ordering::Relaxed);
320 })
321 }
322
323 /// Least time between retries of a port that could not be forwarded because
324 /// its local port was busy. The port watcher only re-reports the guest's
325 /// listeners when the set changes, so this retry covers a local port freeing
326 /// up while the guest server keeps running.
327 const FORWARD_RETRY_INTERVAL: Duration = Duration::from_secs(3);
328
329 /// Spawn the thread that acts on the session's local-forwarding events. It
330 /// drains until the stream ends (the accessor then returns `None`). Active
331 /// port forwards are held for the life of the session and dropped on exit.
332 fn spawn_forward_consumer(proc: Arc<ExecProcess>) -> thread::JoinHandle<()> {
333 thread::spawn(move || {
334 let mut forwards: HashMap<u16, crate::forward::PortForward> = HashMap::new();
335 // Ports whose local port was busy, so the forward could not bind. Kept
336 // so the bind is retried on the interval below in case the local port
337 // frees up.
338 let mut conflicts: HashSet<u16> = HashSet::new();
339 loop {
340 // Wait for the next event, but only until the retry interval when
341 // there are conflicts to re-attempt; otherwise wait indefinitely.
342 let next: Result<Option<ForwardEvent>, tokio::time::error::Elapsed> =
343 if conflicts.is_empty() {
344 Ok(block_on(proc.next_forward_event()))
345 } else {
346 block_on(async {
347 tokio::time::timeout(FORWARD_RETRY_INTERVAL, proc.next_forward_event())
348 .await
349 })
350 };
351 match next {
352 Ok(Some(ForwardEvent::OpenUrl(url))) => {
353 let open = if !is_openable_scheme(&url) {
354 // open_local_url only opens http(s); skip building a
355 // forward for a URL it would refuse anyway.
356 false
357 } else if let Some(port) = crate::forward::forwardable_local_port(&url) {
358 // A URL for a server in the box: forward its port, then
359 // open the bound local address (rewritten below). If it
360 // can't be forwarded, don't open it against the user's
361 // own machine. A login's localhost callback is a server
362 // too, so the port watcher forwards it the same way.
363 if ensure_forward(&proc, &mut forwards, &mut conflicts, port) {
364 true
365 } else {
366 notify_local_port_busy(&url, port);
367 false
368 }
369 } else if crate::forward::is_unforwardable_loopback_url(&url) {
370 // A loopback URL the tunnel can't reach: opening it would
371 // hit the user's own machine, not the sandbox.
372 notify_loopback_unreachable(&url);
373 false
374 } else if let Some(callback) = crate::forward::redirect_callback(&url) {
375 // An external login URL whose redirect returns to a
376 // loopback callback. Don't start a login whose redirect,
377 // carrying the auth code, would hit the user's machine
378 // rather than the sandbox.
379 match callback {
380 // Open only once the callback port is actually
381 // forwarded. The snapshot precedes this URL, so a
382 // listening forwardable callback is already in
383 // `forwards`; a port not there is one whose local
384 // port is busy or whose server is not listening on a
385 // reachable address, and an immediate redirect would
386 // hit the user's own machine.
387 crate::forward::RedirectCallback::Forwardable(port)
388 if forwards.contains_key(&port) =>
389 {
390 true
391 }
392 crate::forward::RedirectCallback::Forwardable(port) => {
393 notify_callback_unforwarded(&url, port);
394 false
395 }
396 // A loopback the tunnel cannot dial at all.
397 crate::forward::RedirectCallback::Unreachable => {
398 notify_callback_unreachable(&url);
399 false
400 }
401 }
402 } else {
403 // An external URL with no localhost callback: open it.
404 true
405 };
406 if open {
407 let url = crate::forward::rewrite_loopback_url(&url, |remote| {
408 forwards
409 .get(&remote)
410 .map(crate::forward::PortForward::local_port)
411 });
412 open_local_url(&url);
413 }
414 }
415 Ok(Some(ForwardEvent::PortSnapshot(ports))) => {
416 // Reconcile against the authoritative set: drop forwards and
417 // conflicts for servers that are gone, then forward the rest.
418 let listening: HashSet<u16> = ports.iter().copied().collect();
419 forwards.retain(|port, _| listening.contains(port));
420 conflicts.retain(|port| listening.contains(port));
421 for port in ports {
422 ensure_forward(&proc, &mut forwards, &mut conflicts, port);
423 }
424 }
425 // The stream ended.
426 Ok(None) => break,
427 // No event within the interval: retry any port whose local port
428 // was busy, in case it has since freed up.
429 Err(_) => {
430 for port in conflicts.iter().copied().collect::<Vec<_>>() {
431 ensure_forward(&proc, &mut forwards, &mut conflicts, port);
432 }
433 }
434 }
435 }
436 })
437 }
438
439 /// Forward `port` (guest to the same local port) if it is not already
440 /// forwarded. Returns whether the port is now forwarded. The local port
441 /// always matches the guest port and is never remapped: a login callback
442 /// redirect targets that exact port, so binding elsewhere would send the
443 /// browser to whatever already holds the local port rather than the sandbox.
444 /// A busy local port is recorded in `conflicts` and retried on the interval.
445 fn ensure_forward(
446 proc: &Arc<ExecProcess>,
447 forwards: &mut HashMap<u16, crate::forward::PortForward>,
448 conflicts: &mut HashSet<u16>,
449 port: u16,
450 ) -> bool {
451 if forwards.contains_key(&port) {
452 return true;
453 }
454 if let Ok(forward) = block_on(proc.forward_port(port, port)) {
455 forwards.insert(port, forward);
456 conflicts.remove(&port);
457 true
458 } else {
459 conflicts.insert(port);
460 false
461 }
462 }
463
464 /// Notify that a URL the box asked to open targets a loopback address the
465 /// sandbox cannot reach, so it was not opened against the user's own machine.
466 fn notify_loopback_unreachable(url: &str) {
467 let notice = format!(
468 "\r\n[sail] not opening {url}: it targets a loopback address the sandbox cannot reach\r\n"
469 );
470 let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
471 }
472
473 /// Notify that a box server was not opened because its port is already in use
474 /// on the local machine, so the forward could not bind it.
475 fn notify_local_port_busy(url: &str, port: u16) {
476 let notice = format!("\r\n[sail] not opening {url}: local port {port} is in use\r\n");
477 let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
478 }
479
480 /// Whether `open_local_url` would open this URL (it opens only http(s)).
481 fn is_openable_scheme(url: &str) -> bool {
482 url.starts_with("http://") || url.starts_with("https://")
483 }
484
485 /// Notify that a login was not opened because its localhost callback port is
486 /// not forwarded (its local port is busy, or its server is not listening on a
487 /// reachable address), so the provider's redirect could not reach the sandbox.
488 fn notify_callback_unforwarded(url: &str, port: u16) {
489 let notice = format!(
490 "\r\n[sail] not opening {url}: its login callback port {port} is not forwarded\r\n"
491 );
492 let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
493 }
494
495 /// Notify that a login was not opened because its callback is a loopback
496 /// address the sandbox cannot reach, so the redirect would hit the user's
497 /// own machine.
498 fn notify_callback_unreachable(url: &str) {
499 let notice = format!(
500 "\r\n[sail] not opening {url}: its login callback is a loopback address the sandbox cannot reach\r\n"
501 );
502 let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
503 }
504
505 /// Open a URL in the user's local browser, best-effort. Only http(s) URLs
506 /// are opened, so a sandbox process cannot drive arbitrary local handlers.
507 /// The child inherits no terminal, so an opener's own output can't corrupt
508 /// the session. Silent on success: the browser tab appearing is the signal.
509 fn open_local_url(url: &str) {
510 if !is_openable_scheme(url) {
511 return;
512 }
513 let _ = local_browser_command(url)
514 .stdin(std::process::Stdio::null())
515 .stdout(std::process::Stdio::null())
516 .stderr(std::process::Stdio::null())
517 .spawn();
518 }
519
520 /// The platform command that opens a URL in the default browser. This
521 /// module is Unix-only, so the choice is macOS `open` or Linux `xdg-open`.
522 fn local_browser_command(url: &str) -> std::process::Command {
523 let program = if cfg!(target_os = "macos") {
524 "open"
525 } else {
526 "xdg-open"
527 };
528 let mut command = std::process::Command::new(program);
529 command.arg(url);
530 command
531 }
532
533 /// Render one live output stream onto a terminal `sink` until the stream
534 /// ends, favoring a current screen over a faithful replay.
535 ///
536 /// The terminal writer must never block the loop: a slow terminal has to keep
537 /// the pump free to notice the ring dropped and ask the guest to repaint the
538 /// current screen ([`ExecProcess::resync`]). So `sink` is written
539 /// non-blockingly, backlog is held to [`OUTPUT_PENDING_CAP`] so the ring
540 /// backs up and drops-oldest when the terminal falls behind, and a reported
541 /// drop discards the torn backlog and requests a repaint rather than crawling
542 /// the slow terminal through stale frames it will never catch. The command is
543 /// detached on the server, so none of this ever blocks it.
544 ///
545 /// Generic over the sink so the drop-to-repaint behavior is testable against a
546 /// deliberately slow writer without a real terminal.
547 #[doc(hidden)]
548 pub fn drive_output_pump<W: Write>(
549 reader: &mut crate::exec::StreamReader,
550 sink: &mut W,
551 proc: &Arc<ExecProcess>,
552 render: &RenderControl,
553 ) {
554 let mut pending: Vec<u8> = Vec::new();
555 let mut last_resync: Option<Instant> = None;
556 // Hold an observed drop until a repaint is actually requested. resync_due
557 // only fires once per RESYNC_MIN_INTERVAL, so a drop seen during that
558 // cooldown would otherwise be forgotten, leaving the screen showing a
559 // torn, partial frame.
560 let mut resync_pending = false;
561 let mut modes = BracketedPasteTracker::default();
562 loop {
563 // An upload progress line owns the terminal: stop rendering (and
564 // stop draining the ring, which then backs up and drops-oldest just
565 // like a slow terminal — the existing repaint path heals it).
566 if render.paused.load(Ordering::Relaxed) {
567 thread::sleep(Duration::from_millis(5));
568 continue;
569 }
570 // Push as much backlog as the terminal accepts right now, without
571 // blocking on it.
572 let mut flushed = false;
573 if !pending.is_empty() {
574 let written = write_nonblocking(sink, &pending);
575 if written > 0 {
576 pending.drain(..written);
577 flushed = true;
578 }
579 }
580 // Refill from the ring, but only up to the cap: leaving the rest in
581 // the ring lets it back up and drop-oldest when the terminal is slow.
582 let mut progressed = false;
583 if pending.len() < OUTPUT_PENDING_CAP {
584 // Don't wait for new data while there is still backlog to push.
585 let wait = if pending.is_empty() {
586 Duration::from_millis(50)
587 } else {
588 Duration::ZERO
589 };
590 match reader.next(wait) {
591 ReadStep::Chunk(bytes) => {
592 // A Snapshot reset the ring: `bytes` is the repaint, and
593 // it supersedes the stale backlog buffered toward the
594 // terminal. Drop that backlog before queuing the repaint
595 // so the finished screen renders at once instead of stuck
596 // behind bytes the slow terminal will never finish
597 // draining (the bounded end-of-stream flush would give up
598 // before reaching it).
599 if reader.took_reset() {
600 pending.clear();
601 }
602 modes.scan(&bytes, render);
603 pending.extend_from_slice(&bytes);
604 progressed = true;
605 }
606 ReadStep::Eof => {
607 flush_blocking(sink, &pending);
608 return;
609 }
610 ReadStep::Pending => {}
611 }
612 while pending.len() < OUTPUT_PENDING_CAP {
613 match reader.try_next() {
614 // Honor a reset here too: the repaint can land in this
615 // batch drain when the Snapshot arrives after next()
616 // above already returned a stale chunk this iteration.
617 Some(more) => {
618 if reader.took_reset() {
619 pending.clear();
620 }
621 modes.scan(&more, render);
622 pending.extend_from_slice(&more);
623 }
624 None => break,
625 }
626 }
627 }
628 // The ring evicted output we had not shown: the backlog is now a torn
629 // tail, so drop it and repaint the current screen instead.
630 if reader.took_drop() {
631 pending.clear();
632 resync_pending = true;
633 }
634 if resync_pending && resync_due(&mut last_resync) {
635 resync_pending = false;
636 let handle = Arc::clone(proc);
637 crate::runtime::runtime().spawn(async move { handle.resync().await });
638 }
639 // Yield when no new ring data was read and bytes are still queued,
640 // either because the backlog is at the cap (so the ring can back up
641 // and drop-oldest for a slow terminal) or because the terminal is
642 // back-pressured and accepted nothing (so the loop does not spin).
643 // A terminal actively draining a partial backlog is making progress,
644 // so it keeps looping.
645 if !progressed
646 && !pending.is_empty()
647 && (pending.len() >= OUTPUT_PENDING_CAP || !flushed)
648 {
649 thread::sleep(Duration::from_millis(5));
650 }
651 }
652 }
653
654 /// Write what the terminal will take right now, returning the bytes accepted.
655 /// A full terminal (`WouldBlock`), or any transient error, accepts zero and
656 /// the caller keeps the rest rather than propagating a terminal write error.
657 fn write_nonblocking<W: Write>(sink: &mut W, buf: &[u8]) -> usize {
658 sink.write(buf).unwrap_or(0)
659 }
660
661 /// End of stream: land the final bytes even against a non-blocking terminal,
662 /// but bounded so a wedged terminal cannot hang the exit.
663 fn flush_blocking<W: Write>(sink: &mut W, buf: &[u8]) {
664 let mut off = 0;
665 for _ in 0..2000 {
666 if off >= buf.len() {
667 break;
668 }
669 match sink.write(&buf[off..]) {
670 Ok(0) => thread::sleep(Duration::from_millis(1)),
671 Ok(n) => off += n,
672 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
673 thread::sleep(Duration::from_millis(1));
674 }
675 Err(_) => break,
676 }
677 }
678 let _ = sink.flush();
679 }
680
681 /// A `Write` over a raw fd. On a non-blocking fd a full pipe surfaces as a
682 /// `WouldBlock` error rather than parking the thread.
683 struct RawFdWriter(libc::c_int);
684
685 impl Write for RawFdWriter {
686 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
687 let n = unsafe { libc::write(self.0, buf.as_ptr().cast(), buf.len()) };
688 if n < 0 {
689 Err(std::io::Error::last_os_error())
690 } else {
691 Ok(n as usize)
692 }
693 }
694
695 fn flush(&mut self) -> std::io::Result<()> {
696 Ok(())
697 }
698 }
699
700 /// Whether enough time has passed since the last repaint request to send
701 /// another, stamping the clock when it returns true.
702 fn resync_due(last: &mut Option<Instant>) -> bool {
703 let now = Instant::now();
704 if last.is_none_or(|t| now.duration_since(t) >= RESYNC_MIN_INTERVAL) {
705 *last = Some(now);
706 true
707 } else {
708 false
709 }
710 }
711
712 /// Forward raw stdin bytes to the guest, draining pending resizes, until the
713 /// output stream ends or local stdin closes.
714 fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
715 let mut buf = [0u8; 4096];
716 let mut stdin_open = true;
717 while !stop.load(Ordering::Relaxed) {
718 if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
719 let (cols, rows) = terminal_size();
720 block_on(proc.resize(cols, rows));
721 }
722 if !stdin_open {
723 thread::sleep(Duration::from_millis(20));
724 continue;
725 }
726 let n = unsafe {
727 libc::read(
728 libc::STDIN_FILENO,
729 buf.as_mut_ptr().cast::<libc::c_void>(),
730 buf.len(),
731 )
732 };
733 match n.cmp(&0) {
734 std::cmp::Ordering::Greater => {
735 if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
736 break; // remote closed stdin or exec ended
737 }
738 }
739 std::cmp::Ordering::Equal => {
740 // Local stdin reached EOF: send EOF and stop reading it, but
741 // keep draining output until the remote process exits.
742 let _ = block_on(proc.close_stdin());
743 stdin_open = false;
744 }
745 std::cmp::Ordering::Less => {
746 // A nonblocking read with no data yet (WouldBlock), or one a
747 // handled signal such as SIGWINCH interrupted (Interrupted),
748 // is transient: back off briefly and retry rather than ending
749 // the input loop, which would wedge stdin until the command
750 // exits.
751 let err = std::io::Error::last_os_error();
752 if matches!(
753 err.kind(),
754 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
755 ) {
756 thread::sleep(Duration::from_millis(10));
757 } else {
758 break;
759 }
760 }
761 }
762 }
763 }
764
765 // --- local paste and drag-and-drop forwarding ---
766
767 /// Parent of the per-session directory where forwarded drops and pastes
768 /// land. Each session gets its own subdirectory (see PasteBridge::new) so
769 /// two shells' drops of the same name never collide, and a cancel/failure
770 /// rollback only ever deletes files this session uploaded.
771 const GUEST_DROPS_ROOT: &str = "/tmp/sail-drops";
772 /// Wait this long before drawing the upload progress line: most drops are
773 /// small images that land invisibly fast, and flashing a progress line for
774 /// them would just flicker the screen.
775 const UPLOAD_UI_DELAY: Duration = Duration::from_millis(400);
776 /// Largest content pushed onto the guest clipboard in one RPC (the message
777 /// must fit the transport's 4 MiB frame). Larger images upload as files
778 /// and paste as a guest path instead.
779 const CLIPBOARD_PUSH_MAX: usize = 3 * 1024 * 1024;
780 /// Longest a clipboard push may hold the input thread. The push must
781 /// complete before the Ctrl+V chord is forwarded (or the guest
782 /// application would paste the clipboard's previous content), and the
783 /// input thread is what sequences both, so a slow guest stalls keystroke
784 /// forwarding for the push's duration. This deadline bounds that stall
785 /// well under the RPC's own; on expiry the caller falls back exactly as
786 /// for any other failed push. It must exceed the guest agent's own 3s
787 /// serve-verification budget (saild's guestClipboardCmdTimeout) with
788 /// round-trip slack, or a push the guest completed near its budget would
789 /// be misclassified as failed.
790 const CLIPBOARD_PUSH_DEADLINE: Duration = Duration::from_secs(5);
791 /// Upload stream granularity: small enough for responsive progress, large
792 /// enough that per-message overhead is noise.
793 const UPLOAD_CHUNK_BYTES: usize = 256 * 1024;
794 /// How long an ambiguous escape-sequence prefix waits for its remaining
795 /// bytes before being forwarded as a real keypress. Terminals send
796 /// sequences in one burst, so only a human typing a lone ESC waits this out.
797 const CARRY_FLUSH_AFTER: Duration = Duration::from_millis(25);
798
799 /// Tracks DEC private mode 2004 (bracketed paste) in the guest's output so
800 /// injected pastes are framed exactly as the terminal would frame a real
801 /// one. Snapshot repaints re-assert tracked modes, so the flag survives
802 /// reattach and heals after any dropped chunk.
803 #[derive(Default)]
804 struct BracketedPasteTracker {
805 tail: Vec<u8>,
806 }
807
808 impl BracketedPasteTracker {
809 fn scan(&mut self, bytes: &[u8], render: &RenderControl) {
810 const INTRO: &[u8] = b"\x1b[?";
811 let mut buf = std::mem::take(&mut self.tail);
812 buf.extend_from_slice(bytes);
813 let mut i = 0;
814 while i < buf.len() {
815 let Some(at) = find(&buf[i..], INTRO) else {
816 break;
817 };
818 let start = i + at;
819 let mut j = start + INTRO.len();
820 while j < buf.len() && (buf[j].is_ascii_digit() || buf[j] == b';') {
821 j += 1;
822 }
823 let Some(&fin) = buf.get(j) else {
824 // Split across chunks: carry the partial sequence, bounded —
825 // a parameter run longer than any real mode list is not one.
826 if buf.len() - start <= 24 {
827 self.tail = buf[start..].to_vec();
828 }
829 return;
830 };
831 if fin == b'h' || fin == b'l' {
832 let in_params = buf[start + INTRO.len()..j]
833 .split(|&b| b == b';')
834 .any(|param| param == b"2004");
835 if in_params {
836 render.bracketed_paste.store(fin == b'h', Ordering::Relaxed);
837 }
838 }
839 i = j;
840 }
841 let keep = partial_suffix_len(&buf, INTRO);
842 if keep > 0 {
843 self.tail = buf[buf.len() - keep..].to_vec();
844 }
845 }
846 }
847
848 /// Applies guest-clipboard updates to the local clipboard, so a copy made
849 /// inside the guest is pasteable locally. Exits when the stream ends. The
850 /// clipboard handle stays alive for the whole session: on X11 the
851 /// selection lives only as long as the handle that set it.
852 fn spawn_clipboard_consumer(proc: Arc<ExecProcess>) -> thread::JoinHandle<()> {
853 thread::spawn(move || {
854 let mut clipboard: Option<arboard::Clipboard> = None;
855 while let Some((mime, data)) = block_on(proc.next_clipboard_update()) {
856 if mime != "text/plain" {
857 continue;
858 }
859 let Ok(text) = String::from_utf8(data) else {
860 continue;
861 };
862 if clipboard.is_none() {
863 clipboard = arboard::Clipboard::new().ok();
864 }
865 if let Some(clipboard) = clipboard.as_mut() {
866 let _ = clipboard.set_text(text);
867 }
868 }
869 })
870 }
871
872 /// The forwarding twin of [`drive_input`]: stdin is scanned for bracketed
873 /// pastes and the Ctrl+V chord (see [`crate::shell_input`]), which the
874 /// [`PasteBridge`] turns into uploads, clipboard pushes, or verbatim
875 /// forwards; every other byte passes through untouched.
876 fn drive_input_forwarding(mut bridge: PasteBridge, stop: &AtomicBool) {
877 let mut scanner = InputScanner::new();
878 let mut buf = [0u8; 4096];
879 let mut stdin_open = true;
880 let mut carry_deadline: Option<Instant> = None;
881 while !stop.load(Ordering::Relaxed) {
882 if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
883 let (cols, rows) = terminal_size();
884 block_on(bridge.proc.resize(cols, rows));
885 }
886 if !stdin_open {
887 thread::sleep(Duration::from_millis(20));
888 continue;
889 }
890 // Keystrokes read while an upload owned stdin replay first, in
891 // order. Refresh the idle deadline like the read path: a replayed
892 // bare ESC lands in the scanner carry and must still flush.
893 if !bridge.stashed_input.is_empty() {
894 let stashed = std::mem::take(&mut bridge.stashed_input);
895 if bridge.handle_events(scanner.scan(&stashed)).is_err() {
896 break;
897 }
898 carry_deadline = scanner
899 .has_idle_carry()
900 .then(|| Instant::now() + CARRY_FLUSH_AFTER);
901 }
902 let n = unsafe {
903 libc::read(
904 libc::STDIN_FILENO,
905 buf.as_mut_ptr().cast::<libc::c_void>(),
906 buf.len(),
907 )
908 };
909 match n.cmp(&0) {
910 std::cmp::Ordering::Greater => {
911 if bridge
912 .handle_events(scanner.scan(&buf[..n as usize]))
913 .is_err()
914 {
915 break;
916 }
917 carry_deadline = scanner
918 .has_idle_carry()
919 .then(|| Instant::now() + CARRY_FLUSH_AFTER);
920 }
921 std::cmp::Ordering::Equal => {
922 // Local stdin reached EOF: flush whatever the scanner
923 // still held verbatim, then send EOF and keep draining
924 // output until the remote process exits.
925 let held = scanner.flush_all();
926 if !held.is_empty() && bridge.forward(&held).is_err() {
927 break;
928 }
929 let _ = block_on(bridge.proc.close_stdin());
930 stdin_open = false;
931 }
932 std::cmp::Ordering::Less => {
933 let err = std::io::Error::last_os_error();
934 if !matches!(
935 err.kind(),
936 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
937 ) {
938 break;
939 }
940 // Idle: a carried escape prefix past its deadline was a
941 // real keypress (e.g. a lone ESC), so forward it now. A
942 // mid-paste hold is exempt (has_idle_carry): the paste's
943 // remaining bytes are still coming.
944 if carry_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
945 carry_deadline = None;
946 if scanner.has_idle_carry() {
947 let carry = scanner.take_carry();
948 if !carry.is_empty() && bridge.forward(&carry).is_err() {
949 break;
950 }
951 }
952 }
953 thread::sleep(Duration::from_millis(10));
954 }
955 }
956 }
957 }
958
959 /// What the local clipboard holds, in the order pasting cares about:
960 /// copied files, then an image (encoded to PNG), then text.
961 enum LocalClipboard {
962 Files(Vec<PathBuf>),
963 Image(Vec<u8>),
964 Text(String),
965 Empty,
966 }
967
968 fn read_local_clipboard() -> LocalClipboard {
969 let Ok(mut clipboard) = arboard::Clipboard::new() else {
970 return LocalClipboard::Empty;
971 };
972 if let Ok(files) = clipboard.get().file_list() {
973 if !files.is_empty() && files.iter().all(|path| path.is_file()) {
974 return LocalClipboard::Files(files);
975 }
976 }
977 if let Ok(image) = clipboard.get_image() {
978 if let Some(png) = encode_png(&image) {
979 return LocalClipboard::Image(png);
980 }
981 }
982 match clipboard.get_text() {
983 Ok(text) if !text.is_empty() => LocalClipboard::Text(text),
984 _ => LocalClipboard::Empty,
985 }
986 }
987
988 /// Encode arboard's raw RGBA image as PNG, the type both the guest
989 /// clipboard and the coding agents (claude, codex) inside it expect. The
990 /// encoder itself rejects a byte buffer that does not match the
991 /// dimensions.
992 fn encode_png(image: &arboard::ImageData) -> Option<Vec<u8>> {
993 let width = u32::try_from(image.width).ok()?;
994 let height = u32::try_from(image.height).ok()?;
995 let mut out = Vec::new();
996 let mut encoder = png::Encoder::new(&mut out, width, height);
997 encoder.set_color(png::ColorType::Rgba);
998 encoder.set_depth(png::BitDepth::Eight);
999 let mut writer = encoder.write_header().ok()?;
1000 writer.write_image_data(&image.bytes).ok()?;
1001 writer.finish().ok()?;
1002 Some(out)
1003 }
1004
1005 /// How an upload ended: the guest paths to paste, a user cancel (paste
1006 /// nothing), or a failure (the caller falls back to forwarding the
1007 /// original bytes where that makes sense).
1008 enum UploadOutcome {
1009 Done(Vec<String>),
1010 Cancelled,
1011 Failed,
1012 }
1013
1014 /// One file (or in-memory blob) headed for the guest drops directory.
1015 struct UploadSource {
1016 guest_path: String,
1017 size: u64,
1018 data: UploadData,
1019 }
1020
1021 enum UploadData {
1022 File(PathBuf),
1023 Memory(Vec<u8>),
1024 }
1025
1026 /// Turns scanned paste events into guest activity: dragged files upload
1027 /// and paste as guest paths, Ctrl+V forwards the local clipboard (guest
1028 /// clipboard when supported, file upload otherwise), everything else
1029 /// forwards verbatim.
1030 struct PasteBridge {
1031 proc: Arc<ExecProcess>,
1032 render: Arc<RenderControl>,
1033 /// This session's private drop directory under GUEST_DROPS_ROOT, keyed
1034 /// by the exec id so concurrent shells never share a path.
1035 drops_dir: String,
1036 /// Guest file names already used this session, so a re-dropped name
1037 /// gets a numbered variant instead of clobbering a different file.
1038 used_names: std::collections::HashSet<String>,
1039 /// Whether this guest accepts clipboard writes. `None` until the first
1040 /// attempt; latches `Some(false)` on Unimplemented (the guest has no
1041 /// clipboard) so later pastes skip straight to the file fallback.
1042 guest_clipboard: Option<bool>,
1043 /// Keystrokes read while an upload owned stdin (watching for cancel),
1044 /// replayed in order afterwards.
1045 stashed_input: Vec<u8>,
1046 }
1047
1048 impl PasteBridge {
1049 fn new(proc: Arc<ExecProcess>, render: Arc<RenderControl>) -> PasteBridge {
1050 // Name the per-session directory by the hash of the exec id, so it
1051 // is always path-safe, never `.`/`..`, and collision-free whatever
1052 // shape the server's id takes: two concurrent sessions never share
1053 // a path.
1054 use sha2::Digest as _;
1055 use std::fmt::Write as _;
1056 let digest = sha2::Sha256::digest(proc.exec_request_id().as_bytes());
1057 let mut session = String::with_capacity(16);
1058 for byte in &digest[..8] {
1059 let _ = write!(session, "{byte:02x}");
1060 }
1061 let drops_dir = format!("{GUEST_DROPS_ROOT}/{session}");
1062 PasteBridge {
1063 proc,
1064 render,
1065 drops_dir,
1066 used_names: std::collections::HashSet::new(),
1067 guest_clipboard: None,
1068 stashed_input: Vec::new(),
1069 }
1070 }
1071
1072 fn handle_events(&mut self, events: Vec<InputEvent>) -> Result<(), ()> {
1073 for event in events {
1074 match event {
1075 InputEvent::Bytes(bytes) => self.forward(&bytes)?,
1076 InputEvent::Paste(body) => self.handle_paste(&body)?,
1077 InputEvent::PasteChord(chord) => self.handle_chord(&chord)?,
1078 }
1079 }
1080 Ok(())
1081 }
1082
1083 /// Forward bytes to the guest pty. Err means the exec ended and the
1084 /// input loop should stop, matching [`drive_input`].
1085 fn forward(&self, bytes: &[u8]) -> Result<(), ()> {
1086 block_on(self.proc.write_stdin(bytes)).map_err(|_| ())
1087 }
1088
1089 /// A completed bracketed paste: a drag-and-drop of local files uploads
1090 /// them and pastes the guest paths; any other paste (or a failed
1091 /// upload) forwards byte-identically.
1092 fn handle_paste(&mut self, body: &[u8]) -> Result<(), ()> {
1093 if let Some(files) = dropped_local_files(body) {
1094 match self.upload_files(&files) {
1095 UploadOutcome::Done(paths) => {
1096 // Keep the trailing separator the drag arrived with,
1097 // so typing right after the drop stays a separate
1098 // argument exactly as it would locally.
1099 let text = format!("{} ", paths.join(" "));
1100 return self.inject_uploaded(&text, &paths);
1101 }
1102 UploadOutcome::Cancelled => return Ok(()),
1103 UploadOutcome::Failed => {} // fall through to the original paste
1104 }
1105 }
1106 let mut raw = Vec::with_capacity(body.len() + PASTE_START.len() + PASTE_END.len());
1107 raw.extend_from_slice(PASTE_START);
1108 raw.extend_from_slice(body);
1109 raw.extend_from_slice(PASTE_END);
1110 self.forward(&raw)
1111 }
1112
1113 /// A Ctrl+V press: make the local clipboard available in the guest,
1114 /// then (except for uploads that paste a path themselves) deliver the
1115 /// keypress so the guest application reacts to it as usual.
1116 fn handle_chord(&mut self, chord: &[u8]) -> Result<(), ()> {
1117 match read_local_clipboard() {
1118 LocalClipboard::Files(files) => {
1119 let outcome = self.upload_files(&files);
1120 self.inject_outcome(outcome)
1121 }
1122 LocalClipboard::Image(png) => {
1123 if png.len() <= CLIPBOARD_PUSH_MAX && self.push_clipboard("image/png", &png) {
1124 return self.forward(chord);
1125 }
1126 // No guest clipboard took the image (a non-devbox guest, or
1127 // one over the push cap): upload it and inject the path, the
1128 // only way a paste-reading agent gets an image here. The
1129 // chord is deliberately not forwarded — doing so would paste
1130 // whatever the guest clipboard last held. Unlike text, which
1131 // is on the clipboard almost always (so the text path never
1132 // injects, to keep vim visual-block / quoted-insert intact),
1133 // an image on the clipboard is almost always an intended
1134 // paste, so injecting wins over preserving a Ctrl+V binding.
1135 let name = self.reserve_name(std::path::Path::new("clipboard.png"));
1136 let outcome = self.upload_bytes(name, png);
1137 self.inject_outcome(outcome)
1138 }
1139 LocalClipboard::Text(text) => {
1140 if text.len() <= CLIPBOARD_PUSH_MAX {
1141 // The chord is forwarded whether or not the push
1142 // lands. Ctrl+V is not only "paste": vim binds it to
1143 // visual-block and readline to quoted-insert, and text
1144 // sits on the clipboard almost always, so replacing a
1145 // failed push with injected text or a file would fire
1146 // inside those apps constantly. On a guest with no
1147 // clipboard the app's paste read finds nothing, which
1148 // is exactly how these sessions behaved before
1149 // clipboard forwarding existed; terminal-level paste
1150 // (Cmd+V) remains the text path there.
1151 self.push_clipboard("text/plain", text.as_bytes());
1152 return self.forward(chord);
1153 }
1154 // Too big for a clipboard push. Forwarding the keypress
1155 // anyway would paste whatever the guest clipboard last
1156 // held, so deliver the text as a file like an oversized
1157 // image.
1158 let name = self.reserve_name(std::path::Path::new("clipboard.txt"));
1159 let outcome = self.upload_bytes(name, text.into_bytes());
1160 self.inject_outcome(outcome)
1161 }
1162 LocalClipboard::Empty => self.forward(chord),
1163 }
1164 }
1165
1166 /// Try to place content on the guest clipboard, reporting success,
1167 /// waiting at most [`CLIPBOARD_PUSH_DEADLINE`]. Unimplemented latches
1168 /// the fallback: this guest has no clipboard (its image ships none,
1169 /// or it predates the feature), and that never changes mid-session.
1170 /// Other failures, the deadline included, just skip the push this
1171 /// time.
1172 fn push_clipboard(&mut self, mime: &str, data: &[u8]) -> bool {
1173 if self.guest_clipboard == Some(false) {
1174 return false;
1175 }
1176 let pushed = block_on(async {
1177 tokio::time::timeout(CLIPBOARD_PUSH_DEADLINE, self.proc.set_clipboard(mime, data))
1178 .await
1179 });
1180 match pushed {
1181 Ok(Ok(())) => {
1182 self.guest_clipboard = Some(true);
1183 true
1184 }
1185 Ok(Err(SailError::Execution {
1186 code: RpcStatus::Unimplemented,
1187 ..
1188 })) => {
1189 self.guest_clipboard = Some(false);
1190 false
1191 }
1192 Ok(Err(_)) | Err(_) => false,
1193 }
1194 }
1195
1196 /// Paste an upload's guest paths, or forward nothing when the upload was
1197 /// cancelled or failed (the original chord/paste was already handled).
1198 fn inject_outcome(&mut self, outcome: UploadOutcome) -> Result<(), ()> {
1199 match outcome {
1200 UploadOutcome::Done(paths) => self.inject_uploaded(&paths.join(" "), &paths),
1201 UploadOutcome::Cancelled | UploadOutcome::Failed => Ok(()),
1202 }
1203 }
1204
1205 /// Paste the uploaded files' guest paths, deleting the uploads if the
1206 /// session ends before the paste can land, so nothing stays
1207 /// unreferenced in the guest.
1208 fn inject_uploaded(&mut self, text: &str, paths: &[String]) -> Result<(), ()> {
1209 if self.inject(text).is_err() {
1210 let _ = block_on(self.proc.remove_guest_files(paths));
1211 return Err(());
1212 }
1213 Ok(())
1214 }
1215
1216 /// Paste text into the guest exactly as the terminal would: bracketed
1217 /// while the application has mode 2004 on, plain keystrokes otherwise.
1218 fn inject(&self, text: &str) -> Result<(), ()> {
1219 let bracketed = self.render.bracketed_paste.load(Ordering::Relaxed);
1220 let mut bytes = Vec::with_capacity(text.len() + 16);
1221 if bracketed {
1222 bytes.extend_from_slice(PASTE_START);
1223 }
1224 bytes.extend_from_slice(text.as_bytes());
1225 if bracketed {
1226 bytes.extend_from_slice(PASTE_END);
1227 }
1228 self.forward(&bytes)
1229 }
1230
1231 /// Reserve a guest-side name for an upload, numbering repeats
1232 /// (photo.png, photo-2.png, ...) within the session.
1233 fn reserve_name(&mut self, path: &std::path::Path) -> String {
1234 let base = sanitize_drop_name(path);
1235 let mut name = base.clone();
1236 let mut n = 1;
1237 while !self.used_names.insert(name.clone()) {
1238 n += 1;
1239 name = match base.rsplit_once('.') {
1240 Some((stem, ext)) if !stem.is_empty() => format!("{stem}-{n}.{ext}"),
1241 _ => format!("{base}-{n}"),
1242 };
1243 }
1244 name
1245 }
1246
1247 fn upload_files(&mut self, files: &[PathBuf]) -> UploadOutcome {
1248 let mut sources = Vec::with_capacity(files.len());
1249 for path in files {
1250 let Ok(meta) = std::fs::metadata(path) else {
1251 return UploadOutcome::Failed;
1252 };
1253 let name = self.reserve_name(path);
1254 sources.push(UploadSource {
1255 guest_path: format!("{}/{name}", self.drops_dir),
1256 size: meta.len(),
1257 data: UploadData::File(path.clone()),
1258 });
1259 }
1260 self.run_upload(sources)
1261 }
1262
1263 /// Upload one in-memory blob under a name already reserved via
1264 /// [`reserve_name`](Self::reserve_name).
1265 fn upload_bytes(&mut self, name: String, bytes: Vec<u8>) -> UploadOutcome {
1266 self.run_upload(vec![UploadSource {
1267 guest_path: format!("{}/{name}", self.drops_dir),
1268 size: bytes.len() as u64,
1269 data: UploadData::Memory(bytes),
1270 }])
1271 }
1272
1273 /// Stream the sources to the guest while this thread keeps the
1274 /// terminal responsive: a progress line appears for slow uploads, Esc
1275 /// or Ctrl+C cancels (aborting the write streams, which the guest
1276 /// discards uncommitted), and other keystrokes are stashed for replay.
1277 /// Guest output rendering is paused throughout; if anything was drawn
1278 /// over the screen, a resync repaints it from the authoritative guest
1279 /// screen state.
1280 fn run_upload(&mut self, sources: Vec<UploadSource>) -> UploadOutcome {
1281 let total: u64 = sources.iter().map(|s| s.size).sum();
1282 let guest_paths: Vec<String> = sources.iter().map(|s| s.guest_path.clone()).collect();
1283 let label = if sources.len() == 1 {
1284 guest_paths[0]
1285 .rsplit('/')
1286 .next()
1287 .unwrap_or_default()
1288 .to_string()
1289 } else {
1290 format!("{} files", sources.len())
1291 };
1292 self.render.paused.store(true, Ordering::Relaxed);
1293 let progress = Arc::new(std::sync::atomic::AtomicU64::new(0));
1294 let committed: Arc<std::sync::Mutex<Vec<String>>> =
1295 Arc::new(std::sync::Mutex::new(Vec::new()));
1296 // Spawn where the exec stream lives (see block_on): an embedding
1297 // runtime's channels must not be redialed on the crate's own
1298 // reactor.
1299 let handle = tokio::runtime::Handle::try_current()
1300 .unwrap_or_else(|_| crate::runtime::runtime().handle().clone());
1301 let mut task = handle.spawn(upload_task(
1302 Arc::clone(&self.proc),
1303 sources,
1304 Arc::clone(&progress),
1305 Arc::clone(&committed),
1306 ));
1307 let started = Instant::now();
1308 let mut ui = ProgressLine::new(label, total);
1309 let mut esc_at: Option<Instant> = None;
1310 let outcome = loop {
1311 if task.is_finished() {
1312 // A bare ESC still inside its disambiguation window was a
1313 // real keypress after all; replay it with the other
1314 // stashed input instead of dropping it.
1315 if esc_at.take().is_some() {
1316 self.stashed_input.push(0x1b);
1317 }
1318 break match block_on(&mut task) {
1319 Ok(Ok(())) => UploadOutcome::Done(guest_paths.clone()),
1320 Ok(Err(err)) => {
1321 self.roll_back_upload(&committed, &guest_paths);
1322 ui.flash(&format!("[sail] upload failed: {err}"));
1323 UploadOutcome::Failed
1324 }
1325 Err(_) => {
1326 self.roll_back_upload(&committed, &guest_paths);
1327 UploadOutcome::Failed
1328 }
1329 };
1330 }
1331 if self.poll_cancel(&mut esc_at) {
1332 // Aborting drops the in-flight writer mid-stream; the
1333 // guest treats the torn stream as uncommitted and discards
1334 // it.
1335 task.abort();
1336 let _ = block_on(&mut task);
1337 self.roll_back_upload(&committed, &guest_paths);
1338 ui.flash("[sail] upload canceled");
1339 break UploadOutcome::Cancelled;
1340 }
1341 ui.tick(started, progress.load(Ordering::Relaxed));
1342 thread::sleep(Duration::from_millis(30));
1343 };
1344 ui.clear();
1345 self.render.paused.store(false, Ordering::Relaxed);
1346 if ui.wrote {
1347 // The progress line scribbled over the guest's screen; repaint
1348 // it from the guest's authoritative screen state.
1349 block_on(self.proc.resync());
1350 }
1351 outcome
1352 }
1353
1354 /// Undo an upload that will paste nothing (cancelled or failed):
1355 /// delete the files that already committed — nothing points at them,
1356 /// and cancel means the user wants none of the dragged content in the
1357 /// guest — and release the reserved names so a retried drag lands on
1358 /// the same paths. Deletion is best effort: if the guest is unreachable
1359 /// the unreferenced files linger in its /tmp until the box goes away.
1360 fn roll_back_upload(
1361 &mut self,
1362 committed: &Arc<std::sync::Mutex<Vec<String>>>,
1363 guest_paths: &[String],
1364 ) {
1365 let committed = std::mem::take(&mut *committed.lock().unwrap());
1366 if !committed.is_empty() {
1367 let _ = block_on(self.proc.remove_guest_files(&committed));
1368 }
1369 for path in guest_paths {
1370 if let Some(name) = path.rsplit('/').next() {
1371 self.used_names.remove(name);
1372 }
1373 }
1374 }
1375
1376 /// Drain stdin during an upload. Ctrl+C cancels at once; a bare ESC
1377 /// cancels after a short pause — long enough for the rest of an escape
1378 /// sequence (an arrow key) to arrive and be stashed instead. Everything
1379 /// else is stashed and replayed after the upload.
1380 fn poll_cancel(&mut self, esc_at: &mut Option<Instant>) -> bool {
1381 let mut buf = [0u8; 256];
1382 loop {
1383 let n = unsafe {
1384 libc::read(
1385 libc::STDIN_FILENO,
1386 buf.as_mut_ptr().cast::<libc::c_void>(),
1387 buf.len(),
1388 )
1389 };
1390 if n <= 0 {
1391 break;
1392 }
1393 let bytes = &buf[..n as usize];
1394 for (idx, &byte) in bytes.iter().enumerate() {
1395 if byte == 0x03 {
1396 // Keys typed in the same burst as the cancel replay
1397 // after the upload settles.
1398 self.stashed_input.extend_from_slice(&bytes[idx + 1..]);
1399 return true;
1400 }
1401 if esc_at.take().is_some() {
1402 // The pending ESC was the start of a sequence after all.
1403 self.stashed_input.push(0x1b);
1404 }
1405 if byte == 0x1b && idx == bytes.len() - 1 {
1406 *esc_at = Some(Instant::now());
1407 } else {
1408 self.stashed_input.push(byte);
1409 }
1410 }
1411 }
1412 // 60ms: longer than one 30ms poll tick of run_upload, so a split
1413 // escape sequence has a whole further poll to finish arriving.
1414 esc_at.is_some_and(|at| at.elapsed() >= Duration::from_millis(60))
1415 }
1416 }
1417
1418 /// The background half of an upload: stream every source into the guest,
1419 /// publishing progress for the interactive thread's UI and each committed
1420 /// guest path for cancel's rollback.
1421 async fn upload_task(
1422 proc: Arc<ExecProcess>,
1423 sources: Vec<UploadSource>,
1424 progress: Arc<std::sync::atomic::AtomicU64>,
1425 committed: Arc<std::sync::Mutex<Vec<String>>>,
1426 ) -> Result<(), SailError> {
1427 use tokio::io::AsyncReadExt;
1428 for source in sources {
1429 let mut writer = proc.guest_file_writer(&source.guest_path);
1430 match source.data {
1431 UploadData::Memory(bytes) => {
1432 for chunk in bytes.chunks(UPLOAD_CHUNK_BYTES) {
1433 writer.write_chunk(chunk.to_vec()).await?;
1434 progress.fetch_add(chunk.len() as u64, Ordering::Relaxed);
1435 }
1436 }
1437 UploadData::File(path) => {
1438 let mut file =
1439 tokio::fs::File::open(&path)
1440 .await
1441 .map_err(|err| SailError::Internal {
1442 message: format!("read {}: {err}", path.display()),
1443 })?;
1444 let mut chunk = vec![0u8; UPLOAD_CHUNK_BYTES];
1445 loop {
1446 let n = file
1447 .read(&mut chunk)
1448 .await
1449 .map_err(|err| SailError::Internal {
1450 message: format!("read {}: {err}", path.display()),
1451 })?;
1452 if n == 0 {
1453 break;
1454 }
1455 writer.write_chunk(chunk[..n].to_vec()).await?;
1456 progress.fetch_add(n as u64, Ordering::Relaxed);
1457 }
1458 }
1459 }
1460 // Record the path before finish()'s await: finish closes the
1461 // client stream, so a cancel that aborts this task during the
1462 // await can still let the guest commit the file. Recording first
1463 // guarantees rollback has the path — an rm of a file that never
1464 // committed is a harmless no-op.
1465 committed.lock().unwrap().push(source.guest_path);
1466 writer.finish().await?;
1467 }
1468 Ok(())
1469 }
1470
1471 /// The one-line upload status drawn at the cursor. It only ever repaints
1472 /// itself in place; whatever it overwrote is restored by the post-upload
1473 /// resync.
1474 struct ProgressLine {
1475 label: String,
1476 total: u64,
1477 wrote: bool,
1478 last_draw: Option<Instant>,
1479 }
1480
1481 impl ProgressLine {
1482 fn new(label: String, total: u64) -> ProgressLine {
1483 ProgressLine {
1484 label,
1485 total,
1486 wrote: false,
1487 last_draw: None,
1488 }
1489 }
1490
1491 fn tick(&mut self, started: Instant, sent: u64) {
1492 if !self.wrote && started.elapsed() < UPLOAD_UI_DELAY {
1493 return;
1494 }
1495 if self
1496 .last_draw
1497 .is_some_and(|last| last.elapsed() < Duration::from_millis(100))
1498 {
1499 return;
1500 }
1501 self.last_draw = Some(Instant::now());
1502 self.wrote = true;
1503 let percent = (sent.min(self.total) * 100)
1504 .checked_div(self.total)
1505 .unwrap_or(100);
1506 write_terminal_line(&format!(
1507 "[sail] uploading {} {percent}% {} / {} (esc cancels)",
1508 self.label,
1509 format_bytes(sent),
1510 format_bytes(self.total),
1511 ));
1512 }
1513
1514 /// Show a final status long enough to read before the screen repaints.
1515 fn flash(&mut self, message: &str) {
1516 self.wrote = true;
1517 write_terminal_line(message);
1518 thread::sleep(Duration::from_millis(1200));
1519 }
1520
1521 fn clear(&mut self) {
1522 if self.wrote {
1523 write_terminal(ERASE_LINE);
1524 }
1525 }
1526 }
1527
1528 /// Carriage return + erase-line: return to column 0 and clear the row, so
1529 /// the next write overwrites the cursor line in place.
1530 const ERASE_LINE: &[u8] = b"\r\x1b[2K";
1531
1532 /// Overwrite the cursor line with `text`.
1533 fn write_terminal_line(text: &str) {
1534 let mut bytes = Vec::with_capacity(text.len() + ERASE_LINE.len());
1535 bytes.extend_from_slice(ERASE_LINE);
1536 bytes.extend_from_slice(text.as_bytes());
1537 write_terminal(&bytes);
1538 }
1539
1540 fn write_terminal(bytes: &[u8]) {
1541 let mut sink = RawFdWriter(libc::STDOUT_FILENO);
1542 flush_blocking(&mut sink, bytes);
1543 }
1544
1545 /// Sizes for the progress line, in the 1000-based MB/KB a file manager
1546 /// labels the dragged file with.
1547 fn format_bytes(n: u64) -> String {
1548 const MB: u64 = 1_000_000;
1549 if n >= 10 * MB {
1550 format!("{} MB", n / MB)
1551 } else if n >= MB {
1552 format!("{:.1} MB", n as f64 / MB as f64)
1553 } else {
1554 format!("{} KB", n.div_ceil(1000))
1555 }
1556 }
1557
1558 // --- platform terminal plumbing ---
1559
1560 /// Put the local terminal into raw mode, returning the saved settings.
1561 fn enter_raw_mode() -> Result<libc::termios, SailError> {
1562 unsafe {
1563 let mut saved: libc::termios = std::mem::zeroed();
1564 if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
1565 return Err(SailError::Internal {
1566 message: format!(
1567 "could not enter raw terminal mode: {}",
1568 std::io::Error::last_os_error()
1569 ),
1570 });
1571 }
1572 let mut raw = saved;
1573 libc::cfmakeraw(&raw mut raw);
1574 if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
1575 return Err(SailError::Internal {
1576 message: format!(
1577 "could not enter raw terminal mode: {}",
1578 std::io::Error::last_os_error()
1579 ),
1580 });
1581 }
1582 Ok(saved)
1583 }
1584 }
1585
1586 fn restore_terminal(saved: &libc::termios) {
1587 unsafe {
1588 let _ = libc::tcsetattr(
1589 libc::STDIN_FILENO,
1590 libc::TCSADRAIN,
1591 std::ptr::from_ref(saved),
1592 );
1593 }
1594 }
1595
1596 type SigHandler = libc::sighandler_t;
1597
1598 fn install_sigwinch() -> SigHandler {
1599 // `signal` takes the handler as a numeric `sighandler_t`; cast through a
1600 // concrete fn pointer first so this is a pointer-to-int cast, not a
1601 // fn-item-to-int cast.
1602 let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
1603 unsafe { libc::signal(libc::SIGWINCH, handler) }
1604 }
1605
1606 fn restore_sigwinch(prev: SigHandler) {
1607 unsafe {
1608 libc::signal(libc::SIGWINCH, prev);
1609 }
1610 }
1611
1612 /// Put stdin into non-blocking mode so the input loop can interleave reads
1613 /// with resize handling and the stop flag. Returns the previous fcntl flags.
1614 fn set_stdin_nonblocking() -> libc::c_int {
1615 unsafe {
1616 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
1617 if flags >= 0 {
1618 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
1619 }
1620 flags
1621 }
1622 }
1623
1624 fn restore_stdin_flags(flags: libc::c_int) {
1625 if flags >= 0 {
1626 unsafe {
1627 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
1628 }
1629 }
1630 }
1631
1632 /// Put stdout into non-blocking mode so the output pump is never parked in a
1633 /// write to a slow terminal. Returns the previous fcntl flags.
1634 fn set_stdout_nonblocking() -> libc::c_int {
1635 unsafe {
1636 let flags = libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFL);
1637 if flags >= 0 {
1638 libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
1639 }
1640 flags
1641 }
1642 }
1643
1644 fn restore_stdout_flags(flags: libc::c_int) {
1645 if flags >= 0 {
1646 unsafe {
1647 libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags);
1648 }
1649 }
1650 }
1651
1652 #[cfg(test)]
1653 mod tests {
1654 use super::*;
1655
1656 #[test]
1657 fn resync_due_throttles_back_to_back_requests() {
1658 let mut last = None;
1659 // The first request is always due and stamps the clock.
1660 assert!(resync_due(&mut last));
1661 // A second request within RESYNC_MIN_INTERVAL is suppressed, so a
1662 // persistently-behind reader cannot flood the guest with resync RPCs.
1663 assert!(!resync_due(&mut last));
1664 }
1665
1666 #[test]
1667 fn format_bytes_matches_its_thousand_based_labels() {
1668 assert_eq!(format_bytes(0), "0 KB");
1669 assert_eq!(format_bytes(1), "1 KB");
1670 assert_eq!(format_bytes(999_999), "1000 KB");
1671 assert_eq!(format_bytes(1_000_000), "1.0 MB");
1672 assert_eq!(format_bytes(3_200_000), "3.2 MB");
1673 assert_eq!(format_bytes(25_000_000), "25 MB");
1674 }
1675 }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use super::*;
1681
1682 #[test]
1683 fn login_shell_quotes_an_explicit_path() {
1684 // A path with spaces runs as one literal program.
1685 assert_eq!(
1686 login_shell_command(Some("/opt/my tools/zsh")),
1687 "exec '/opt/my tools/zsh' -l"
1688 );
1689 // The default stays unquoted so the guest expands $SHELL.
1690 assert_eq!(
1691 login_shell_command(/* shell */ None),
1692 "exec ${SHELL:-/bin/bash} -l"
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn shell_requires_a_tty() {
1698 // Test processes have no TTY on stdin/stdout, so the precondition
1699 // fires before any network or terminal manipulation.
1700 let client = crate::Client::builder("sk_test")
1701 .api_url("http://127.0.0.1:1")
1702 .sailbox_api_url("http://127.0.0.1:1")
1703 .build()
1704 .expect("build");
1705 let err = client
1706 .sailbox("sb_test")
1707 .shell(/* command */ None, ShellOptions::default())
1708 .await
1709 .expect_err("no tty in tests");
1710 assert!(err.to_string().contains("interactive terminal"), "{err}");
1711 }
1712}