Skip to main content

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            &params,
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, &params.extra_metadata, /* timeout */ None)?;
1654        let status = match worker
1655            .client_for(&params.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(&params.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}