Skip to main content

sail/
exec.rs

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