Skip to main content

sail/
exec.rs

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