Skip to main content

sail/
exec.rs

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