sail/exec.rs
1//! A running command in a Sailbox with live output.
2//!
3//! [`ExecProcess::start`] launches a command and returns a handle right away,
4//! carrying its durable `exec_request_id`. The command runs detached, so
5//! dropping the handle never kills it. Read stdout and stderr live through
6//! [`StreamReader`], write to stdin, and call [`ExecProcess::wait`] for the
7//! exit result. Output and the exit status survive a dropped connection: the
8//! handle resumes the live output where it left off, or falls back to the
9//! buffered result, so a caller never loses the tail.
10
11use std::collections::VecDeque;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Condvar, Mutex};
14use std::time::Duration;
15
16use serde::Serialize;
17use tokio::sync::Mutex as AsyncMutex;
18use tokio::sync::Notify;
19use tonic::{Code, Status, Streaming};
20
21use crate::error::SailError;
22use crate::pb::workerproxy::v1 as pb;
23use crate::worker::{
24 retry_deadline, rpc_attempt_timeout, should_invalidate_channel,
25 should_retry_transient_exec_rpc, sleep_before_retry, WorkerProxy,
26 EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS,
27};
28
29/// Default budget for transient-RPC retries against a waking/migrating Sailbox;
30/// the default value of [`ExecOptions::retry_timeout`]. Long enough that a
31/// wake queued behind other restores completes instead of surfacing a
32/// transient error.
33#[doc(hidden)]
34pub const EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS: f64 = 600.0;
35/// Local cap on buffered stream output: a slow reader loses the oldest output
36/// rather than blocking the stream. Sized to the server's in-memory exec
37/// replay ring so a locally resolved tail is the same size the server replays
38/// on a reattach. A backend test keeps this in lockstep with the ring
39/// (`guestExecChunkBufferBytes`); change both together.
40const STREAM_BUFFER_CAP_BYTES: usize = 1024 * 1024;
41/// Stdin writes are chunked so a single RPC stays well under gRPC message limits
42/// and partial accepts resume cheaply.
43const STDIN_WRITE_CHUNK_BYTES: usize = 256 * 1024;
44
45/// Lock a mutex, recovering the guard if a peer panicked while holding it
46/// (matching `channels.rs`). The data under these locks is simple, so a poisoned
47/// peer should degrade rather than cascade a panic into reader/pump threads.
48fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
49 mutex
50 .lock()
51 .unwrap_or_else(std::sync::PoisonError::into_inner)
52}
53
54/// Which output stream a chunk or reader belongs to.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum OutputStream {
57 /// The standard output stream.
58 Stdout,
59 /// The standard error stream.
60 Stderr,
61}
62
63/// One step of reading a live output stream.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum ReadStep {
66 /// The next retained chunk of output, exactly as the command wrote it: the
67 /// live stream is a byte pipe (escape sequences and binary payloads
68 /// included), not decoded text. String-typed conveniences decode at the
69 /// edge ([`ExecResult`], the bindings' str iterators).
70 Chunk(Vec<u8>),
71 /// The stream is closed and fully drained.
72 Eof,
73 /// Nothing new before the timeout; the caller may check for signals and
74 /// retry.
75 Pending,
76}
77
78/// The buffered result of a finished exec.
79#[derive(Debug, Clone, Serialize)]
80#[non_exhaustive]
81#[allow(clippy::struct_excessive_bools)]
82pub struct ExecResult {
83 /// Buffered stdout, lossily decoded as UTF-8 (the live byte stream is
84 /// unmodified; the decode happens only here). For a pty exec that was
85 /// reattached, this is the last screen repaint plus the output after it,
86 /// not a full transcript.
87 pub stdout: String,
88 /// Buffered stderr, lossily decoded as UTF-8 (see `stdout`).
89 pub stderr: String,
90 /// The command's exit code.
91 pub exit_code: i32,
92 /// Whether the command was killed for exceeding its timeout.
93 pub timed_out: bool,
94 /// Whether stdout exceeded the captured-output cap, dropping its oldest
95 /// bytes.
96 pub stdout_truncated: bool,
97 /// Whether stderr exceeded the captured-output cap, dropping its oldest
98 /// bytes.
99 pub stderr_truncated: bool,
100 /// Whether the live stream delivered stdout through to the command's exit.
101 /// When true, a consumer that streamed the output live already holds the
102 /// complete stdout even if `stdout` here is a truncated buffered tail. When
103 /// false (the stream ended before the exit, or no exit was observed),
104 /// `stdout` is the authoritative buffered copy to fall back on.
105 pub stdout_complete: bool,
106 /// Whether the live stream delivered stderr through to the command's exit
107 /// (see `stdout_complete`).
108 pub stderr_complete: bool,
109 /// Total bytes the command wrote to stdout over its whole run, including
110 /// bytes truncation dropped from the buffered `stdout` field above. `0` when
111 /// unknown (no exit was observed on the stream, or an older guest). Subtract
112 /// what a consumer actually saw to learn how much was lost.
113 pub stdout_total_bytes: i64,
114 /// Total bytes the command wrote to stderr over its whole run (see
115 /// `stdout_total_bytes`).
116 pub stderr_total_bytes: i64,
117}
118
119/// Which signal to send when cancelling a running exec.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CancelSignal {
122 /// SIGINT: ask the command to stop (what a first Ctrl-C sends).
123 Interrupt,
124 /// SIGKILL: force-kill a command that ignored the interrupt.
125 Kill,
126}
127
128impl CancelSignal {
129 /// Whether this is the forceful (SIGKILL) variant, as the wire encodes it.
130 fn is_force(self) -> bool {
131 matches!(self, CancelSignal::Kill)
132 }
133}
134
135/// How long to keep retrying transient RPCs against a waking or migrating
136/// Sailbox before giving up.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RetryBudget {
139 /// Do not retry; fail on the first transient error.
140 None,
141 /// Retry for at most this long.
142 Within(Duration),
143 /// Retry indefinitely, until the call succeeds or hits a non-transient error.
144 Forever,
145}
146
147/// Retry budget for the cancel RPC against a box that may be waking or
148/// migrating: such a box can reject the cancel with `Unavailable` for up to
149/// ~1s while it finishes coming back, so retrying within this budget lets the
150/// signal still land. Distinct from the exec's run-retry budget (which `wait` uses) and
151/// from the command's `timeout` (a server-enforced kill). One value for every
152/// surface so Ctrl-C behaves the same across the SDKs and CLI.
153pub const EXEC_CANCEL_RETRY: RetryBudget = RetryBudget::Within(Duration::from_secs(5));
154
155impl RetryBudget {
156 /// Encode as the seconds the core's retry loop expects: `0` = none, a finite
157 /// count = a bounded budget, `+inf` = forever.
158 #[doc(hidden)]
159 pub fn as_secs_f64(self) -> f64 {
160 match self {
161 RetryBudget::None => 0.0,
162 RetryBudget::Within(d) => d.as_secs_f64(),
163 RetryBudget::Forever => f64::INFINITY,
164 }
165 }
166
167 /// Decode from seconds at the FFI boundary (Python passes an `f64`): `<= 0` =
168 /// none, a non-finite value = forever, otherwise a bounded budget.
169 #[doc(hidden)]
170 pub fn from_secs_f64(secs: f64) -> RetryBudget {
171 if secs <= 0.0 {
172 RetryBudget::None
173 } else if secs.is_finite() {
174 RetryBudget::Within(Duration::from_secs_f64(secs))
175 } else {
176 RetryBudget::Forever
177 }
178 }
179}
180
181/// Optional settings for [`Sailbox::exec`](crate::Sailbox::exec) and
182/// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell). `Default` runs a
183/// plain foreground command (no pty, no stdin, no timeout) and retries transient
184/// failures against a waking or migrating Sailbox for ten minutes (see
185/// [`retry_timeout`](Self::retry_timeout)).
186#[allow(clippy::struct_excessive_bools)] // independent command settings, not a state machine
187#[derive(Debug, Clone)]
188pub struct ExecOptions {
189 /// Wall-clock limit before the server kills the command; `None` means no
190 /// limit. The wire is whole seconds, so a set sub-second timeout rounds up
191 /// to 1 second (it never collapses to the no-limit `0`).
192 pub timeout: Option<Duration>,
193 /// Leave the command's stdin open for [`ExecProcess::write_stdin`].
194 pub open_stdin: bool,
195 /// Allocate a pseudo-terminal for the command.
196 pub pty: bool,
197 /// TERM value for the pty (e.g. `xterm-256color`); ignored without `pty`.
198 pub term: String,
199 /// Initial pty width in columns; ignored without `pty`.
200 pub cols: u32,
201 /// Initial pty height in rows; ignored without `pty`.
202 pub rows: u32,
203 /// Extra environment for the command, applied for pty and non-pty execs
204 /// alike. Entries override the guest's defaults (including `LANG`) and the
205 /// image env. A few reserved variables that identify the Sailbox (such as
206 /// `SAILBOX_ID`) cannot be overridden. For pty execs the terminal variables
207 /// (`COLORTERM`, `LANG`, `LC_*`, `TERM_PROGRAM`) are auto-forwarded from the
208 /// local environment for keys not set here.
209 pub env: Vec<(String, String)>,
210 /// Stable key that dedupes the launch so a reconnect reattaches to the same
211 /// command. Empty mints a fresh one per call.
212 pub idempotency_key: String,
213 /// Budget for retrying transient failures against a waking or migrating
214 /// Sailbox: while opening the output stream, when [`ExecProcess::wait`]
215 /// reattaches to the guest for the result, and for stdin writes'
216 /// transport retries.
217 pub retry_timeout: RetryBudget,
218 /// Working directory to run a shell command in. Only valid with
219 /// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell).
220 pub cwd: Option<String>,
221 /// Detach a shell command so it keeps running and the call returns
222 /// immediately; output is discarded. Only valid with
223 /// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell), and incompatible with
224 /// `open_stdin` and `pty`.
225 pub background: bool,
226 /// Forward the command's localhost servers to the user's machine. Set by the
227 /// interactive shell; off for ordinary execs.
228 pub forward_ports: bool,
229 /// Forward the command's browser opens to the user's machine. Set by the
230 /// interactive shell; off for ordinary execs.
231 pub forward_browser: bool,
232 /// Bridge the guest clipboard to this client while the stream is attached:
233 /// the guest mirrors in-guest copies out as clipboard updates, and accepts
234 /// `set_clipboard` writes. Set by the interactive shell; off for ordinary
235 /// execs. Only meaningful with `pty`, and only on guests whose image ships
236 /// a clipboard. The local input side (paste and drag-and-drop scanning)
237 /// lives in `shell::run_interactive`.
238 pub forward_clipboard: bool,
239}
240
241impl Default for ExecOptions {
242 fn default() -> ExecOptions {
243 ExecOptions {
244 timeout: None,
245 open_stdin: false,
246 pty: false,
247 term: String::new(),
248 cols: 0,
249 rows: 0,
250 env: Vec::new(),
251 idempotency_key: String::new(),
252 retry_timeout: RetryBudget::Within(Duration::from_secs_f64(
253 EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
254 )),
255 cwd: None,
256 background: false,
257 forward_ports: false,
258 forward_browser: false,
259 forward_clipboard: false,
260 }
261 }
262}
263
264/// Derive the exec forwarding flags from an interactive session's opt-out flags,
265/// returning `(forward_ports, forward_browser, forward_clipboard)`. `no_forward`
266/// turns off all three; `no_forward_browser` turns off only browser opens.
267/// Browser forwarding always implies port forwarding, since a login's OAuth
268/// callback is itself a forwarded localhost server, so it is gated on both
269/// opt-outs.
270#[doc(hidden)]
271pub fn forward_flags(no_forward: bool, no_forward_browser: bool) -> (bool, bool, bool) {
272 let forward_ports = !no_forward;
273 let forward_browser = forward_ports && !no_forward_browser;
274 let forward_clipboard = !no_forward;
275 (forward_ports, forward_browser, forward_clipboard)
276}
277
278/// Optional settings for [`Sailbox::run`](crate::Sailbox::run) and
279/// [`Sailbox::run_shell`](crate::Sailbox::run_shell): the [`ExecOptions`]
280/// subset that applies to a buffered one-shot run (no pty, no stdin, no
281/// background).
282#[derive(Debug, Clone, Default)]
283pub struct RunOptions {
284 /// Wall-clock limit before the server kills the command; `None` means no
285 /// limit. An exceeded limit reports through [`ExecResult::timed_out`],
286 /// not an error.
287 pub timeout: Option<Duration>,
288 /// Extra environment for the command (see [`ExecOptions::env`]).
289 pub env: Vec<(String, String)>,
290 /// Working directory to run a shell command in. Only valid with
291 /// [`Sailbox::run_shell`](crate::Sailbox::run_shell).
292 pub cwd: Option<String>,
293 /// Stable key that dedupes the launch, so a retried `run` waits on the
294 /// original command instead of starting it again. Empty mints a fresh key
295 /// per call.
296 pub idempotency_key: String,
297}
298
299impl RunOptions {
300 /// The equivalent [`ExecOptions`] for the underlying exec call.
301 pub(crate) fn into_exec_options(self) -> ExecOptions {
302 ExecOptions {
303 timeout: self.timeout,
304 env: self.env,
305 cwd: self.cwd,
306 idempotency_key: self.idempotency_key,
307 ..ExecOptions::default()
308 }
309 }
310}
311
312/// POSIX single-quote a string for safe inclusion in a shell command.
313pub(crate) fn sh_quote(value: &str) -> String {
314 format!("'{}'", value.replace('\'', "'\\''"))
315}
316
317/// Local env vars auto-forwarded to pty execs so terminal programs render
318/// correctly (truecolor detection, locale-driven width math). TERM rides the
319/// dedicated `term` field, not this list.
320const PTY_ENV_WHITELIST: [&str; 3] = ["COLORTERM", "LANG", "TERM_PROGRAM"];
321
322fn pty_env_whitelisted(key: &str) -> bool {
323 PTY_ENV_WHITELIST.contains(&key) || key.starts_with("LC_")
324}
325
326/// Snapshot the local environment filtered to the pty forwarding whitelist.
327pub(crate) fn pty_forward_env() -> Vec<(String, String)> {
328 // vars_os, not vars: std::env::vars panics on any non-Unicode entry in the
329 // inherited environment, even one unrelated to the whitelist. Entries that
330 // do not decode cannot ride a proto string map anyway, so they are skipped.
331 pty_forward_env_from(
332 std::env::vars_os()
333 .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))),
334 )
335}
336
337fn pty_forward_env_from(vars: impl Iterator<Item = (String, String)>) -> Vec<(String, String)> {
338 vars.filter(|(key, _)| pty_env_whitelisted(key)).collect()
339}
340
341/// Validate user-supplied env pairs into the wire map. Values are free-form;
342/// keys must be non-empty and free of `=` and NUL (execve constraints). Every
343/// binding's user env funnels through here (the Rust client and the bindings
344/// that build `ExecParams` directly), so a key like `"A=B"` fails loudly
345/// instead of silently becoming a different variable in the guest.
346#[doc(hidden)]
347pub fn encode_env(
348 pairs: &[(String, String)],
349) -> Result<std::collections::HashMap<String, String>, SailError> {
350 let mut env = std::collections::HashMap::with_capacity(pairs.len());
351 for (key, value) in pairs {
352 // The name must be a portable identifier and the value must carry no NUL
353 // (execve cannot represent either). is_portable_env_name already rejects
354 // '=', whitespace, and NUL in the name, so only the value needs a guard.
355 if !is_portable_env_name(key) || value.contains('\0') {
356 return Err(SailError::InvalidArgument {
357 message: format!("invalid env entry {key:?}"),
358 });
359 }
360 env.insert(key.clone(), value.clone());
361 }
362 Ok(env)
363}
364
365/// Whether `name` is a portable environment variable name: a non-empty run of
366/// `[A-Za-z_][A-Za-z0-9_]*`. Rejects a leading digit, whitespace, `=`, a NUL, or
367/// any other character the guest could not represent (or a shell could not read
368/// back) as an environment entry.
369fn is_portable_env_name(name: &str) -> bool {
370 let mut chars = name.chars();
371 match chars.next() {
372 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
373 _ => return false,
374 }
375 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
376}
377
378/// Build the `argv` that runs `command` via `/bin/sh -lc`, applying the
379/// `cwd`/`background` shell conveniences from `options` and validating their
380/// combinations. This is the single implementation behind every SDK's
381/// string-command exec.
382#[doc(hidden)]
383pub fn shell_argv(command: &str, options: &ExecOptions) -> Result<Vec<String>, SailError> {
384 let invalid = |message: &str| {
385 Err(SailError::InvalidArgument {
386 message: message.to_string(),
387 })
388 };
389 if command.is_empty() {
390 return invalid("command must be non-empty");
391 }
392 if options.background && (options.open_stdin || options.pty) {
393 return invalid("background is not supported with open_stdin or pty");
394 }
395 let mut command = command.to_string();
396 if let Some(cwd) = &options.cwd {
397 let cwd = cwd.trim();
398 if cwd.is_empty() {
399 return invalid("cwd must be non-empty");
400 }
401 command = format!(
402 "cd {} && exec /bin/sh -lc {}",
403 sh_quote(cwd),
404 sh_quote(&command)
405 );
406 }
407 if options.background {
408 command = format!(
409 "nohup /bin/sh -lc {} </dev/null >/dev/null 2>&1 &",
410 sh_quote(&command)
411 );
412 }
413 Ok(vec!["/bin/sh".to_string(), "-lc".to_string(), command])
414}
415
416/// Parameters captured at launch and reused on every reconnect.
417#[doc(hidden)]
418#[derive(Debug, Clone)]
419#[allow(clippy::struct_excessive_bools)]
420pub struct ExecParams {
421 /// The Sailbox the command runs in.
422 pub sailbox_id: String,
423 /// Worker-proxy endpoint that terminates the exec RPCs for this Sailbox.
424 pub exec_endpoint: String,
425 /// The command and its arguments.
426 pub argv: Vec<String>,
427 /// Wall-clock limit in seconds before the server kills the command; 0 means
428 /// no limit.
429 pub timeout_seconds: u32,
430 /// Stable key that dedupes the launch and identifies the stream so a
431 /// reconnect reattaches to the same command rather than starting a new one.
432 pub idempotency_key: String,
433 /// Whether the command's stdin is left open for writes.
434 pub open_stdin: bool,
435 /// Whether to allocate a pseudo-terminal for the command.
436 pub pty: bool,
437 /// TERM value for the pty (e.g. `xterm-256color`); empty when not a pty.
438 pub term: String,
439 /// Initial pty width in columns.
440 pub cols: u32,
441 /// Initial pty height in rows.
442 pub rows: u32,
443 /// Extra environment for the command as wire-ready KEY=VALUE entries,
444 /// resolved once at launch (including the pty terminal whitelist) and
445 /// resent verbatim on every reconnect.
446 pub env: std::collections::HashMap<String, String>,
447 /// Budget in seconds for retrying transient failures while opening or
448 /// resuming the stream.
449 pub retry_timeout: f64,
450 /// Forward the command's localhost servers to the user's machine. Set by the
451 /// interactive shell.
452 pub forward_ports: bool,
453 /// Forward the command's browser opens to the user's machine. Set by the
454 /// interactive shell.
455 pub forward_browser: bool,
456 /// Tracing metadata the wrapper injects (e.g. Voyages); opaque to the core.
457 pub extra_metadata: Vec<(String, String)>,
458 /// Ask the guest to bridge its clipboard to this stream (see
459 /// [`ExecOptions::forward_clipboard`]).
460 pub forward_clipboard: bool,
461}
462
463impl ExecParams {
464 /// Ensures reconnects and endpoint re-resolution reuse one command
465 /// identity even when the caller did not provide an idempotency key.
466 #[doc(hidden)]
467 pub fn ensure_idempotency_key(&mut self) {
468 let key = self.idempotency_key.trim();
469 self.idempotency_key = if key.is_empty() {
470 format!("exec_{}", uuid::Uuid::new_v4())
471 } else {
472 key.to_string()
473 };
474 }
475}
476
477/// Drop-oldest byte ring mirroring the server output ring. Appends never
478/// block; past the cap the oldest bytes are dropped (byte-exact) and `dropped`
479/// latches. Pieces carry absolute indices so a reader that falls behind skips
480/// the dropped head instead of stalling.
481#[derive(Default)]
482struct Ring {
483 pieces: Vec<Vec<u8>>,
484 first_idx: usize,
485 size: usize,
486 dropped: bool,
487 /// Monotonic count of in-place front-piece clips. A clip drops bytes from
488 /// the piece at `first_idx` without advancing it, so a reader parked on
489 /// that piece cannot see the loss through `first_idx` alone; it compares
490 /// this instead.
491 front_clips: u64,
492 /// Monotonic count of `reset_to` repaints. A repaint advances `first_idx`
493 /// past a reader's cursor like an eviction, but it supersedes those bytes
494 /// with a fresh screen instead of losing them, so a reader compares this to
495 /// tell a heal from a fall-behind drop.
496 resets: u64,
497}
498
499impl Ring {
500 fn append(&mut self, data: Vec<u8>) {
501 self.size += data.len();
502 self.pieces.push(data);
503 while self.size > STREAM_BUFFER_CAP_BYTES {
504 let overflow = self.size - STREAM_BUFFER_CAP_BYTES;
505 if self.pieces[0].len() <= overflow {
506 self.size -= self.pieces[0].len();
507 self.pieces.remove(0);
508 self.first_idx += 1;
509 } else {
510 self.pieces[0].drain(..overflow);
511 self.size -= overflow;
512 self.front_clips += 1;
513 }
514 self.dropped = true;
515 }
516 }
517
518 /// Replace the retained content with a pty screen repaint. Advancing
519 /// `first_idx` past the old pieces makes every attached reader (cursor
520 /// below it) skip straight to the repaint, and a late reader replays only
521 /// the repaint. `dropped` is cleared: the repaint supersedes everything
522 /// the ring ever dropped, so a healed session must not read as truncated
523 /// (which would force `wait()` into the server fallback). `append`
524 /// re-latches it only if the repaint itself overflows.
525 fn reset_to(&mut self, repaint: Vec<u8>) {
526 self.first_idx += self.pieces.len();
527 self.pieces.clear();
528 self.size = 0;
529 self.dropped = false;
530 self.resets += 1;
531 if !repaint.is_empty() {
532 self.append(repaint);
533 }
534 }
535
536 fn tail(&self) -> Vec<u8> {
537 self.pieces.concat()
538 }
539}
540
541/// Lossily decode a ring's retained bytes for the string-typed [`ExecResult`].
542/// The only place live output becomes text in the core. NUL is replaced too:
543/// it is valid UTF-8 that `from_utf8_lossy` keeps, but the text result is the
544/// client twin of the guest's persisted tail (which replaces NUL with U+FFFD
545/// for its Postgres text column), so the two agree. The raw byte readers keep NUL.
546fn lossy_tail(ring: &Ring) -> String {
547 String::from_utf8_lossy(&ring.tail()).replace('\0', "\u{FFFD}")
548}
549
550#[derive(Default)]
551struct State {
552 stdout: Ring,
553 stderr: Ring,
554 ended: bool,
555}
556
557impl State {
558 fn ring(&self, which: OutputStream) -> &Ring {
559 match which {
560 OutputStream::Stdout => &self.stdout,
561 OutputStream::Stderr => &self.stderr,
562 }
563 }
564}
565
566/// Terminal exec result captured from the Exit frame or a poll.
567#[derive(Clone)]
568struct ExitInfo {
569 status: i32,
570 exit_code: i32,
571 timed_out: bool,
572 stdout_truncated: bool,
573 stderr_truncated: bool,
574 error_message: String,
575 stdout_seq: i64,
576 stderr_seq: i64,
577 stdout_total_bytes: i64,
578 stderr_total_bytes: i64,
579}
580
581#[derive(Default)]
582struct StdinState {
583 offset: i64,
584 eof_sent: bool,
585 broken: bool,
586 /// Set under the lock for the duration of a data write, which holds the lock
587 /// across its network send. A clean return clears it; a write whose future
588 /// is dropped mid-send (the caller cancelled it) releases the lock with this
589 /// still set, so the next writer observes it and poisons rather than
590 /// resuming from a stale offset. This is the cancellation latch: it lives in
591 /// the same lock that serializes writes, so no later write can race ahead of
592 /// it.
593 write_in_flight: bool,
594}
595
596/// A local-forwarding request the guest sends for an interactive session,
597/// consumed by the shell driver to act on the user's machine.
598#[derive(Debug, Clone)]
599pub enum ForwardEvent {
600 /// Open this URL in the user's local browser.
601 OpenUrl(String),
602 /// The current set of localhost servers in the sandbox. The client forwards
603 /// these and drops forwards for any no longer listed.
604 PortSnapshot(Vec<u16>),
605}
606
607/// Cap on forward events awaiting the shell driver. A real session drains these
608/// as fast as it opens tabs and binds ports, so this only bounds memory if a guest
609/// opens them faster than the driver consumes; excess is dropped.
610const MAX_PENDING_FORWARD_EVENTS: usize = 128;
611
612struct ExecShared {
613 worker: Arc<WorkerProxy>,
614 params: ExecParams,
615 /// Newest unapplied guest-clipboard content (mime, bytes), latest wins —
616 /// the clipboard holds one thing, so there is no backlog to replay.
617 /// `clipboard_notify` wakes the consumer; end of stream wakes it too so it
618 /// can exit.
619 clipboard_update: Mutex<Option<(String, Vec<u8>)>>,
620 clipboard_notify: Notify,
621 state: Mutex<State>,
622 /// Local-forwarding events for an interactive session (browser opens and
623 /// localhost-server snapshots), queued by the pump and drained by the shell
624 /// driver.
625 forward_events: Mutex<VecDeque<ForwardEvent>>,
626 forward_notify: Notify,
627 /// Wakes synchronous readers/waiters when output is appended or the stream
628 /// ends.
629 cond: Condvar,
630 /// The async counterpart of `cond`: wakes [`AsyncStreamReader`]s without
631 /// parking a runtime thread. Notified on every append and at end of stream.
632 data_notify: Notify,
633 exit: Mutex<Option<ExitInfo>>,
634 /// Highest chunk seq received per stream, published when the pump ends.
635 high_seq: Mutex<(i64, i64)>,
636 stdin: AsyncMutex<StdinState>,
637 ended: AtomicBool,
638 ended_notify: Notify,
639 closing: AtomicBool,
640 close_notify: Notify,
641}
642
643/// A handle to a running command. Drop or [`ExecProcess::close`] releases the
644/// stream without killing the command.
645pub struct ExecProcess {
646 shared: Arc<ExecShared>,
647 exec_request_id: String,
648}
649
650impl std::fmt::Debug for ExecProcess {
651 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 f.debug_struct("ExecProcess")
653 .field("exec_request_id", &self.exec_request_id)
654 .field("sailbox_id", &self.shared.params.sailbox_id)
655 .finish_non_exhaustive()
656 }
657}
658
659impl Drop for ExecProcess {
660 fn drop(&mut self) {
661 // Honor the documented contract: a handle dropped without an explicit
662 // close() (e.g. by Python GC) still stops the pump and releases the
663 // stream, instead of leaving the gRPC stream running until the command
664 // finishes on its own.
665 self.close();
666 }
667}
668
669impl ExecProcess {
670 /// Submit the exec and start pumping output. Blocks until the server sends
671 /// the `Started` frame (the launch is durably settled).
672 ///
673 /// # Runtime
674 ///
675 /// Spawns the background output pump on the calling task's tokio runtime, so
676 /// call it from within one. The reconnect dials co-locate on that runtime.
677 #[doc(hidden)]
678 pub async fn start(
679 worker: Arc<WorkerProxy>,
680 params: ExecParams,
681 ) -> Result<ExecProcess, SailError> {
682 Self::start_with_initial_retry_timeout(worker, params, None).await
683 }
684
685 /// Starts an exec with an optional retry-budget override for only the
686 /// initial submit. The process retains `params.retry_timeout` for later
687 /// stream reconnects, waits, and stdin writes.
688 pub(crate) async fn start_with_initial_retry_timeout(
689 worker: Arc<WorkerProxy>,
690 mut params: ExecParams,
691 initial_retry_timeout: Option<f64>,
692 ) -> Result<ExecProcess, SailError> {
693 params.ensure_idempotency_key();
694 // Resolve the pty terminal-env whitelist once at launch (caller-supplied
695 // keys win); params is reused verbatim on every reconnect.
696 if params.pty {
697 for (key, value) in pty_forward_env() {
698 params.env.entry(key).or_insert(value);
699 }
700 }
701 let (exec_request_id, stream) = submit(
702 &worker,
703 ¶ms,
704 /* stdout_resume_seq */ 0,
705 /* stderr_resume_seq */ 0,
706 initial_retry_timeout.unwrap_or(params.retry_timeout),
707 )
708 .await?;
709 let shared = Arc::new(ExecShared {
710 worker,
711 params,
712 clipboard_update: Mutex::new(None),
713 clipboard_notify: Notify::new(),
714 state: Mutex::new(State::default()),
715 forward_events: Mutex::new(VecDeque::new()),
716 forward_notify: Notify::new(),
717 cond: Condvar::new(),
718 data_notify: Notify::new(),
719 exit: Mutex::new(None),
720 high_seq: Mutex::new((0, 0)),
721 stdin: AsyncMutex::new(StdinState::default()),
722 ended: AtomicBool::new(false),
723 ended_notify: Notify::new(),
724 closing: AtomicBool::new(false),
725 close_notify: Notify::new(),
726 });
727 let pump_shared = shared.clone();
728 tokio::spawn(async move { pump(pump_shared, stream).await });
729 Ok(ExecProcess {
730 shared,
731 exec_request_id,
732 })
733 }
734
735 /// The durable server-assigned id for this exec, taken from the `Started`
736 /// frame. Identifies the command for wait, cancel, resize, and stdin RPCs.
737 pub fn exec_request_id(&self) -> &str {
738 &self.exec_request_id
739 }
740
741 /// The Sailbox this exec runs on.
742 pub fn sailbox_id(&self) -> &str {
743 &self.shared.params.sailbox_id
744 }
745
746 /// The idempotency key this exec launched with: the caller's, or the one
747 /// minted in `start` when none was supplied.
748 pub fn idempotency_key(&self) -> &str {
749 &self.shared.params.idempotency_key
750 }
751
752 /// Whether this session forwards the clipboard: the interactive bridge runs
753 /// the paste/drag-and-drop and clipboard-mirror machinery only when set.
754 #[doc(hidden)]
755 pub fn forward_clipboard(&self) -> bool {
756 self.shared.params.forward_clipboard
757 }
758
759 /// Forward a local port to a guest-local port over this session. The
760 /// listener binds on `127.0.0.1`; passing `local_port` 0 lets the OS pick a
761 /// free port (read back from the returned handle). Dropping the handle stops
762 /// the forward.
763 pub async fn forward_port(
764 &self,
765 local_port: u16,
766 remote_port: u16,
767 ) -> Result<crate::forward::PortForward, SailError> {
768 crate::forward::forward_port(
769 Arc::clone(&self.shared.worker),
770 self.shared.params.exec_endpoint.clone(),
771 self.shared.params.sailbox_id.clone(),
772 local_port,
773 remote_port,
774 )
775 .await
776 }
777
778 /// Await the next local-forwarding event (e.g. a browser open) for an
779 /// interactive session. Returns `None` once the stream has ended and no
780 /// queued events remain. The shell driver consumes these to act on the
781 /// user's machine.
782 pub async fn next_forward_event(&self) -> Option<ForwardEvent> {
783 loop {
784 if let Some(event) = lock(&self.shared.forward_events).pop_front() {
785 return Some(event);
786 }
787 if self.shared.ended.load(Ordering::SeqCst) {
788 return None;
789 }
790 // Arm both wakers, then re-check so an event or end that landed
791 // between the drain above and here is not missed. `notified()`
792 // snapshots the notify generation at creation, so a `notify_waiters`
793 // that fires before the first poll still wakes the awaited future.
794 let on_event = self.shared.forward_notify.notified();
795 let on_end = self.shared.ended_notify.notified();
796 if !lock(&self.shared.forward_events).is_empty()
797 || self.shared.ended.load(Ordering::SeqCst)
798 {
799 continue;
800 }
801 tokio::select! {
802 () = on_event => {}
803 () = on_end => {}
804 }
805 }
806 }
807
808 /// Create a reader over a live output stream. A fresh reader replays the
809 /// retained tail from the start, then follows live.
810 pub fn reader(&self, which: OutputStream) -> StreamReader {
811 StreamReader {
812 shared: self.shared.clone(),
813 which,
814 cursor: 0,
815 dropped: false,
816 reset: false,
817 seen_front_clips: 0,
818 seen_resets: 0,
819 }
820 }
821
822 /// Create an async reader over a live output stream (the awaiting twin of
823 /// [`reader`](Self::reader)).
824 pub fn reader_async(&self, which: OutputStream) -> AsyncStreamReader {
825 AsyncStreamReader {
826 shared: self.shared.clone(),
827 which,
828 cursor: 0,
829 }
830 }
831
832 /// The locally buffered raw bytes of one stream (the byte-typed twin of the
833 /// lossily decoded [`ExecResult`] fields): exactly what the readers have
834 /// been fed, capped drop-oldest. A byte-count reconciliation against what a
835 /// consumer already printed must use this, not the decoded strings, whose
836 /// lengths diverge from the raw stream on invalid UTF-8.
837 #[doc(hidden)]
838 pub fn buffered_output(&self, which: OutputStream) -> Vec<u8> {
839 lock(&self.shared.state).ring(which).tail()
840 }
841
842 /// Non-blocking exit check, like [`std::process::Child::try_wait`]:
843 /// returns the exit code if the Exit frame arrived on the stream, mapping
844 /// a not-a-real-result terminal status to its error. `None` means the
845 /// result is not known on the stream yet. `wait` is authoritative.
846 pub fn try_wait(&self) -> Option<Result<i32, SailError>> {
847 let exit = lock(&self.shared.exit);
848 exit.as_ref()
849 .map(|exit| match terminal_status_error(exit.status) {
850 Some(err) => Err(err),
851 None => Ok(exit.exit_code),
852 })
853 }
854
855 /// Stop the pump and release the stream without touching the remote command.
856 pub fn close(&self) {
857 self.shared.closing.store(true, Ordering::SeqCst);
858 // notify_one stores a permit if the pump is between its `closing` check
859 // and registering on close_notify, so a close racing the pump is not
860 // missed (notify_waiters wakes only already-registered waiters). The
861 // pump is the sole waiter, so one permit suffices.
862 self.shared.close_notify.notify_one();
863 }
864
865 /// Block up to `timeout` for the output stream to end; returns whether it
866 /// has. Lets a synchronous caller stay responsive to its own signals and
867 /// stop conditions between ticks before committing to the blocking
868 /// [`wait`](Self::wait) resolve.
869 #[doc(hidden)]
870 pub async fn wait_stream_ended(&self, timeout: Duration) -> bool {
871 let _ = tokio::time::timeout(timeout, self.await_ended()).await;
872 self.shared.ended.load(Ordering::SeqCst)
873 }
874
875 /// Resolve once the output stream has ended (the pump set `ended`).
876 async fn await_ended(&self) {
877 loop {
878 let notified = self.shared.ended_notify.notified();
879 if self.shared.ended.load(Ordering::SeqCst) {
880 return;
881 }
882 notified.await;
883 }
884 }
885
886 /// Wait for the command to finish and return its buffered result.
887 ///
888 /// A clean exit resolves from the locally buffered output; a stream that
889 /// ended without one (or whose tail is truncated or short) reattaches to
890 /// the guest session for the authoritative result, retrying a not-ready
891 /// box for the configured `retry_timeout` budget.
892 pub async fn wait(&self) -> Result<ExecResult, SailError> {
893 self.await_ended().await;
894 let exit = lock(&self.shared.exit).clone();
895 let (high_out, high_err) = *lock(&self.shared.high_seq);
896 let (out_dropped, err_dropped) = {
897 let state = lock(&self.shared.state);
898 (state.stdout.dropped, state.stderr.dropped)
899 };
900
901 // The server fallback only recovers a MISSING ENDING: a stream with no
902 // Exit, or one that fell short of the exit's high-water seq (a migration
903 // replay that did not deliver the tail). It does NOT help a stream that
904 // is complete but merely truncated at the front — the server's persisted
905 // tail is smaller than the local ring, so falling back there returns a
906 // worse result and burns a WaitSailboxExec RPC. Truncation is reported
907 // honestly on the local result instead.
908 let incomplete = exit
909 .as_ref()
910 .is_some_and(|exit| exit.stdout_seq > high_out || exit.stderr_seq > high_err);
911 // Whether the buffered `stdout`/`stderr` fields dropped their oldest
912 // bytes — the guest ring overflowed, or the local ring evicted output
913 // the reader may already have consumed. Complete streams still report
914 // it so a caller knows the convenience field is a tail, not the whole.
915 let stdout_truncated_flag = exit
916 .as_ref()
917 .is_some_and(|exit| exit.stdout_truncated || out_dropped);
918 let stderr_truncated_flag = exit
919 .as_ref()
920 .is_some_and(|exit| exit.stderr_truncated || err_dropped);
921
922 // Whether the live stream reached each stream's final chunk. When true,
923 // a live consumer already saw the whole stream (the buffered tail below
924 // can only repeat its ending); when false, the buffered copy is the only
925 // way to recover the missing tail.
926 let stdout_complete = exit
927 .as_ref()
928 .is_some_and(|exit| exit.stdout_seq <= high_out);
929 let stderr_complete = exit
930 .as_ref()
931 .is_some_and(|exit| exit.stderr_seq <= high_err);
932
933 // Total bytes the command produced on each stream (0 when no exit was
934 // observed on the stream, or the guest is too old to report it). A caller
935 // subtracts what it saw to learn how much truncation dropped.
936 let stdout_total_bytes = exit.as_ref().map_or(0, |exit| exit.stdout_total_bytes);
937 let stderr_total_bytes = exit.as_ref().map_or(0, |exit| exit.stderr_total_bytes);
938
939 if exit.is_none() || incomplete {
940 let outcome = self
941 .shared
942 .worker
943 .wait_exec(
944 &self.shared.params.exec_endpoint,
945 &self.shared.params.sailbox_id,
946 &self.exec_request_id,
947 self.shared.params.retry_timeout,
948 )
949 .await?;
950 // Record the polled terminal outcome (when the stream carried no
951 // Exit) so try_wait()/exit_code agree with this wait().
952 {
953 let mut exit_slot = lock(&self.shared.exit);
954 if exit_slot.is_none() {
955 *exit_slot = Some(ExitInfo {
956 status: outcome.status,
957 exit_code: outcome.exit_code,
958 timed_out: outcome.timed_out,
959 stdout_truncated: outcome.stdout_truncated,
960 stderr_truncated: outcome.stderr_truncated,
961 error_message: String::new(),
962 stdout_seq: 0,
963 stderr_seq: 0,
964 // WaitSailboxExec carries no byte totals; 0 = unknown.
965 stdout_total_bytes: 0,
966 stderr_total_bytes: 0,
967 });
968 }
969 }
970 // A real terminal Exit was already witnessed on the live stream, so
971 // a later host-lost status is stale: the host can be lost between the
972 // command finishing and the persisted row being read. The witnessed
973 // completion is authoritative, so return it from the local rings
974 // rather than raising host-lost.
975 if let Some(witnessed) = exit.as_ref() {
976 if outcome.status == pb::SailboxExecStatus::WorkerLost as i32 {
977 let state = lock(&self.shared.state);
978 let mut stderr = lossy_tail(&state.stderr);
979 if witnessed.status == pb::SailboxExecStatus::Failed as i32
980 && !witnessed.error_message.is_empty()
981 {
982 stderr = witnessed.error_message.clone();
983 }
984 return Ok(ExecResult {
985 stdout: lossy_tail(&state.stdout),
986 stderr,
987 exit_code: witnessed.exit_code,
988 timed_out: witnessed.timed_out,
989 stdout_truncated: witnessed.stdout_truncated
990 || out_dropped
991 || witnessed.stdout_seq > high_out,
992 stderr_truncated: witnessed.stderr_truncated
993 || err_dropped
994 || witnessed.stderr_seq > high_err,
995 stdout_complete,
996 stderr_complete,
997 stdout_total_bytes,
998 stderr_total_bytes,
999 });
1000 }
1001 }
1002 if let Some(err) = terminal_status_error(outcome.status) {
1003 return Err(err);
1004 }
1005 return Ok(ExecResult {
1006 stdout: outcome.stdout,
1007 stderr: outcome.stderr,
1008 exit_code: outcome.exit_code,
1009 timed_out: outcome.timed_out,
1010 stdout_truncated: outcome.stdout_truncated,
1011 stderr_truncated: outcome.stderr_truncated,
1012 stdout_complete,
1013 stderr_complete,
1014 stdout_total_bytes,
1015 stderr_total_bytes,
1016 });
1017 }
1018
1019 let exit = exit.expect("exit present on the clean path");
1020 if let Some(err) = terminal_status_error(exit.status) {
1021 return Err(err);
1022 }
1023 let state = lock(&self.shared.state);
1024 let mut stderr = lossy_tail(&state.stderr);
1025 if exit.status == pb::SailboxExecStatus::Failed as i32 && !exit.error_message.is_empty() {
1026 // A failed row persists its failure text as stderr; mirror the poll path.
1027 stderr = exit.error_message.clone();
1028 }
1029 Ok(ExecResult {
1030 stdout: lossy_tail(&state.stdout),
1031 stderr,
1032 exit_code: exit.exit_code,
1033 timed_out: exit.timed_out,
1034 stdout_truncated: stdout_truncated_flag,
1035 stderr_truncated: stderr_truncated_flag,
1036 stdout_complete,
1037 stderr_complete,
1038 stdout_total_bytes,
1039 stderr_total_bytes,
1040 })
1041 }
1042
1043 /// Signal the command: [`CancelSignal::Interrupt`] (SIGINT) or
1044 /// [`CancelSignal::Kill`] (SIGKILL).
1045 pub async fn cancel(&self, signal: CancelSignal, retry: RetryBudget) -> Result<(), SailError> {
1046 self.shared
1047 .worker
1048 .cancel_exec(
1049 &self.shared.params.exec_endpoint,
1050 &self.shared.params.sailbox_id,
1051 &self.exec_request_id,
1052 signal.is_force(),
1053 retry.as_secs_f64(),
1054 )
1055 .await
1056 }
1057
1058 /// Set the pty window for a `pty` exec. Advisory and best-effort: an
1059 /// unknown, finished, or not-yet-placed exec is a server no-op, and a
1060 /// transient transport error is swallowed (the next resize resends).
1061 pub async fn resize(&self, cols: u32, rows: u32) {
1062 let message = pb::ResizeSailboxExecRequest {
1063 sailbox_id: self.shared.params.sailbox_id.clone(),
1064 exec_request_id: self.exec_request_id.clone(),
1065 cols,
1066 rows,
1067 };
1068 let Ok(request) =
1069 self.shared
1070 .worker
1071 .request_for(message, &[], Some(Duration::from_secs(5)))
1072 else {
1073 return;
1074 };
1075 if let Ok(mut client) = self
1076 .shared
1077 .worker
1078 .client_for(&self.shared.params.exec_endpoint)
1079 {
1080 let _ = client.resize_sailbox_exec(request).await;
1081 }
1082 }
1083
1084 /// Ask a `pty` exec to re-emit its current screen as a Snapshot on the live
1085 /// stream. A client whose local buffer dropped output (it fell behind a
1086 /// fast producer) calls this to repaint instead of rendering a torn tail;
1087 /// the command keeps running detached. Advisory and best-effort like
1088 /// [`resize`](Self::resize): an unknown, finished, or non-pty exec is a
1089 /// server no-op, and a transient error is swallowed (the client re-requests
1090 /// if it is still behind).
1091 pub async fn resync(&self) {
1092 let message = pb::ResyncSailboxExecRequest {
1093 sailbox_id: self.shared.params.sailbox_id.clone(),
1094 exec_request_id: self.exec_request_id.clone(),
1095 };
1096 let Ok(request) =
1097 self.shared
1098 .worker
1099 .request_for(message, &[], Some(Duration::from_secs(5)))
1100 else {
1101 return;
1102 };
1103 if let Ok(mut client) = self
1104 .shared
1105 .worker
1106 .client_for(&self.shared.params.exec_endpoint)
1107 {
1108 let _ = client.resync_sailbox_exec(request).await;
1109 }
1110 }
1111
1112 /// Place content on the guest clipboard, so a forwarded local paste
1113 /// behaves as if it were copied inside the guest. Single attempt, no
1114 /// retries: the interactive bridge falls back to uploading the content as
1115 /// a file on any failure, and an `Unimplemented` error means this guest
1116 /// has no clipboard at all (its image ships none, or it predates this
1117 /// RPC) so the caller should stop asking.
1118 #[doc(hidden)]
1119 pub async fn set_clipboard(&self, mime: &str, data: &[u8]) -> Result<(), SailError> {
1120 let message = pb::SetSailboxClipboardRequest {
1121 sailbox_id: self.shared.params.sailbox_id.clone(),
1122 mime: mime.to_string(),
1123 data: data.to_vec(),
1124 };
1125 let request =
1126 self.shared
1127 .worker
1128 .request_for(message, &[], Some(Duration::from_secs(10)))?;
1129 let mut client = self
1130 .shared
1131 .worker
1132 .client_for(&self.shared.params.exec_endpoint)?;
1133 client
1134 .set_sailbox_clipboard(request)
1135 .await
1136 .map(|_| ())
1137 .map_err(|status| SailError::from_exec_status(&status))
1138 }
1139
1140 /// Wait for the next guest-clipboard update (mime, bytes), or `None` once
1141 /// the output stream has ended. Updates coalesce: only the newest unread
1142 /// content is returned, since the clipboard holds one thing.
1143 #[doc(hidden)]
1144 pub async fn next_clipboard_update(&self) -> Option<(String, Vec<u8>)> {
1145 loop {
1146 // Arm the wakeup before inspecting state, like
1147 // AsyncStreamReader::next: a notify_waiters that lands between
1148 // the checks and the await must wake the armed future, not be
1149 // lost while the consumer parks past the end of the stream.
1150 let notified = self.shared.clipboard_notify.notified();
1151 tokio::pin!(notified);
1152 notified.as_mut().enable();
1153 if let Some(update) = lock(&self.shared.clipboard_update).take() {
1154 return Some(update);
1155 }
1156 if self.shared.ended.load(Ordering::SeqCst) {
1157 // A final frame can land between the take above and `ended`
1158 // flipping; the stream's last update must still be delivered.
1159 return lock(&self.shared.clipboard_update).take();
1160 }
1161 notified.await;
1162 }
1163 }
1164
1165 /// Delete guest files, for the interactive bridge's cancel rollback: a
1166 /// short `rm -f` exec on the same box (argv is executed directly, no
1167 /// shell, so the paths need no quoting). Errors when the exec could not
1168 /// run or reported failure.
1169 #[doc(hidden)]
1170 pub async fn remove_guest_files(&self, paths: &[String]) -> Result<(), SailError> {
1171 let mut argv = vec!["rm".to_string(), "-f".to_string()];
1172 argv.extend_from_slice(paths);
1173 let params = ExecParams {
1174 sailbox_id: self.shared.params.sailbox_id.clone(),
1175 exec_endpoint: self.shared.params.exec_endpoint.clone(),
1176 argv,
1177 timeout_seconds: 60,
1178 idempotency_key: String::new(),
1179 open_stdin: false,
1180 pty: false,
1181 term: String::new(),
1182 cols: 0,
1183 rows: 0,
1184 env: std::collections::HashMap::default(),
1185 retry_timeout: 10.0,
1186 extra_metadata: Vec::new(),
1187 forward_ports: false,
1188 forward_browser: false,
1189 forward_clipboard: false,
1190 };
1191 let proc = ExecProcess::start(Arc::clone(&self.shared.worker), params).await?;
1192 let result = proc.wait().await?;
1193 if result.exit_code != 0 {
1194 return Err(SailError::Internal {
1195 message: format!("rm exited with {}", result.exit_code),
1196 });
1197 }
1198 Ok(())
1199 }
1200
1201 /// Open a streaming write to a guest file over this exec's endpoint, for
1202 /// the interactive bridge's paste/drop uploads. Parent directories are
1203 /// created; only `finish` commits.
1204 #[doc(hidden)]
1205 pub fn guest_file_writer(&self, path: &str) -> crate::worker::FileWriter {
1206 self.shared.worker.write_file(
1207 &self.shared.params.exec_endpoint,
1208 &self.shared.params.sailbox_id,
1209 path,
1210 /* create_parents */ true,
1211 /* mode */ None,
1212 )
1213 }
1214
1215 /// Write to the command's stdin. Chunked with absolute offsets; an uncertain
1216 /// mid-flight failure poisons the writer (a stale-offset resume could
1217 /// silently drop bytes). Blocks (with backoff) while the guest buffer is full.
1218 pub async fn write_stdin(&self, data: &[u8]) -> Result<(), SailError> {
1219 let mut stdin = self.shared.stdin.lock().await;
1220 if stdin.eof_sent {
1221 return Err(SailError::BrokenPipe {
1222 message: "stdin is closed".to_string(),
1223 });
1224 }
1225 if stdin.broken {
1226 return Err(SailError::BrokenPipe {
1227 message: "an earlier stdin write failed".to_string(),
1228 });
1229 }
1230 if stdin.write_in_flight {
1231 // The previous write held the lock across its send and never cleared
1232 // this, so its future was cancelled mid-flight: bytes may have landed
1233 // and the offset is uncertain. Poison rather than resume.
1234 stdin.broken = true;
1235 return Err(SailError::BrokenPipe {
1236 message: "an earlier stdin write was interrupted".to_string(),
1237 });
1238 }
1239 if data.is_empty() {
1240 return Ok(());
1241 }
1242 stdin.write_in_flight = true;
1243 let result = self.send_stdin(&mut stdin, data, /* eof */ false).await;
1244 // Reached only if the send was not cancelled. A clean return clears the
1245 // latch; an uncertain failure already set `broken` inside send_stdin.
1246 stdin.write_in_flight = false;
1247 result
1248 }
1249
1250 /// Close the command's stdin (send EOF).
1251 pub async fn close_stdin(&self) -> Result<(), SailError> {
1252 let mut stdin = self.shared.stdin.lock().await;
1253 if stdin.eof_sent {
1254 return Ok(());
1255 }
1256 if stdin.broken || stdin.write_in_flight {
1257 // An earlier write failed or was cancelled mid-flight, so the stream
1258 // is undeliverable at a known offset.
1259 stdin.eof_sent = true;
1260 return Ok(());
1261 }
1262 // The EOF write carries no data and is idempotent, so a transient
1263 // failure can't corrupt the offset: send directly without poisoning, and
1264 // leave eof_sent false until it lands so a later eof retries it.
1265 match self.send_stdin(&mut stdin, &[], /* eof */ true).await {
1266 Ok(()) => {
1267 stdin.eof_sent = true;
1268 Ok(())
1269 }
1270 // The command already exited / closed stdin: nothing to deliver.
1271 Err(SailError::BrokenPipe { .. }) => {
1272 stdin.eof_sent = true;
1273 Ok(())
1274 }
1275 Err(err) => Err(err),
1276 }
1277 }
1278
1279 /// Drive the chunked WriteSailboxExecStdin loop. `eof` latches only when the
1280 /// final chunk is fully accepted. Poisons `stdin.broken` on an uncertain
1281 /// mid-flight failure (anything but a clean broken-pipe).
1282 async fn send_stdin(
1283 &self,
1284 stdin: &mut StdinState,
1285 payload: &[u8],
1286 eof: bool,
1287 ) -> Result<(), SailError> {
1288 let endpoint = &self.shared.params.exec_endpoint;
1289 let sailbox_id = &self.shared.params.sailbox_id;
1290 let retry_timeout = self.shared.params.retry_timeout;
1291 let mut deadline = retry_deadline(retry_timeout);
1292 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
1293 let mut sent = 0usize;
1294 loop {
1295 let end = (sent + STDIN_WRITE_CHUNK_BYTES).min(payload.len());
1296 let chunk = &payload[sent..end];
1297 let last = end >= payload.len();
1298 let message = pb::WriteSailboxExecStdinRequest {
1299 sailbox_id: sailbox_id.clone(),
1300 exec_request_id: self.exec_request_id.clone(),
1301 offset: stdin.offset,
1302 data: chunk.to_vec(),
1303 eof: eof && last,
1304 };
1305 // Bound each attempt so a stalled connection (one that never
1306 // returns a status) times out and the retry/poison logic below
1307 // runs, instead of one await blocking the whole budget.
1308 let request = match self.shared.worker.request_for(
1309 message,
1310 &[],
1311 Some(rpc_attempt_timeout(deadline)),
1312 ) {
1313 Ok(request) => request,
1314 Err(err) => return Err(err),
1315 };
1316 let result = match self.shared.worker.client_for(endpoint) {
1317 Ok(mut client) => client.write_sailbox_exec_stdin(request).await,
1318 Err(err) => return Err(err),
1319 };
1320 match result {
1321 Ok(resp) => {
1322 let accepted_through = resp.into_inner().accepted_through;
1323 // Clamp to the chunk we actually sent: a server that
1324 // over-reports accepted_through must not push `sent` past the
1325 // payload (a slice panic) or advance the idempotent offset
1326 // beyond delivered bytes.
1327 let accepted =
1328 ((accepted_through - stdin.offset).max(0) as usize).min(chunk.len());
1329 stdin.offset += accepted as i64;
1330 sent += accepted;
1331 if sent >= payload.len() && (!eof || (last && accepted == chunk.len())) {
1332 return Ok(());
1333 }
1334 // A success proves the transport healthy: restart the budget.
1335 deadline = retry_deadline(retry_timeout);
1336 if accepted > 0 {
1337 delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
1338 } else {
1339 // Guest buffer full: block like a pipe write, no deadline.
1340 delay = sleep_no_deadline(delay).await;
1341 }
1342 }
1343 Err(status) => {
1344 if matches!(status.code(), Code::NotFound | Code::FailedPrecondition) {
1345 // The exec is over or closed its stdin: a dead pipe.
1346 return Err(SailError::BrokenPipe {
1347 message: status.message().to_string(),
1348 });
1349 }
1350 if is_exec_not_ready(&status) {
1351 // Row open but guest not reachable yet (wake/migration):
1352 // wait it out against the exec's lifetime, no deadline,
1353 // resetting the transport budget on each "; retry".
1354 delay = sleep_no_deadline(delay).await;
1355 deadline = retry_deadline(retry_timeout);
1356 continue;
1357 }
1358 if !should_retry_transient_exec_rpc(&status, deadline) {
1359 // Uncertain whether bytes landed: poison so a stale-offset
1360 // resume can't silently drop overlapping bytes.
1361 stdin.broken = true;
1362 return Err(SailError::from_exec_status(&status));
1363 }
1364 tracing::warn!(code = ?status.code(), "retrying exec stdin write");
1365 if should_invalidate_channel(&status) {
1366 self.shared.worker.channels().invalidate(endpoint);
1367 }
1368 delay = sleep_before_retry(delay, deadline).await;
1369 }
1370 }
1371 }
1372 }
1373}
1374
1375/// A cursor over one live output stream. [`StreamReader::next`] blocks up to a
1376/// timeout for the next chunk.
1377pub struct StreamReader {
1378 shared: Arc<ExecShared>,
1379 which: OutputStream,
1380 cursor: usize,
1381 dropped: bool,
1382 /// Set when the ring was reset to a repaint since the last read. Surfaced
1383 /// via took_reset so an interactive consumer drops its stale local backlog
1384 /// before rendering the repaint, rather than leaving it stuck behind bytes
1385 /// the terminal will never finish draining.
1386 reset: bool,
1387 /// The ring's `front_clips` value this reader has already accounted for, so
1388 /// an in-place clip of the piece it is parked on registers as a drop once.
1389 seen_front_clips: u64,
1390 /// The ring's `reset_to` count this reader has accounted for. A repaint
1391 /// advances `first_idx` past the cursor like an eviction, but reading the
1392 /// repaint heals the screen, so it must not register as a fall-behind drop.
1393 seen_resets: u64,
1394}
1395
1396impl StreamReader {
1397 /// Block up to `timeout` for the next step: the next retained chunk, `Eof`
1398 /// once the stream is closed and fully drained, or `Pending` if nothing new
1399 /// arrived in time.
1400 ///
1401 /// This parks the calling thread. From async code use
1402 /// [`ExecProcess::reader_async`] instead, which awaits without blocking a
1403 /// runtime worker.
1404 pub fn next(&mut self, timeout: Duration) -> ReadStep {
1405 let mut state = lock(&self.shared.state);
1406 loop {
1407 let ring = state.ring(self.which);
1408 // Reconcile with the ring head. A reset_to repaint replaced the ring
1409 // and supersedes whatever was skipped, so it is a heal surfaced via
1410 // took_reset, not a drop; it also supersedes a drop an earlier read
1411 // had already latched. Adopt the ring's own dropped flag rather than
1412 // clearing unconditionally: reset_to clears it, so it is false for a
1413 // clean heal, but re-latches if the repaint itself was then evicted
1414 // by later output before this read. In that case the reader is about
1415 // to hand back a torn post-repaint suffix, so it must still report a
1416 // drop for the consumer to resync. Otherwise a cursor below the head
1417 // means the ring evicted chunks this reader had not consumed, and a
1418 // front-clip trims the piece at first_idx in place without advancing
1419 // it; both latch a drop so an interactive consumer can repaint
1420 // (took_drop).
1421 if ring.resets != self.seen_resets {
1422 self.reset = true;
1423 self.dropped = ring.dropped;
1424 if self.cursor < ring.first_idx {
1425 self.cursor = ring.first_idx;
1426 }
1427 } else if self.cursor < ring.first_idx {
1428 self.cursor = ring.first_idx;
1429 self.dropped = true;
1430 } else if self.cursor == ring.first_idx && ring.front_clips != self.seen_front_clips {
1431 self.dropped = true;
1432 }
1433 self.seen_front_clips = ring.front_clips;
1434 self.seen_resets = ring.resets;
1435 let available = ring.first_idx + ring.pieces.len();
1436 if self.cursor < available {
1437 let piece = ring.pieces[self.cursor - ring.first_idx].clone();
1438 self.cursor += 1;
1439 return ReadStep::Chunk(piece);
1440 }
1441 if state.ended {
1442 return ReadStep::Eof;
1443 }
1444 // Recover from poison like the `lock` helper: the rings hold plain
1445 // data, so a panicked writer leaves nothing half-updated worth
1446 // propagating.
1447 let (next_state, timed_out) = self
1448 .shared
1449 .cond
1450 .wait_timeout(state, timeout)
1451 .unwrap_or_else(std::sync::PoisonError::into_inner);
1452 state = next_state;
1453 if timed_out.timed_out() {
1454 return ReadStep::Pending;
1455 }
1456 }
1457 }
1458
1459 /// The next retained chunk if one is already buffered, without blocking.
1460 /// `None` means the ring is momentarily drained (not that the stream
1461 /// ended); callers batch-draining an interactive stream use it to flush
1462 /// several chunks in one write.
1463 pub fn try_next(&mut self) -> Option<Vec<u8>> {
1464 let state = lock(&self.shared.state);
1465 let ring = state.ring(self.which);
1466 if ring.resets != self.seen_resets {
1467 self.reset = true;
1468 // Adopt the ring's dropped flag: a clean repaint clears it, but a
1469 // repaint evicted by later output before this read leaves it set, so
1470 // the torn suffix still reports a drop. See next() for the full note.
1471 self.dropped = ring.dropped;
1472 if self.cursor < ring.first_idx {
1473 self.cursor = ring.first_idx;
1474 }
1475 } else if self.cursor < ring.first_idx {
1476 self.cursor = ring.first_idx;
1477 self.dropped = true;
1478 } else if self.cursor == ring.first_idx && ring.front_clips != self.seen_front_clips {
1479 self.dropped = true;
1480 }
1481 self.seen_front_clips = ring.front_clips;
1482 self.seen_resets = ring.resets;
1483 if self.cursor < ring.first_idx + ring.pieces.len() {
1484 let piece = ring.pieces[self.cursor - ring.first_idx].clone();
1485 self.cursor += 1;
1486 Some(piece)
1487 } else {
1488 None
1489 }
1490 }
1491
1492 /// Whether the ring evicted output this reader had not yet consumed since
1493 /// the last call, clearing the flag. An interactive consumer uses it to
1494 /// trigger a screen repaint ([`ExecProcess::resync`]) after falling behind.
1495 pub fn took_drop(&mut self) -> bool {
1496 std::mem::take(&mut self.dropped)
1497 }
1498
1499 /// Whether the ring was reset to a repaint (a Snapshot superseded the
1500 /// stream) since the last call, clearing the flag. An interactive consumer
1501 /// drops any stale terminal-local backlog on this so the repaint it is about
1502 /// to read renders at once instead of stuck behind bytes the terminal will
1503 /// never finish draining.
1504 pub fn took_reset(&mut self) -> bool {
1505 std::mem::take(&mut self.reset)
1506 }
1507}
1508
1509impl std::fmt::Debug for StreamReader {
1510 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1511 f.debug_struct("StreamReader")
1512 .field("which", &self.which)
1513 .field("cursor", &self.cursor)
1514 .field("dropped", &self.dropped)
1515 .finish_non_exhaustive()
1516 }
1517}
1518
1519/// An async cursor over one live output stream, the awaiting counterpart of
1520/// [`StreamReader`]. [`AsyncStreamReader::next`] yields the next chunk without
1521/// parking a runtime thread, so many streams can be read on one event loop.
1522pub struct AsyncStreamReader {
1523 shared: Arc<ExecShared>,
1524 which: OutputStream,
1525 cursor: usize,
1526}
1527
1528impl AsyncStreamReader {
1529 /// The next retained chunk of raw bytes, or `None` once the stream is
1530 /// closed and fully drained. A reader that falls more than the buffer cap
1531 /// behind skips the dropped head rather than stalling.
1532 pub async fn next(&mut self) -> Option<Vec<u8>> {
1533 loop {
1534 // Arm the wakeup before inspecting the ring: `notify_waiters` only
1535 // wakes already-registered waiters, so enabling first closes the gap
1536 // where an append between the check and the await would be missed.
1537 let notified = self.shared.data_notify.notified();
1538 tokio::pin!(notified);
1539 notified.as_mut().enable();
1540 {
1541 let state = lock(&self.shared.state);
1542 let ring = state.ring(self.which);
1543 if self.cursor < ring.first_idx {
1544 self.cursor = ring.first_idx;
1545 }
1546 let available = ring.first_idx + ring.pieces.len();
1547 if self.cursor < available {
1548 let piece = ring.pieces[self.cursor - ring.first_idx].clone();
1549 self.cursor += 1;
1550 return Some(piece);
1551 }
1552 if state.ended {
1553 return None;
1554 }
1555 }
1556 notified.await;
1557 }
1558 }
1559
1560 /// Consume the reader into a [`futures::Stream`] of raw byte chunks, for
1561 /// `StreamExt` combinators and `select!`:
1562 ///
1563 /// ```no_run
1564 /// # async fn demo(process: sail::ExecProcess) {
1565 /// use futures::StreamExt;
1566 /// let mut stdout = process.reader_async(sail::exec::OutputStream::Stdout).into_stream();
1567 /// while let Some(chunk) = stdout.next().await {
1568 /// print!("{}", String::from_utf8_lossy(&chunk));
1569 /// }
1570 /// # }
1571 /// ```
1572 pub fn into_stream(self) -> futures::stream::BoxStream<'static, Vec<u8>> {
1573 Box::pin(futures::stream::unfold(self, |mut reader| async move {
1574 reader.next().await.map(|chunk| (chunk, reader))
1575 }))
1576 }
1577}
1578
1579impl std::fmt::Debug for AsyncStreamReader {
1580 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1581 f.debug_struct("AsyncStreamReader")
1582 .field("which", &self.which)
1583 .field("cursor", &self.cursor)
1584 .finish_non_exhaustive()
1585 }
1586}
1587
1588/// Sleep one backoff step with no deadline (used when the guest stdin buffer is
1589/// full or the guest is not reachable yet); returns the doubled delay.
1590async fn sleep_no_deadline(delay: f64) -> f64 {
1591 let sleep_for = delay.min(crate::worker::EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
1592 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
1593 (delay * 2.0).min(crate::worker::EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
1594}
1595
1596fn is_exec_not_ready(status: &Status) -> bool {
1597 status.code() == Code::Unavailable && status.message().contains("; retry")
1598}
1599
1600/// Map a host-loss status (no real return code) to its error; `None` for a
1601/// normal exit (succeeded/failed/timed-out, which carry a real return code).
1602fn terminal_status_error(status: i32) -> Option<SailError> {
1603 if status == pb::SailboxExecStatus::WorkerLost as i32 {
1604 return Some(SailError::HostLost {
1605 message: "the machine hosting your sailbox was lost before the command \
1606 finished; run exec again to retry"
1607 .to_string(),
1608 });
1609 }
1610 None
1611}
1612
1613/// Open the exec stream and read the `Started` frame, retrying transient
1614/// failures. Non-zero resume seqs reattach a dropped stream.
1615async fn submit(
1616 worker: &Arc<WorkerProxy>,
1617 params: &ExecParams,
1618 stdout_resume_seq: i64,
1619 stderr_resume_seq: i64,
1620 retry_timeout: f64,
1621) -> Result<(String, Streaming<pb::StreamSailboxExecResponse>), SailError> {
1622 let deadline = retry_deadline(retry_timeout);
1623 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
1624 loop {
1625 let message = pb::StreamSailboxExecRequest {
1626 sailbox_id: params.sailbox_id.clone(),
1627 argv: params.argv.clone(),
1628 timeout_seconds: params.timeout_seconds,
1629 idempotency_key: params.idempotency_key.clone(),
1630 open_stdin: params.open_stdin,
1631 stdout_resume_seq,
1632 stderr_resume_seq,
1633 pty: params.pty,
1634 term_cols: params.cols,
1635 term_rows: params.rows,
1636 term: params.term.clone(),
1637 env: params.env.clone(),
1638 forward_ports: params.forward_ports,
1639 forward_browser: params.forward_browser,
1640 forward_clipboard: params.forward_clipboard,
1641 };
1642 let request =
1643 worker.request_for(message, ¶ms.extra_metadata, /* timeout */ None)?;
1644 let status = match worker
1645 .client_for(¶ms.exec_endpoint)?
1646 .stream_sailbox_exec(request)
1647 .await
1648 {
1649 Ok(resp) => {
1650 let mut stream = resp.into_inner();
1651 match stream.message().await {
1652 Ok(Some(first)) => match first.frame {
1653 Some(pb::stream_sailbox_exec_response::Frame::Started(started)) => {
1654 return Ok((started.exec_request_id, stream));
1655 }
1656 _ => {
1657 return Err(SailError::Execution {
1658 code: crate::error::RpcStatus::Internal,
1659 detail: "exec stream opened with a non-started frame".to_string(),
1660 });
1661 }
1662 },
1663 // On a fresh launch (resume seqs 0,0) a clean end before the
1664 // Started frame is a transport-level teardown, not a server
1665 // verdict — the server deliberately sends Started even for
1666 // lost sessions on that path. Nothing was confirmed and the
1667 // relaunch reuses the idempotency key, so route it through
1668 // the transient-retry gate like any dropped connection. On a
1669 // mid-run reconnect the same clean end IS a verdict (the box
1670 // parked: autoslept, lost, or re-homing) — surface it so the
1671 // caller falls back to wait(), which wakes the box, instead
1672 // of re-submitting against a server that will not act.
1673 Ok(None) if stdout_resume_seq == 0 && stderr_resume_seq == 0 => {
1674 tonic::Status::unavailable(
1675 "exec stream ended before the server confirmed the launch",
1676 )
1677 }
1678 Ok(None) => {
1679 return Err(SailError::Execution {
1680 code: crate::error::RpcStatus::Internal,
1681 detail: "exec stream ended before the server confirmed the launch"
1682 .to_string(),
1683 });
1684 }
1685 Err(status) => status,
1686 }
1687 }
1688 Err(status) => status,
1689 };
1690 if !should_retry_transient_exec_rpc(&status, deadline) {
1691 return Err(SailError::from_exec_status(&status));
1692 }
1693 tracing::warn!(
1694 code = ?status.code(),
1695 stdout_resume_seq,
1696 stderr_resume_seq,
1697 "reconnecting exec stream"
1698 );
1699 if should_invalidate_channel(&status) {
1700 worker.channels().invalidate(¶ms.exec_endpoint);
1701 }
1702 delay = sleep_before_retry(delay, deadline).await;
1703 }
1704}
1705
1706/// Drains exec frames into the rings while tracking the per-stream high-water
1707/// seqs (the resume points sent on reconnect). Split out from the transport
1708/// loop so the resume/replay state machine is testable in-process without a
1709/// gRPC stream: a mid-stream break is just "keep applying frames after a gap".
1710struct Pump {
1711 shared: Arc<ExecShared>,
1712 stdout_seq: i64,
1713 stderr_seq: i64,
1714}
1715
1716impl Pump {
1717 fn new(shared: Arc<ExecShared>) -> Pump {
1718 Pump {
1719 shared,
1720 stdout_seq: 0,
1721 stderr_seq: 0,
1722 }
1723 }
1724
1725 /// Apply one frame. Returns `true` for a terminal Exit frame (stop draining).
1726 /// Chunk seqs only advance the high-water mark, never rewind — a server that
1727 /// replays an already-seen tail after reconnect can't lower the resume point
1728 /// — with one deliberate exception: a pty Snapshot assigns its basis, since
1729 /// the repaint supersedes everything before it.
1730 fn apply_frame(&mut self, frame: pb::StreamSailboxExecResponse) -> bool {
1731 match frame.frame {
1732 Some(pb::stream_sailbox_exec_response::Frame::Chunk(chunk)) => {
1733 let is_stderr = chunk.stream == pb::SailboxExecStream::Stderr as i32;
1734 let which = if is_stderr {
1735 self.stderr_seq = self.stderr_seq.max(chunk.seq);
1736 OutputStream::Stderr
1737 } else {
1738 self.stdout_seq = self.stdout_seq.max(chunk.seq);
1739 OutputStream::Stdout
1740 };
1741 if !chunk.data.is_empty() {
1742 let mut state = lock(&self.shared.state);
1743 match which {
1744 OutputStream::Stdout => state.stdout.append(chunk.data),
1745 OutputStream::Stderr => state.stderr.append(chunk.data),
1746 }
1747 self.shared.cond.notify_all();
1748 self.shared.data_notify.notify_waiters();
1749 }
1750 false
1751 }
1752 Some(pb::stream_sailbox_exec_response::Frame::Snapshot(snap)) => {
1753 // A pty screen resync (reattach or server-side overflow
1754 // recovery): the repaint replaces the retained stream and seq
1755 // accounting continues from the basis, so Exit-completeness
1756 // math stays honest without any snapshot-specific logic in
1757 // wait() or the readers.
1758 self.stdout_seq = snap.stdout_seq_basis;
1759 let mut state = lock(&self.shared.state);
1760 state.stdout.reset_to(snap.repaint);
1761 self.shared.cond.notify_all();
1762 self.shared.data_notify.notify_waiters();
1763 false
1764 }
1765 Some(pb::stream_sailbox_exec_response::Frame::OpenUrl(open)) => {
1766 // A browser-open inside the sandbox. It carries no output, so it
1767 // is queued for the shell driver rather than entering the ring.
1768 // The opt-out is enforced here, in the trusted client: the
1769 // guest runs untrusted code and cannot be relied on to suppress
1770 // the frame. A dropped frame also keeps a plain exec (which
1771 // never forwards) from accruing events no one drains.
1772 if self.shared.params.forward_browser {
1773 let mut events = lock(&self.shared.forward_events);
1774 if events.len() < MAX_PENDING_FORWARD_EVENTS {
1775 events.push_back(ForwardEvent::OpenUrl(open.url));
1776 self.shared.forward_notify.notify_waiters();
1777 }
1778 }
1779 false
1780 }
1781 Some(pb::stream_sailbox_exec_response::Frame::PortSnapshot(snapshot)) => {
1782 // The current set of localhost servers. Off-ring like OpenUrl,
1783 // and gated on the client for the same reason.
1784 if self.shared.params.forward_ports {
1785 let ports = snapshot
1786 .ports
1787 .into_iter()
1788 .filter_map(|port| u16::try_from(port).ok())
1789 .collect();
1790 // Only the newest snapshot matters (each carries the full
1791 // current set), so it replaces any queued one instead of
1792 // competing with OpenUrl events for cap space; the guest
1793 // re-sends only on set changes, so a dropped snapshot
1794 // would leave the forward set stale indefinitely.
1795 let mut events = lock(&self.shared.forward_events);
1796 events.retain(|event| !matches!(event, ForwardEvent::PortSnapshot(_)));
1797 events.push_back(ForwardEvent::PortSnapshot(ports));
1798 self.shared.forward_notify.notify_waiters();
1799 }
1800 false
1801 }
1802 Some(pb::stream_sailbox_exec_response::Frame::ClipboardUpdate(update)) => {
1803 // New guest-clipboard content. Off-ring like the forward events,
1804 // and gated on the client for the same reason: the guest runs
1805 // untrusted code and cannot be relied on to honor the opt-out,
1806 // so a frame that arrives despite the flag is dropped here.
1807 if self.shared.params.forward_clipboard {
1808 *lock(&self.shared.clipboard_update) = Some((update.mime, update.data));
1809 self.shared.clipboard_notify.notify_waiters();
1810 }
1811 false
1812 }
1813 Some(pb::stream_sailbox_exec_response::Frame::Exit(exit)) => {
1814 *lock(&self.shared.exit) = Some(ExitInfo {
1815 status: exit.status,
1816 exit_code: exit.return_code,
1817 timed_out: exit.timed_out,
1818 stdout_truncated: exit.stdout_truncated,
1819 stderr_truncated: exit.stderr_truncated,
1820 error_message: exit.error_message,
1821 stdout_seq: exit.stdout_seq,
1822 stderr_seq: exit.stderr_seq,
1823 stdout_total_bytes: exit.stdout_total_bytes,
1824 stderr_total_bytes: exit.stderr_total_bytes,
1825 });
1826 true
1827 }
1828 _ => false,
1829 }
1830 }
1831
1832 /// Publish the high-water seqs, close the rings, and wake every reader and
1833 /// waiter. Consumes the pump: nothing follows finalize.
1834 fn finalize(self) {
1835 {
1836 let mut state = lock(&self.shared.state);
1837 state.ended = true;
1838 self.shared.cond.notify_all();
1839 self.shared.data_notify.notify_waiters();
1840 }
1841 *lock(&self.shared.high_seq) = (self.stdout_seq, self.stderr_seq);
1842 self.shared.ended.store(true, Ordering::SeqCst);
1843 self.shared.ended_notify.notify_waiters();
1844 // The clipboard consumer waits on its own notify; wake it so it
1845 // observes the end of stream and exits.
1846 self.shared.clipboard_notify.notify_waiters();
1847 }
1848}
1849
1850/// Drain the output stream into the rings, reconnecting on a transient break,
1851/// until the Exit frame or an unrecoverable end. Always finalizes the rings.
1852async fn pump(shared: Arc<ExecShared>, mut stream: Streaming<pb::StreamSailboxExecResponse>) {
1853 let mut state = Pump::new(shared.clone());
1854 loop {
1855 if shared.closing.load(Ordering::SeqCst) {
1856 break;
1857 }
1858 let message = tokio::select! {
1859 biased;
1860 () = shared.close_notify.notified() => break,
1861 message = stream.message() => message,
1862 };
1863 match message {
1864 Ok(Some(frame)) => {
1865 if state.apply_frame(frame) {
1866 break;
1867 }
1868 }
1869 // The stream ended cleanly without an Exit; leave it to wait()'s poll.
1870 Ok(None) => break,
1871 Err(_status) => {
1872 if shared.closing.load(Ordering::SeqCst) {
1873 break;
1874 }
1875 // Reconnect from the last seq we saw, so the guest replays only
1876 // the unseen tail.
1877 match submit(
1878 &shared.worker,
1879 &shared.params,
1880 state.stdout_seq,
1881 state.stderr_seq,
1882 shared.params.retry_timeout,
1883 )
1884 .await
1885 {
1886 Ok((_id, fresh)) => {
1887 stream = fresh;
1888 continue;
1889 }
1890 Err(_) => break,
1891 }
1892 }
1893 }
1894 }
1895 state.finalize();
1896}
1897
1898/// Fuzz entry point: drive an arbitrary byte stream through the incremental
1899/// UTF-8 decoder (split at data-derived boundaries) and the drop-oldest ring,
1900/// asserting the engine's invariants. Any violation panics, which libfuzzer
1901/// flags as a crash. Compiled only under `cfg(test)` or the `fuzzing` feature,
1902/// so it is never part of a production build.
1903#[cfg(any(test, feature = "fuzzing"))]
1904pub fn fuzz_exec_ring(data: &[u8]) {
1905 // Append the input split at data-derived boundaries (cut after every odd
1906 // byte) and as one piece: the retained tail must be identical — the ring is
1907 // chunk-boundary invariant on raw bytes.
1908 let mut chunked = Ring::default();
1909 let mut start = 0;
1910 for (i, byte) in data.iter().enumerate() {
1911 if byte & 1 == 1 {
1912 chunked.append(data[start..=i].to_vec());
1913 start = i + 1;
1914 }
1915 }
1916 if start < data.len() {
1917 chunked.append(data[start..].to_vec());
1918 }
1919 let mut whole = Ring::default();
1920 if !data.is_empty() {
1921 whole.append(data.to_vec());
1922 }
1923 assert_eq!(
1924 chunked.tail(),
1925 whole.tail(),
1926 "ring is not chunk-boundary invariant"
1927 );
1928
1929 // Overrun the cap by a hair so the drop-oldest clip lands inside the
1930 // fuzz-derived front piece: the ring must stay within the cap (byte-exact)
1931 // and its retained bytes must remain a suffix of everything appended.
1932 let mut ring = Ring::default();
1933 ring.append(data.to_vec());
1934 let filler = vec![b'a'; STREAM_BUFFER_CAP_BYTES + 1 - data.len().min(STREAM_BUFFER_CAP_BYTES)];
1935 ring.append(filler.clone());
1936 assert!(
1937 ring.size <= STREAM_BUFFER_CAP_BYTES,
1938 "ring exceeded its cap"
1939 );
1940 let mut full = data.to_vec();
1941 full.extend_from_slice(&filler);
1942 assert!(
1943 full.ends_with(&ring.tail()),
1944 "ring tail is not a suffix of the appended bytes"
1945 );
1946
1947 // A reset supersedes everything retained: prior pieces become unreachable
1948 // (first_idx advanced past them) and the tail is exactly the repaint,
1949 // clipped to the cap.
1950 let before_reset_pieces = ring.first_idx + ring.pieces.len();
1951 ring.reset_to(data.to_vec());
1952 assert!(
1953 ring.first_idx >= before_reset_pieces,
1954 "reset left old pieces reachable"
1955 );
1956 let expected = &data[data.len().saturating_sub(STREAM_BUFFER_CAP_BYTES)..];
1957 assert_eq!(
1958 ring.tail(),
1959 expected,
1960 "reset tail is not the capped repaint"
1961 );
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966 use super::*;
1967
1968 #[test]
1969 fn shell_argv_wraps_cwd_and_background() {
1970 let plain = shell_argv("echo hi", &ExecOptions::default()).unwrap();
1971 assert_eq!(plain, ["/bin/sh", "-lc", "echo hi"]);
1972
1973 let cwd = shell_argv(
1974 "echo hi",
1975 &ExecOptions {
1976 cwd: Some("/app".to_string()),
1977 ..ExecOptions::default()
1978 },
1979 )
1980 .unwrap();
1981 assert_eq!(cwd[2], "cd '/app' && exec /bin/sh -lc 'echo hi'");
1982
1983 let background = shell_argv(
1984 "echo hi",
1985 &ExecOptions {
1986 cwd: Some("/app".to_string()),
1987 background: true,
1988 ..ExecOptions::default()
1989 },
1990 )
1991 .unwrap();
1992 assert_eq!(
1993 background[2],
1994 "nohup /bin/sh -lc 'cd '\\''/app'\\'' && exec /bin/sh -lc '\\''echo hi'\\''' </dev/null >/dev/null 2>&1 &"
1995 );
1996 }
1997
1998 #[test]
1999 fn shell_argv_rejects_invalid_combinations() {
2000 assert!(shell_argv("", &ExecOptions::default()).is_err());
2001 assert!(shell_argv(
2002 "x",
2003 &ExecOptions {
2004 cwd: Some(" ".to_string()),
2005 ..ExecOptions::default()
2006 }
2007 )
2008 .is_err());
2009 for (open_stdin, pty) in [(true, false), (false, true)] {
2010 assert!(shell_argv(
2011 "x",
2012 &ExecOptions {
2013 background: true,
2014 open_stdin,
2015 pty,
2016 ..ExecOptions::default()
2017 }
2018 )
2019 .is_err());
2020 }
2021 }
2022
2023 #[test]
2024 fn fuzz_exec_ring_holds_on_samples() {
2025 // Verifies the fuzz entry point itself: hand-picked inputs covering valid
2026 // multibyte chars, split sequences, and invalid bytes.
2027 for sample in [
2028 &b""[..],
2029 b"hello",
2030 b"\xff\xfe\xfd",
2031 "€ µ é".as_bytes(),
2032 b"ab\xc3\xa9cd",
2033 &[0xC3, 0x28],
2034 ] {
2035 fuzz_exec_ring(sample);
2036 }
2037 }
2038
2039 #[test]
2040 fn ring_drops_oldest_past_cap_and_latches() {
2041 let mut ring = Ring::default();
2042 ring.append(vec![b'a'; STREAM_BUFFER_CAP_BYTES]);
2043 assert!(!ring.dropped);
2044 ring.append(b"bbbb".to_vec());
2045 assert!(ring.dropped);
2046 assert_eq!(ring.size, STREAM_BUFFER_CAP_BYTES);
2047 assert_eq!(ring.tail().len(), STREAM_BUFFER_CAP_BYTES);
2048 assert!(ring.tail().ends_with(b"bbbb"));
2049 }
2050
2051 #[test]
2052 fn ring_clip_is_byte_exact() {
2053 // Overflow of 1 lands inside the leading 2-byte 'é'. The byte ring
2054 // clips at the exact byte — split multibyte sequences are the reader's
2055 // concern (decode at the edge), never the ring's.
2056 let mut ring = Ring::default();
2057 let mut data = "é".as_bytes().to_vec();
2058 data.extend(std::iter::repeat_n(b'a', STREAM_BUFFER_CAP_BYTES - 1));
2059 ring.append(data);
2060 assert_eq!(ring.size, STREAM_BUFFER_CAP_BYTES);
2061 assert!(ring.dropped);
2062 let tail = ring.tail();
2063 assert_eq!(tail.len(), STREAM_BUFFER_CAP_BYTES);
2064 // The clip cut the first byte of 'é'; its continuation byte survives.
2065 assert_eq!(tail[0], "é".as_bytes()[1]);
2066 }
2067
2068 #[test]
2069 fn ring_reset_to_supersedes_retained_pieces() {
2070 let mut ring = Ring::default();
2071 ring.append(b"old output".to_vec());
2072 ring.append(b"more".to_vec());
2073 let reachable_end = ring.first_idx + ring.pieces.len();
2074 ring.reset_to(b"\x1b[2J\x1b[Hrepaint".to_vec());
2075 assert!(ring.first_idx >= reachable_end);
2076 assert_eq!(ring.tail(), b"\x1b[2J\x1b[Hrepaint");
2077 // A catch-up, not a loss: dropped must not latch.
2078 assert!(!ring.dropped);
2079
2080 let mut empty = Ring::default();
2081 empty.append(b"x".to_vec());
2082 empty.reset_to(Vec::new());
2083 assert!(empty.tail().is_empty());
2084 }
2085
2086 /// A drop latched before the snapshot must not survive it: the repaint
2087 /// supersedes the lost bytes, and a stale `dropped` would force `wait()`
2088 /// into the server fallback for a fully healed pty session.
2089 #[test]
2090 fn pty_forward_env_filters_to_the_whitelist() {
2091 let vars = vec![
2092 ("COLORTERM".to_string(), "truecolor".to_string()),
2093 ("LC_ALL".to_string(), "en_US.UTF-8".to_string()),
2094 ("LANG".to_string(), "en_US.UTF-8".to_string()),
2095 ("TERM_PROGRAM".to_string(), "TestTerm".to_string()),
2096 ("PATH".to_string(), "/bin".to_string()),
2097 ("TERM".to_string(), "xterm".to_string()), // rides the term field, not env
2098 ("SECRET_TOKEN".to_string(), "x".to_string()),
2099 ];
2100 let forwarded = pty_forward_env_from(vars.into_iter());
2101 let keys: Vec<&str> = forwarded.iter().map(|(k, _)| k.as_str()).collect();
2102 assert_eq!(keys, ["COLORTERM", "LC_ALL", "LANG", "TERM_PROGRAM"]);
2103 }
2104
2105 #[test]
2106 fn encode_env_rejects_malformed_keys() {
2107 for (key, value) in [
2108 ("", "v"),
2109 ("A=B", "v"),
2110 ("NUL\0KEY", "v"),
2111 ("K", "nul\0value"),
2112 // Non-portable names: leading digit, whitespace, punctuation.
2113 ("1FOO", "v"),
2114 ("FO O", "v"),
2115 ("FOO-BAR", "v"),
2116 ("FOO.BAR", "v"),
2117 ] {
2118 let pairs = vec![(key.to_string(), value.to_string())];
2119 assert!(
2120 encode_env(&pairs).is_err(),
2121 "expected rejection for {key:?}={value:?}"
2122 );
2123 }
2124 // A leading-underscore name and an opaque value (including '=') are fine.
2125 let ok = encode_env(&[
2126 ("_FOO".to_string(), "bar=baz".to_string()),
2127 ("LC_ALL".to_string(), "C.UTF-8".to_string()),
2128 ])
2129 .unwrap();
2130 assert_eq!(ok.get("_FOO").map(String::as_str), Some("bar=baz"));
2131 }
2132
2133 #[test]
2134 fn ring_reset_to_clears_prior_dropped() {
2135 let mut ring = Ring::default();
2136 ring.append(vec![b'a'; STREAM_BUFFER_CAP_BYTES + 1]);
2137 assert!(ring.dropped);
2138 ring.reset_to(b"repaint".to_vec());
2139 assert!(!ring.dropped);
2140 assert_eq!(ring.tail(), b"repaint");
2141
2142 // An over-cap repaint re-latches through append's normal path.
2143 let mut over = Ring::default();
2144 over.append(vec![b'b'; STREAM_BUFFER_CAP_BYTES + 1]);
2145 over.reset_to(vec![b'c'; STREAM_BUFFER_CAP_BYTES + 1]);
2146 assert!(over.dropped);
2147 assert_eq!(over.size, STREAM_BUFFER_CAP_BYTES);
2148 }
2149
2150 #[test]
2151 fn terminal_status_mapping() {
2152 assert!(matches!(
2153 terminal_status_error(pb::SailboxExecStatus::WorkerLost as i32),
2154 Some(SailError::HostLost { .. })
2155 ));
2156 assert!(terminal_status_error(pb::SailboxExecStatus::Succeeded as i32).is_none());
2157 // Retired wire values (canceled=9, interrupted_retryable=6, interrupted_unsafe_to_retry=7,
2158 // reserved in the proto) carry no error: an old backend that still emits one falls through
2159 // to a normal result rather than raising.
2160 for retired in [9, 6, 7] {
2161 assert!(terminal_status_error(retired).is_none());
2162 }
2163 }
2164
2165 fn test_shared() -> Arc<ExecShared> {
2166 Arc::new(ExecShared {
2167 worker: Arc::new(WorkerProxy::new("test-key").unwrap()),
2168 params: ExecParams {
2169 sailbox_id: "sb".into(),
2170 exec_endpoint: "endpoint".into(),
2171 argv: vec!["echo".into()],
2172 timeout_seconds: 0,
2173 idempotency_key: "idem".into(),
2174 open_stdin: false,
2175 pty: false,
2176 term: String::new(),
2177 cols: 0,
2178 rows: 0,
2179 env: std::collections::HashMap::default(),
2180 retry_timeout: 0.0,
2181 forward_ports: false,
2182 forward_browser: false,
2183 extra_metadata: vec![],
2184 forward_clipboard: false,
2185 },
2186 clipboard_update: Mutex::new(None),
2187 clipboard_notify: Notify::new(),
2188 state: Mutex::new(State::default()),
2189 forward_events: Mutex::new(VecDeque::new()),
2190 forward_notify: Notify::new(),
2191 cond: Condvar::new(),
2192 data_notify: Notify::new(),
2193 exit: Mutex::new(None),
2194 high_seq: Mutex::new((0, 0)),
2195 stdin: AsyncMutex::new(StdinState::default()),
2196 ended: AtomicBool::new(false),
2197 ended_notify: Notify::new(),
2198 closing: AtomicBool::new(false),
2199 close_notify: Notify::new(),
2200 })
2201 }
2202
2203 fn chunk(which: OutputStream, seq: i64, data: &[u8]) -> pb::StreamSailboxExecResponse {
2204 let stream = match which {
2205 OutputStream::Stdout => pb::SailboxExecStream::Stdout,
2206 OutputStream::Stderr => pb::SailboxExecStream::Stderr,
2207 };
2208 pb::StreamSailboxExecResponse {
2209 frame: Some(pb::stream_sailbox_exec_response::Frame::Chunk(
2210 pb::SailboxExecChunk {
2211 stream: stream as i32,
2212 data: data.to_vec(),
2213 seq,
2214 },
2215 )),
2216 }
2217 }
2218
2219 fn test_shared_with_forward(forward_ports: bool, forward_browser: bool) -> Arc<ExecShared> {
2220 let shared = test_shared();
2221 // ExecShared is only mutated through interior mutability at runtime; for
2222 // the test, rebuild it with the forwarding flags set.
2223 let mut params = shared.params.clone();
2224 params.forward_ports = forward_ports;
2225 params.forward_browser = forward_browser;
2226 Arc::new(ExecShared {
2227 worker: shared.worker.clone(),
2228 params,
2229 clipboard_update: Mutex::new(None),
2230 clipboard_notify: Notify::new(),
2231 state: Mutex::new(State::default()),
2232 forward_events: Mutex::new(VecDeque::new()),
2233 forward_notify: Notify::new(),
2234 cond: Condvar::new(),
2235 data_notify: Notify::new(),
2236 exit: Mutex::new(None),
2237 high_seq: Mutex::new((0, 0)),
2238 stdin: AsyncMutex::new(StdinState::default()),
2239 ended: AtomicBool::new(false),
2240 ended_notify: Notify::new(),
2241 closing: AtomicBool::new(false),
2242 close_notify: Notify::new(),
2243 })
2244 }
2245
2246 fn open_url_frame(url: &str) -> pb::StreamSailboxExecResponse {
2247 pb::StreamSailboxExecResponse {
2248 frame: Some(pb::stream_sailbox_exec_response::Frame::OpenUrl(
2249 pb::SailboxExecOpenUrl {
2250 url: url.to_string(),
2251 },
2252 )),
2253 }
2254 }
2255
2256 fn port_snapshot_frame(ports: &[u32]) -> pb::StreamSailboxExecResponse {
2257 pb::StreamSailboxExecResponse {
2258 frame: Some(pb::stream_sailbox_exec_response::Frame::PortSnapshot(
2259 pb::SailboxExecPortSnapshot {
2260 ports: ports.to_vec(),
2261 },
2262 )),
2263 }
2264 }
2265
2266 #[test]
2267 fn forward_flags_gate_browser_on_both_opt_outs() {
2268 // Default: all three on.
2269 assert_eq!(forward_flags(false, false), (true, true, true));
2270 // Browser-only opt-out keeps port and clipboard forwarding.
2271 assert_eq!(forward_flags(false, true), (true, false, true));
2272 // Full opt-out turns all three off, and browser cannot outlive ports
2273 // (its OAuth callback is a forwarded localhost server).
2274 assert_eq!(forward_flags(true, false), (false, false, false));
2275 assert_eq!(forward_flags(true, true), (false, false, false));
2276 }
2277
2278 #[test]
2279 fn forward_frames_are_delivered_only_when_the_session_opted_in() {
2280 // Forwarding on: both frames become events for the shell driver.
2281 let mut pump = Pump::new(test_shared_with_forward(true, true));
2282 pump.apply_frame(open_url_frame("http://localhost:3000"));
2283 pump.apply_frame(port_snapshot_frame(&[3000, 5173]));
2284 let events: Vec<_> = {
2285 let mut q = lock(&pump.shared.forward_events);
2286 q.drain(..).collect()
2287 };
2288 assert_eq!(events.len(), 2, "opted-in frames are delivered");
2289
2290 // Forwarding off (a plain exec, or an opted-out session): the client
2291 // drops the frames itself, so a guest that emits them regardless cannot
2292 // open the user's browser or bind local ports, and the queue stays empty.
2293 let mut pump = Pump::new(test_shared_with_forward(false, false));
2294 pump.apply_frame(open_url_frame("http://localhost:3000"));
2295 pump.apply_frame(port_snapshot_frame(&[3000]));
2296 assert!(
2297 lock(&pump.shared.forward_events).is_empty(),
2298 "opted-out frames are dropped at the trusted client"
2299 );
2300
2301 // Ports on, browser off: the port snapshot lands, the browser open does not.
2302 let mut pump = Pump::new(test_shared_with_forward(true, false));
2303 pump.apply_frame(open_url_frame("http://localhost:3000"));
2304 pump.apply_frame(port_snapshot_frame(&[3000]));
2305 let events: Vec<_> = {
2306 let mut q = lock(&pump.shared.forward_events);
2307 q.drain(..).collect()
2308 };
2309 assert_eq!(events.len(), 1, "only the port snapshot is delivered");
2310 assert!(matches!(events[0], ForwardEvent::PortSnapshot(_)));
2311 }
2312
2313 fn exit_frame(status: pb::SailboxExecStatus, stdout_seq: i64) -> pb::StreamSailboxExecResponse {
2314 pb::StreamSailboxExecResponse {
2315 frame: Some(pb::stream_sailbox_exec_response::Frame::Exit(
2316 pb::SailboxExecExit {
2317 status: status as i32,
2318 return_code: 0,
2319 timed_out: false,
2320 stdout_truncated: false,
2321 stderr_truncated: false,
2322 error_message: String::new(),
2323 stdout_seq,
2324 stderr_seq: 0,
2325 ..Default::default()
2326 },
2327 )),
2328 }
2329 }
2330
2331 /// The resume state machine: bytes split across a mid-stream break arrive
2332 /// verbatim (a mid-char break is just two chunks; the edge decode heals it),
2333 /// the high-water seq only advances (so a replayed tail can't lower the
2334 /// resume point), and finalize publishes the seqs `wait()` checks for
2335 /// completeness.
2336 #[tokio::test]
2337 async fn exec_resume_carries_bytes_and_tracks_seq_across_break() {
2338 let shared = test_shared();
2339 let mut pump = Pump::new(shared.clone());
2340
2341 // Pre-break: seq 1 ends mid-'é' (0xC3 0xA9), delivering only the lead byte.
2342 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 1, b"ab\xc3")));
2343 assert_eq!(shared.state.lock().unwrap().stdout.tail(), b"ab\xc3");
2344 // The reconnect would call submit(.., stdout_resume_seq = 1, ..).
2345 assert_eq!(pump.stdout_seq, 1);
2346
2347 // The socket breaks; the guest replays only seq > 1. Seq 2 supplies the
2348 // rest of 'é' plus more; the ring concatenates the raw bytes, and the
2349 // string edge lossy-decodes them whole.
2350 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 2, b"\xa9cd")));
2351 assert_eq!(
2352 shared.state.lock().unwrap().stdout.tail(),
2353 "abécd".as_bytes()
2354 );
2355 assert_eq!(lossy_tail(&shared.state.lock().unwrap().stdout), "abécd");
2356 assert_eq!(pump.stdout_seq, 2);
2357
2358 // An out-of-order/replayed lower seq must not rewind the resume point.
2359 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 1, b"!")));
2360 assert_eq!(pump.stdout_seq, 2);
2361
2362 assert!(pump.apply_frame(exit_frame(pb::SailboxExecStatus::Succeeded, 2)));
2363 pump.finalize();
2364 assert_eq!(*shared.high_seq.lock().unwrap(), (2, 0));
2365 assert!(shared.ended.load(Ordering::SeqCst));
2366 }
2367
2368 fn snapshot_frame(repaint: &[u8], basis: i64) -> pb::StreamSailboxExecResponse {
2369 pb::StreamSailboxExecResponse {
2370 frame: Some(pb::stream_sailbox_exec_response::Frame::Snapshot(
2371 pb::SailboxExecSnapshot {
2372 repaint: repaint.to_vec(),
2373 stdout_seq_basis: basis,
2374 },
2375 )),
2376 }
2377 }
2378
2379 /// A pty Snapshot supersedes the retained stream: readers skip to the
2380 /// repaint, a late reader sees only the repaint, the seq high-water is
2381 /// assigned to the basis, and Exit-completeness math continues from it.
2382 #[tokio::test]
2383 async fn snapshot_resets_ring_seq_basis_and_skips_readers() {
2384 let shared = test_shared();
2385 let mut pump = Pump::new(shared.clone());
2386 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 1, b"pre-disconnect ")));
2387 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 2, b"tail")));
2388
2389 // The reattach answers with a repaint based at the guest's high seq.
2390 assert!(!pump.apply_frame(snapshot_frame(b"\x1b[2J\x1b[Hscreen", 7)));
2391 assert_eq!(pump.stdout_seq, 7);
2392
2393 // A late reader replays only the repaint, then the post-snapshot chunk.
2394 assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 8, b" after")));
2395 assert!(pump.apply_frame(exit_frame(pb::SailboxExecStatus::Succeeded, 8)));
2396 pump.finalize();
2397
2398 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2399 let mut out = Vec::new();
2400 loop {
2401 match reader.next(Duration::from_millis(50)) {
2402 ReadStep::Chunk(piece) => out.extend_from_slice(&piece),
2403 ReadStep::Eof => break,
2404 ReadStep::Pending => panic!("ended ring should not return Pending"),
2405 }
2406 }
2407 assert_eq!(out, b"\x1b[2J\x1b[Hscreen after");
2408 // Completeness: exit.stdout_seq (8) <= published high seq (8).
2409 assert_eq!(*shared.high_seq.lock().unwrap(), (8, 0));
2410 }
2411
2412 #[test]
2413 fn snapshot_with_empty_repaint_is_reset_only() {
2414 let shared = test_shared();
2415 let mut pump = Pump::new(shared.clone());
2416 pump.apply_frame(chunk(OutputStream::Stdout, 3, b"stale"));
2417 pump.apply_frame(snapshot_frame(b"", 3));
2418 assert_eq!(pump.stdout_seq, 3);
2419 let state = lock(&shared.state);
2420 assert!(state.stdout.tail().is_empty());
2421 assert!(!state.stdout.dropped);
2422 }
2423
2424 /// A reader started before any output replays the retained tail in order, then
2425 /// follows live chunks (including ones that arrive after a reconnect gap), and
2426 /// stops at Eof once the pump finalizes.
2427 #[tokio::test]
2428 async fn exec_reader_follows_replayed_then_live() {
2429 let shared = test_shared();
2430 let mut reader = StreamReader {
2431 shared: shared.clone(),
2432 which: OutputStream::Stdout,
2433 cursor: 0,
2434 dropped: false,
2435 reset: false,
2436 seen_front_clips: 0,
2437 seen_resets: 0,
2438 };
2439 let collector = tokio::task::spawn_blocking(move || {
2440 let mut out = Vec::new();
2441 loop {
2442 match reader.next(Duration::from_millis(50)) {
2443 ReadStep::Chunk(piece) => out.extend_from_slice(&piece),
2444 ReadStep::Eof => return out,
2445 ReadStep::Pending => {}
2446 }
2447 }
2448 });
2449
2450 let mut pump = Pump::new(shared.clone());
2451 pump.apply_frame(chunk(OutputStream::Stdout, 1, b"hello "));
2452 tokio::time::sleep(Duration::from_millis(10)).await;
2453 // A reconnect gap, then the live tail resumes.
2454 pump.apply_frame(chunk(OutputStream::Stdout, 2, b"world"));
2455 tokio::time::sleep(Duration::from_millis(10)).await;
2456 pump.apply_frame(exit_frame(pb::SailboxExecStatus::Succeeded, 2));
2457 pump.finalize();
2458
2459 assert_eq!(collector.await.unwrap(), b"hello world");
2460 }
2461
2462 /// A reader created after output is already buffered still replays the
2463 /// retained tail from the start (cursor 0), then sees Eof.
2464 #[test]
2465 fn exec_reader_started_late_replays_retained_tail() {
2466 let shared = test_shared();
2467 let mut pump = Pump::new(shared.clone());
2468 pump.apply_frame(chunk(OutputStream::Stdout, 1, b"early "));
2469 pump.apply_frame(chunk(OutputStream::Stdout, 2, b"output"));
2470 pump.finalize();
2471
2472 // Construct the reader only now, against an already-populated, ended ring.
2473 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2474 let mut out = Vec::new();
2475 loop {
2476 match reader.next(Duration::from_millis(50)) {
2477 ReadStep::Chunk(piece) => out.extend_from_slice(&piece),
2478 ReadStep::Eof => break,
2479 ReadStep::Pending => panic!("ended ring should not return Pending"),
2480 }
2481 }
2482 assert_eq!(out, b"early output");
2483 }
2484
2485 // A reader that falls behind a ring overflow skips the evicted chunks and
2486 // reports the drop once (then clears it), and try_next batch-drains without
2487 // blocking. This is what the interactive bridge keys its repaint request on.
2488 #[test]
2489 fn reader_reports_drop_and_batch_drains() {
2490 let shared = test_shared();
2491 {
2492 // A small head chunk plus a cap-sized chunk overflows the ring,
2493 // fully evicting the head (advancing first_idx past it).
2494 let mut state = lock(&shared.state);
2495 state.stdout.append(b"HEAD".to_vec());
2496 state.stdout.append(vec![b'x'; STREAM_BUFFER_CAP_BYTES]);
2497 state.ended = true;
2498 }
2499 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2500
2501 // The reader skips the evicted head and reports the drop once.
2502 let first = reader.next(Duration::from_millis(50));
2503 let ReadStep::Chunk(first) = first else {
2504 panic!("expected the retained chunk, got {first:?}");
2505 };
2506 assert!(
2507 reader.took_drop(),
2508 "the overflow evicted an unconsumed chunk"
2509 );
2510 assert!(!reader.took_drop(), "took_drop clears the latch");
2511
2512 // The retained content is the surviving chunk; the head is gone.
2513 assert_eq!(first, vec![b'x'; STREAM_BUFFER_CAP_BYTES]);
2514 assert!(
2515 reader.try_next().is_none(),
2516 "a drained ring yields None without blocking"
2517 );
2518 }
2519
2520 #[test]
2521 fn reader_does_not_report_a_reset_repaint_as_a_drop() {
2522 let shared = test_shared();
2523 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2524 {
2525 let mut state = lock(&shared.state);
2526 state.stdout.append(b"one".to_vec());
2527 }
2528 // Consume the first chunk cleanly: no drop yet.
2529 assert!(matches!(
2530 reader.next(Duration::from_millis(50)),
2531 ReadStep::Chunk(_)
2532 ));
2533 assert!(!reader.took_drop(), "a clean read latches no drop");
2534 assert!(!reader.took_reset(), "a clean read is not a reset");
2535
2536 {
2537 // More live output the reader has not read, then a repaint that
2538 // supersedes the ring and advances first_idx past the cursor. The
2539 // reader must read the repaint as a heal, not report a new drop:
2540 // otherwise the pump discards the repaint and loops on resyncs.
2541 let mut state = lock(&shared.state);
2542 state.stdout.append(b"two".to_vec());
2543 state.stdout.append(b"three".to_vec());
2544 state.stdout.reset_to(b"REPAINT".to_vec());
2545 state.ended = true;
2546 }
2547 let repaint = reader.next(Duration::from_millis(50));
2548 let ReadStep::Chunk(repaint) = repaint else {
2549 panic!("expected the repaint chunk, got {repaint:?}");
2550 };
2551 assert_eq!(repaint, b"REPAINT");
2552 assert!(
2553 !reader.took_drop(),
2554 "reading a reset repaint is a heal, not a fall-behind drop"
2555 );
2556 assert!(
2557 reader.took_reset(),
2558 "the reset repaint is surfaced as a took_reset event so the pump can \
2559 drop stale terminal-local backlog buffered ahead of it"
2560 );
2561 assert!(!reader.took_reset(), "took_reset clears the latch");
2562 }
2563
2564 #[test]
2565 fn reader_reset_supersedes_a_previously_latched_drop() {
2566 let shared = test_shared();
2567 {
2568 // A small head plus a cap-sized chunk overflows the ring, evicting
2569 // the head so the first read below latches a drop.
2570 let mut state = lock(&shared.state);
2571 state.stdout.append(b"HEAD".to_vec());
2572 state.stdout.append(vec![b'x'; STREAM_BUFFER_CAP_BYTES]);
2573 }
2574 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2575
2576 // Read the surviving chunk. The drop is latched but not yet observed,
2577 // the way the output pump reads a chunk and only checks took_drop at the
2578 // end of its loop iteration.
2579 assert!(matches!(
2580 reader.next(Duration::from_millis(50)),
2581 ReadStep::Chunk(_)
2582 ));
2583
2584 {
2585 // A repaint supersedes the stream before that pending drop is
2586 // observed. The repaint heals the drop too, so the drop latch must
2587 // not survive to trigger a resync that would discard the repaint.
2588 let mut state = lock(&shared.state);
2589 state.stdout.reset_to(b"REPAINT".to_vec());
2590 state.ended = true;
2591 }
2592 let repaint = reader.next(Duration::from_millis(50));
2593 let ReadStep::Chunk(repaint) = repaint else {
2594 panic!("expected the repaint chunk, got {repaint:?}");
2595 };
2596 assert_eq!(repaint, b"REPAINT");
2597 assert!(reader.took_reset(), "the repaint is surfaced as a reset");
2598 assert!(
2599 !reader.took_drop(),
2600 "the reset superseded the stale drop latch, so no redundant resync \
2601 clobbers the repaint"
2602 );
2603 }
2604
2605 #[test]
2606 fn reader_reports_a_drop_when_output_evicts_the_repaint_before_it_is_read() {
2607 let shared = test_shared();
2608 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2609 {
2610 let mut state = lock(&shared.state);
2611 state.stdout.append(b"one".to_vec());
2612 }
2613 // Consume the first chunk cleanly: no drop, no reset yet.
2614 assert!(matches!(
2615 reader.next(Duration::from_millis(50)),
2616 ReadStep::Chunk(_)
2617 ));
2618 assert!(!reader.took_drop());
2619 assert!(!reader.took_reset());
2620
2621 {
2622 // A repaint resets the ring, then a burst larger than the cap evicts
2623 // that repaint before the reader observes the reset. The reader is
2624 // now about to hand the terminal a torn post-repaint suffix, not the
2625 // repaint. It must still report a drop so the pump resyncs for a
2626 // fresh repaint, rather than leaving the terminal on an arbitrary
2627 // fragment when the stream then goes idle.
2628 let mut state = lock(&shared.state);
2629 state.stdout.reset_to(b"REPAINT".to_vec());
2630 state.stdout.append(vec![b'x'; STREAM_BUFFER_CAP_BYTES + 1]);
2631 state.ended = true;
2632 }
2633 let chunk = reader.next(Duration::from_millis(50));
2634 let ReadStep::Chunk(chunk) = chunk else {
2635 panic!("expected the retained suffix, got {chunk:?}");
2636 };
2637 assert_ne!(chunk, b"REPAINT", "the repaint was evicted by the burst");
2638 assert!(reader.took_reset(), "a reset did occur");
2639 assert!(
2640 reader.took_drop(),
2641 "the repaint was evicted after the reset, so the reader reports a \
2642 drop and the pump resyncs instead of showing a torn suffix"
2643 );
2644 }
2645
2646 #[test]
2647 fn reader_try_next_batch_drains_buffered_chunks() {
2648 let shared = test_shared();
2649 {
2650 let mut state = lock(&shared.state);
2651 state.stdout.append(b"one".to_vec());
2652 state.stdout.append(b"two".to_vec());
2653 state.stdout.append(b"three".to_vec());
2654 state.ended = true;
2655 }
2656 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2657
2658 // next() takes the first chunk; try_next() drains the rest in one batch
2659 // without blocking, then reports the ring is momentarily empty.
2660 let first = reader.next(Duration::from_millis(50));
2661 let ReadStep::Chunk(first) = first else {
2662 panic!("expected the first buffered chunk, got {first:?}");
2663 };
2664 assert_eq!(first, b"one");
2665 assert_eq!(reader.try_next(), Some(b"two".to_vec()));
2666 assert_eq!(reader.try_next(), Some(b"three".to_vec()));
2667 assert!(reader.try_next().is_none(), "the ring is drained");
2668 assert!(!reader.took_drop(), "no eviction, so no drop is latched");
2669 }
2670
2671 #[test]
2672 fn reader_reports_a_front_clip_as_a_drop() {
2673 let shared = test_shared();
2674 {
2675 // Fill the ring to exactly the cap with one piece the reader parks on.
2676 let mut state = lock(&shared.state);
2677 state.stdout.append(vec![b'a'; STREAM_BUFFER_CAP_BYTES]);
2678 }
2679 let mut reader = shared_reader(&shared, OutputStream::Stdout);
2680 {
2681 // A small append overflows by less than the front piece, so the ring
2682 // trims that piece in place rather than evicting it: first_idx holds.
2683 let mut state = lock(&shared.state);
2684 state.stdout.append(b"tail".to_vec());
2685 state.ended = true;
2686 assert_eq!(
2687 state.stdout.first_idx, 0,
2688 "the front piece was clipped, not evicted"
2689 );
2690 }
2691 // The reader is still parked on the clipped piece, so it must latch the
2692 // drop even though first_idx never moved.
2693 let step = reader.next(Duration::from_millis(50));
2694 let ReadStep::Chunk(_) = step else {
2695 panic!("expected the clipped front piece, got {step:?}");
2696 };
2697 assert!(
2698 reader.took_drop(),
2699 "an in-place front clip of the parked piece is a drop"
2700 );
2701 }
2702
2703 fn shared_reader(shared: &Arc<ExecShared>, which: OutputStream) -> StreamReader {
2704 StreamReader {
2705 shared: shared.clone(),
2706 which,
2707 cursor: 0,
2708 dropped: false,
2709 reset: false,
2710 seen_front_clips: 0,
2711 seen_resets: 0,
2712 }
2713 }
2714
2715 use proptest::prelude::*;
2716
2717 proptest! {
2718 /// Under the cap the ring is lossless: it keeps every byte in order,
2719 /// reports no drop, and its accounting matches the input exactly.
2720 #[test]
2721 fn ring_without_overflow_is_lossless(
2722 pieces in proptest::collection::vec(proptest::collection::vec(any::<u8>(), 0..512), 0..32)
2723 ) {
2724 let concat: Vec<u8> = pieces.concat();
2725 prop_assume!(concat.len() <= STREAM_BUFFER_CAP_BYTES);
2726 let mut ring = Ring::default();
2727 for piece in &pieces {
2728 ring.append(piece.clone());
2729 }
2730 prop_assert!(!ring.dropped);
2731 prop_assert_eq!(ring.size, concat.len());
2732 prop_assert_eq!(ring.tail(), concat);
2733 }
2734 }
2735
2736 proptest! {
2737 // Each case allocates ~1 MiB, so keep the case count modest.
2738 #![proptest_config(ProptestConfig::with_cases(48))]
2739
2740 /// Once total output exceeds the cap, the ring drops oldest bytes: it
2741 /// stays byte-exactly at the cap and its retained bytes are always a
2742 /// suffix of everything appended (drops only ever come off the front).
2743 #[test]
2744 fn ring_eviction_keeps_byte_suffix_at_cap(
2745 overflow in 1usize..8192,
2746 tail_pieces in proptest::collection::vec(proptest::collection::vec(any::<u8>(), 0..64), 0..8),
2747 ) {
2748 let head = vec![b'h'; STREAM_BUFFER_CAP_BYTES + overflow];
2749 let mut full = head.clone();
2750 let mut ring = Ring::default();
2751 ring.append(head);
2752 for piece in &tail_pieces {
2753 ring.append(piece.clone());
2754 full.extend_from_slice(piece);
2755 }
2756 prop_assert!(ring.dropped);
2757 prop_assert_eq!(ring.size, STREAM_BUFFER_CAP_BYTES);
2758 let tail = ring.tail();
2759 prop_assert_eq!(tail.len(), STREAM_BUFFER_CAP_BYTES);
2760 prop_assert!(full.ends_with(&tail));
2761 }
2762 }
2763
2764 proptest! {
2765 /// A Snapshot assigns the seq basis regardless of prior seqs (the one
2766 /// deliberate exception to never-rewind), and the ring afterwards holds
2767 /// exactly the repaint.
2768 #[test]
2769 fn pump_snapshot_assigns_basis_and_replaces_ring(
2770 pre_seqs in proptest::collection::vec(0i64..10_000, 0..16),
2771 basis in 0i64..10_000,
2772 ) {
2773 let shared = test_shared();
2774 let mut pump = Pump::new(shared.clone());
2775 for seq in pre_seqs {
2776 pump.apply_frame(chunk(OutputStream::Stdout, seq, b"x"));
2777 }
2778 pump.apply_frame(snapshot_frame(b"repaint", basis));
2779 prop_assert_eq!(pump.stdout_seq, basis);
2780 prop_assert_eq!(lock(&shared.state).stdout.tail(), b"repaint".to_vec());
2781 }
2782 }
2783
2784 proptest! {
2785 /// The per-stream high-water seq is the running max of the seqs seen on
2786 /// that stream and only ever advances, regardless of frame order, so a
2787 /// replayed or out-of-order tail after a reconnect can't lower the
2788 /// resume point, and the two streams are tracked independently.
2789 #[test]
2790 fn pump_seq_is_monotonic_running_max_per_stream(
2791 frames in proptest::collection::vec(
2792 (any::<bool>(), 0i64..10_000, proptest::collection::vec(any::<u8>(), 0..8)),
2793 0..64,
2794 )
2795 ) {
2796 let mut pump = Pump::new(test_shared());
2797 let (mut expect_out, mut expect_err) = (0i64, 0i64);
2798 let (mut prev_out, mut prev_err) = (0i64, 0i64);
2799 for (is_stderr, seq, data) in frames {
2800 let which = if is_stderr { OutputStream::Stderr } else { OutputStream::Stdout };
2801 pump.apply_frame(chunk(which, seq, &data));
2802 if is_stderr {
2803 expect_err = expect_err.max(seq);
2804 } else {
2805 expect_out = expect_out.max(seq);
2806 }
2807 prop_assert_eq!(pump.stdout_seq, expect_out);
2808 prop_assert_eq!(pump.stderr_seq, expect_err);
2809 prop_assert!(pump.stdout_seq >= prev_out);
2810 prop_assert!(pump.stderr_seq >= prev_err);
2811 prev_out = pump.stdout_seq;
2812 prev_err = pump.stderr_seq;
2813 }
2814 }
2815 }
2816}