Skip to main content

sail/
exec.rs

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