Skip to main content

sail/
exec.rs

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