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