Skip to main content

sail/
exec.rs

1//! Streaming exec engine: a running command in a sailbox with live output.
2//!
3//! [`ExecProcess::start`] opens a server-streaming exec request. The first frame
4//! reports that the command has started (carrying the durable
5//! `exec_request_id`); chunk frames carry live stdout/stderr; a terminal frame
6//! carries the result. The command is detached on the server, so dropping the
7//! handle never kills it.
8//!
9//! A background pump task drains the stream into two drop-oldest ring buffers
10//! that synchronous readers ([`StreamReader`]) pull from. If the stream breaks
11//! mid-run, the pump reconnects with the same idempotency key and the per-stream
12//! seqs it last saw, so the guest replays only the unseen tail. Only when the
13//! stream cannot be resumed does [`ExecProcess::wait`] fall back to fetching the
14//! authoritative buffered result. Stdin rides a separate offset-idempotent unary
15//! request, independent of the output stream.
16
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Condvar, Mutex};
19use std::time::Duration;
20
21use serde::Serialize;
22use tokio::sync::Mutex as AsyncMutex;
23use tokio::sync::Notify;
24use tonic::{Code, Status, Streaming};
25
26use crate::error::SailError;
27use crate::pb::workerproxy::v1 as pb;
28use crate::worker::{
29    retry_deadline, rpc_attempt_timeout, should_invalidate_channel,
30    should_retry_transient_exec_rpc, sleep_before_retry, WorkerProxy,
31    EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS,
32};
33
34/// Default budget for transient-RPC retries against a waking/migrating sailbox;
35/// the default value of [`ExecOptions::retry_timeout`].
36#[doc(hidden)]
37pub const EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS: f64 = 30.0;
38/// Local cap on buffered stream output: a slow reader loses the oldest output
39/// rather than blocking the stream. Sized in UTF-8 bytes to the server's
40/// in-memory exec replay ring so a locally resolved tail is the same size the
41/// server replays on a reattach. A backend test keeps this in lockstep with the
42/// ring (`guestExecChunkBufferBytes`); change both together.
43const STREAM_BUFFER_CAP_BYTES: usize = 1024 * 1024;
44/// Stdin writes are chunked so a single RPC stays well under gRPC message limits
45/// and partial accepts resume cheaply.
46const STDIN_WRITE_CHUNK_BYTES: usize = 256 * 1024;
47
48/// Lock a mutex, recovering the guard if a peer panicked while holding it
49/// (matching `channels.rs`). The data under these locks is simple, so a poisoned
50/// peer should degrade rather than cascade a panic into reader/pump threads.
51fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
52    mutex
53        .lock()
54        .unwrap_or_else(std::sync::PoisonError::into_inner)
55}
56
57/// Which output stream a chunk or reader belongs to.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum OutputStream {
60    /// The standard output stream.
61    Stdout,
62    /// The standard error stream.
63    Stderr,
64}
65
66/// One step of reading a live output stream.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ReadStep {
69    /// The next retained chunk of output.
70    Chunk(String),
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, decoded as UTF-8.
84    pub stdout: String,
85    /// Buffered stderr, decoded as UTF-8.
86    pub stderr: String,
87    /// The command's exit code.
88    pub exit_code: i32,
89    /// Whether the command was killed for exceeding its timeout.
90    pub timed_out: bool,
91    /// Whether stdout overflowed the server output ring and lost its oldest
92    /// bytes.
93    pub stdout_truncated: bool,
94    /// Whether stderr overflowed the server output ring and lost its oldest
95    /// bytes.
96    pub stderr_truncated: bool,
97    /// Whether the live stream delivered stdout through to the command's exit.
98    /// When true, a consumer that streamed the output live already holds the
99    /// complete stdout even if `stdout` here is a truncated buffered tail. When
100    /// false (the stream ended before the exit, or no exit was observed),
101    /// `stdout` is the authoritative buffered copy to fall back on.
102    pub stdout_complete: bool,
103    /// Whether the live stream delivered stderr through to the command's exit
104    /// (see `stdout_complete`).
105    pub stderr_complete: bool,
106}
107
108/// Which signal to send when cancelling a running exec.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CancelSignal {
111    /// SIGINT: ask the command to stop (what a first Ctrl-C sends).
112    Interrupt,
113    /// SIGKILL: force-kill a command that ignored the interrupt.
114    Kill,
115}
116
117impl CancelSignal {
118    /// Whether this is the forceful (SIGKILL) variant, as the wire encodes it.
119    fn is_force(self) -> bool {
120        matches!(self, CancelSignal::Kill)
121    }
122}
123
124/// How long to keep retrying transient RPCs against a waking or migrating
125/// sailbox before giving up.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub enum RetryBudget {
128    /// Do not retry; fail on the first transient error.
129    None,
130    /// Retry for at most this long.
131    Within(Duration),
132    /// Retry indefinitely, until the call succeeds or hits a non-transient error.
133    Forever,
134}
135
136impl RetryBudget {
137    /// Encode as the seconds the core's retry loop expects: `0` = none, a finite
138    /// count = a bounded budget, `+inf` = forever.
139    #[doc(hidden)]
140    pub fn as_secs_f64(self) -> f64 {
141        match self {
142            RetryBudget::None => 0.0,
143            RetryBudget::Within(d) => d.as_secs_f64(),
144            RetryBudget::Forever => f64::INFINITY,
145        }
146    }
147
148    /// Decode from seconds at the FFI boundary (Python passes an `f64`): `<= 0` =
149    /// none, a non-finite value = forever, otherwise a bounded budget.
150    #[doc(hidden)]
151    pub fn from_secs_f64(secs: f64) -> RetryBudget {
152        if secs <= 0.0 {
153            RetryBudget::None
154        } else if secs.is_finite() {
155            RetryBudget::Within(Duration::from_secs_f64(secs))
156        } else {
157            RetryBudget::Forever
158        }
159    }
160}
161
162/// Optional settings for [`Sailbox::exec`](crate::Sailbox::exec) and
163/// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell). `Default` runs a
164/// plain foreground command (no pty, no stdin, no timeout) and retries transient
165/// failures against a waking or migrating sailbox for 30 seconds (see
166/// [`retry_timeout`](Self::retry_timeout)).
167#[derive(Debug, Clone)]
168pub struct ExecOptions {
169    /// Wall-clock limit before the server kills the command; `None` means no
170    /// limit. The wire is whole seconds, so a set sub-second timeout rounds up
171    /// to 1 second (it never collapses to the no-limit `0`).
172    pub timeout: Option<Duration>,
173    /// Leave the command's stdin open for [`ExecProcess::write_stdin`].
174    pub open_stdin: bool,
175    /// Allocate a pseudo-terminal for the command.
176    pub pty: bool,
177    /// TERM value for the pty (e.g. `xterm-256color`); ignored without `pty`.
178    pub term: String,
179    /// Initial pty width in columns; ignored without `pty`.
180    pub cols: u32,
181    /// Initial pty height in rows; ignored without `pty`.
182    pub rows: u32,
183    /// Stable key that dedupes the launch so a reconnect reattaches to the same
184    /// command. Empty mints a fresh one per call.
185    pub idempotency_key: String,
186    /// Budget for retrying transient RPC failures against a waking or migrating
187    /// sailbox: both while opening the stream and when [`ExecProcess::wait`]
188    /// falls back to `WaitSailboxExec`, which reattaches to the guest for the
189    /// result.
190    pub retry_timeout: RetryBudget,
191    /// Working directory to run a shell command in. Only valid with
192    /// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell).
193    pub cwd: Option<String>,
194    /// Detach a shell command so it keeps running and the call returns
195    /// immediately; output is discarded. Only valid with
196    /// [`Sailbox::exec_shell`](crate::Sailbox::exec_shell), and incompatible with
197    /// `open_stdin` and `pty`.
198    pub background: bool,
199}
200
201impl Default for ExecOptions {
202    fn default() -> ExecOptions {
203        ExecOptions {
204            timeout: None,
205            open_stdin: false,
206            pty: false,
207            term: String::new(),
208            cols: 0,
209            rows: 0,
210            idempotency_key: String::new(),
211            retry_timeout: RetryBudget::Within(Duration::from_secs_f64(
212                EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
213            )),
214            cwd: None,
215            background: false,
216        }
217    }
218}
219
220/// POSIX single-quote a string for safe inclusion in a shell command.
221pub(crate) fn sh_quote(value: &str) -> String {
222    format!("'{}'", value.replace('\'', "'\\''"))
223}
224
225/// Build the `argv` that runs `command` via `/bin/sh -lc`, applying the
226/// `cwd`/`background` shell conveniences from `options` and validating their
227/// combinations. This is the single implementation behind every SDK's
228/// string-command exec.
229#[doc(hidden)]
230pub fn shell_argv(command: &str, options: &ExecOptions) -> Result<Vec<String>, SailError> {
231    let invalid = |message: &str| {
232        Err(SailError::InvalidArgument {
233            message: message.to_string(),
234        })
235    };
236    if command.is_empty() {
237        return invalid("command must be non-empty");
238    }
239    if options.background && (options.open_stdin || options.pty) {
240        return invalid("background is not supported with open_stdin or pty");
241    }
242    let mut command = command.to_string();
243    if let Some(cwd) = &options.cwd {
244        let cwd = cwd.trim();
245        if cwd.is_empty() {
246            return invalid("cwd must be non-empty");
247        }
248        command = format!(
249            "cd {} && exec /bin/sh -lc {}",
250            sh_quote(cwd),
251            sh_quote(&command)
252        );
253    }
254    if options.background {
255        command = format!(
256            "nohup /bin/sh -lc {} </dev/null >/dev/null 2>&1 &",
257            sh_quote(&command)
258        );
259    }
260    Ok(vec!["/bin/sh".to_string(), "-lc".to_string(), command])
261}
262
263/// Parameters captured at launch and reused on every reconnect.
264#[doc(hidden)]
265#[derive(Debug, Clone)]
266pub struct ExecParams {
267    /// The sailbox the command runs in.
268    pub sailbox_id: String,
269    /// Worker-proxy endpoint that terminates the exec RPCs for this sailbox.
270    pub exec_endpoint: String,
271    /// The command and its arguments.
272    pub argv: Vec<String>,
273    /// Wall-clock limit in seconds before the server kills the command; 0 means
274    /// no limit.
275    pub timeout_seconds: u32,
276    /// Stable key that dedupes the launch and identifies the stream so a
277    /// reconnect reattaches to the same command rather than starting a new one.
278    pub idempotency_key: String,
279    /// Whether the command's stdin is left open for writes.
280    pub open_stdin: bool,
281    /// Whether to allocate a pseudo-terminal for the command.
282    pub pty: bool,
283    /// TERM value for the pty (e.g. `xterm-256color`); empty when not a pty.
284    pub term: String,
285    /// Initial pty width in columns.
286    pub cols: u32,
287    /// Initial pty height in rows.
288    pub rows: u32,
289    /// Budget in seconds for retrying transient failures while opening or
290    /// resuming the stream.
291    pub retry_timeout: f64,
292    /// Tracing metadata the wrapper injects (e.g. Voyages); opaque to the core.
293    pub extra_metadata: Vec<(String, String)>,
294}
295
296/// Incremental UTF-8 decoder: emits only complete characters and carries an
297/// incomplete trailing sequence to the next chunk, replacing genuinely invalid
298/// bytes with U+FFFD.
299#[derive(Default)]
300struct Utf8Decoder {
301    pending: Vec<u8>,
302}
303
304impl Utf8Decoder {
305    fn decode(&mut self, data: &[u8]) -> String {
306        self.pending.extend_from_slice(data);
307        let mut out = String::new();
308        loop {
309            match std::str::from_utf8(&self.pending) {
310                Ok(valid) => {
311                    out.push_str(valid);
312                    self.pending.clear();
313                    break;
314                }
315                Err(err) => {
316                    let valid_up_to = err.valid_up_to();
317                    // bytes [..valid_up_to] are valid UTF-8 by definition, so this can't fail.
318                    out.push_str(std::str::from_utf8(&self.pending[..valid_up_to]).unwrap());
319                    if let Some(bad) = err.error_len() {
320                        out.push('\u{FFFD}');
321                        self.pending.drain(..valid_up_to + bad);
322                    } else {
323                        // An incomplete trailing char: keep it for next time.
324                        self.pending.drain(..valid_up_to);
325                        break;
326                    }
327                }
328            }
329        }
330        out
331    }
332
333    fn flush(&mut self) -> String {
334        if self.pending.is_empty() {
335            return String::new();
336        }
337        let out = String::from_utf8_lossy(&self.pending).into_owned();
338        self.pending.clear();
339        out
340    }
341}
342
343/// Drop-oldest UTF-8 ring mirroring the server output ring. Appends never
344/// block; past the cap the oldest bytes are dropped and `dropped` latches.
345/// Pieces carry absolute indices so a reader that falls behind skips the
346/// dropped head instead of stalling.
347#[derive(Default)]
348struct Ring {
349    pieces: Vec<String>,
350    piece_bytes: Vec<usize>,
351    first_idx: usize,
352    size: usize,
353    dropped: bool,
354}
355
356impl Ring {
357    fn append(&mut self, text: String) {
358        let bytes = text.len();
359        self.pieces.push(text);
360        self.piece_bytes.push(bytes);
361        self.size += bytes;
362        while self.size > STREAM_BUFFER_CAP_BYTES {
363            let overflow = self.size - STREAM_BUFFER_CAP_BYTES;
364            if self.piece_bytes[0] <= overflow {
365                self.pieces.remove(0);
366                self.size -= self.piece_bytes.remove(0);
367                self.first_idx += 1;
368            } else {
369                // Clip the oldest piece by the overflow. Advance the cut to the
370                // next char boundary so a split multibyte char is dropped, then
371                // slice the valid str directly: from_utf8_lossy would expand a
372                // split leading byte into U+FFFD (3 bytes), which can keep size
373                // above the cap and spin this loop while holding the mutex.
374                let piece = &self.pieces[0];
375                let mut cut = overflow;
376                while cut < piece.len() && !piece.is_char_boundary(cut) {
377                    cut += 1;
378                }
379                let kept = piece[cut..].to_string();
380                self.size -= self.piece_bytes[0];
381                self.piece_bytes[0] = kept.len();
382                self.size += self.piece_bytes[0];
383                self.pieces[0] = kept;
384            }
385            self.dropped = true;
386        }
387    }
388
389    fn tail(&self) -> String {
390        self.pieces.concat()
391    }
392}
393
394#[derive(Default)]
395struct State {
396    stdout: Ring,
397    stderr: Ring,
398    ended: bool,
399}
400
401impl State {
402    fn ring(&self, which: OutputStream) -> &Ring {
403        match which {
404            OutputStream::Stdout => &self.stdout,
405            OutputStream::Stderr => &self.stderr,
406        }
407    }
408}
409
410/// Terminal exec result captured from the Exit frame or a poll.
411#[derive(Clone)]
412struct ExitInfo {
413    status: i32,
414    exit_code: i32,
415    timed_out: bool,
416    stdout_truncated: bool,
417    stderr_truncated: bool,
418    error_message: String,
419    stdout_seq: i64,
420    stderr_seq: i64,
421}
422
423#[derive(Default)]
424struct StdinState {
425    offset: i64,
426    eof_sent: bool,
427    broken: bool,
428    /// Set under the lock for the duration of a data write, which holds the lock
429    /// across its network send. A clean return clears it; a write whose future
430    /// is dropped mid-send (the caller cancelled it) releases the lock with this
431    /// still set, so the next writer observes it and poisons rather than
432    /// resuming from a stale offset. This is the cancellation latch: it lives in
433    /// the same lock that serializes writes, so no later write can race ahead of
434    /// it.
435    write_in_flight: bool,
436}
437
438struct ExecShared {
439    worker: Arc<WorkerProxy>,
440    params: ExecParams,
441    state: Mutex<State>,
442    /// Wakes synchronous readers/waiters when output is appended or the stream
443    /// ends.
444    cond: Condvar,
445    /// The async counterpart of `cond`: wakes [`AsyncStreamReader`]s without
446    /// parking a runtime thread. Notified on every append and at end of stream.
447    data_notify: Notify,
448    exit: Mutex<Option<ExitInfo>>,
449    /// Highest chunk seq received per stream, published when the pump ends.
450    high_seq: Mutex<(i64, i64)>,
451    stdin: AsyncMutex<StdinState>,
452    ended: AtomicBool,
453    ended_notify: Notify,
454    closing: AtomicBool,
455    close_notify: Notify,
456}
457
458/// A handle to a running command. Drop or [`ExecProcess::close`] releases the
459/// stream without killing the command.
460pub struct ExecProcess {
461    shared: Arc<ExecShared>,
462    exec_request_id: String,
463}
464
465impl Drop for ExecProcess {
466    fn drop(&mut self) {
467        // Honor the documented contract: a handle dropped without an explicit
468        // close() (e.g. by Python GC) still stops the pump and releases the
469        // stream, instead of leaving the gRPC stream running until the command
470        // finishes on its own.
471        self.close();
472    }
473}
474
475impl ExecProcess {
476    /// Submit the exec and start pumping output. Blocks until the server sends
477    /// the `Started` frame (the launch is durably settled).
478    ///
479    /// # Runtime
480    ///
481    /// Spawns the background output pump on the calling task's tokio runtime, so
482    /// call it from within one. The reconnect dials co-locate on that runtime.
483    #[doc(hidden)]
484    pub async fn start(
485        worker: Arc<WorkerProxy>,
486        mut params: ExecParams,
487    ) -> Result<ExecProcess, SailError> {
488        // The idempotency key dedupes the launch and lets a reconnect reattach
489        // to the same command. Mint one when the caller didn't supply their own,
490        // so every binding gets a stable key without restating the format.
491        let key = params.idempotency_key.trim();
492        params.idempotency_key = if key.is_empty() {
493            format!("exec_{}", uuid::Uuid::new_v4())
494        } else {
495            key.to_string()
496        };
497        let (exec_request_id, stream) = submit(
498            &worker, &params, /* stdout_resume_seq */ 0, /* stderr_resume_seq */ 0,
499        )
500        .await?;
501        let shared = Arc::new(ExecShared {
502            worker,
503            params,
504            state: Mutex::new(State::default()),
505            cond: Condvar::new(),
506            data_notify: Notify::new(),
507            exit: Mutex::new(None),
508            high_seq: Mutex::new((0, 0)),
509            stdin: AsyncMutex::new(StdinState::default()),
510            ended: AtomicBool::new(false),
511            ended_notify: Notify::new(),
512            closing: AtomicBool::new(false),
513            close_notify: Notify::new(),
514        });
515        let pump_shared = shared.clone();
516        tokio::spawn(async move { pump(pump_shared, stream).await });
517        Ok(ExecProcess {
518            shared,
519            exec_request_id,
520        })
521    }
522
523    /// The durable server-assigned id for this exec, taken from the `Started`
524    /// frame. Identifies the command for wait, cancel, resize, and stdin RPCs.
525    pub fn exec_request_id(&self) -> &str {
526        &self.exec_request_id
527    }
528
529    /// The idempotency key this exec launched with: the caller's, or the one
530    /// minted in `start` when none was supplied.
531    pub fn idempotency_key(&self) -> &str {
532        &self.shared.params.idempotency_key
533    }
534
535    /// Create a reader over a live output stream. A fresh reader replays the
536    /// retained tail from the start, then follows live.
537    pub fn reader(&self, which: OutputStream) -> StreamReader {
538        StreamReader {
539            shared: self.shared.clone(),
540            which,
541            cursor: 0,
542        }
543    }
544
545    /// Create an async reader over a live output stream (the awaiting twin of
546    /// [`reader`](Self::reader)).
547    pub fn reader_async(&self, which: OutputStream) -> AsyncStreamReader {
548        AsyncStreamReader {
549            shared: self.shared.clone(),
550            which,
551            cursor: 0,
552        }
553    }
554
555    /// Returns the exit code if the Exit frame arrived on the stream, mapping a
556    /// not-a-real-result terminal status to its error. `None` means the result
557    /// is not known on the stream yet. `wait` is authoritative.
558    pub fn poll(&self) -> Option<Result<i32, SailError>> {
559        let exit = lock(&self.shared.exit);
560        exit.as_ref()
561            .map(|exit| match terminal_status_error(exit.status) {
562                Some(err) => Err(err),
563                None => Ok(exit.exit_code),
564            })
565    }
566
567    /// Stop the pump and release the stream without touching the remote command.
568    pub fn close(&self) {
569        self.shared.closing.store(true, Ordering::SeqCst);
570        // notify_one stores a permit if the pump is between its `closing` check
571        // and registering on close_notify, so a close racing the pump is not
572        // missed (notify_waiters wakes only already-registered waiters). The
573        // pump is the sole waiter, so one permit suffices.
574        self.shared.close_notify.notify_one();
575    }
576
577    /// Block up to `timeout` for the output stream to end; returns whether it
578    /// has. Lets a synchronous caller stay responsive to its own signals and
579    /// stop conditions between ticks before committing to the blocking
580    /// [`wait`](Self::wait) resolve.
581    #[doc(hidden)]
582    pub async fn wait_stream_ended(&self, timeout: Duration) -> bool {
583        let _ = tokio::time::timeout(timeout, self.await_ended()).await;
584        self.shared.ended.load(Ordering::SeqCst)
585    }
586
587    /// Resolve once the output stream has ended (the pump set `ended`).
588    async fn await_ended(&self) {
589        loop {
590            let notified = self.shared.ended_notify.notified();
591            if self.shared.ended.load(Ordering::SeqCst) {
592                return;
593            }
594            notified.await;
595        }
596    }
597
598    /// Wait for the command to finish and return its buffered result.
599    ///
600    /// A clean Exit resolves from the local rings; a stream that ended without
601    /// one (or whose tail is truncated or short) falls back to `WaitSailboxExec`,
602    /// which reattaches to the guest session for the authoritative result,
603    /// retrying a not-ready box for the exec's configured `retry_timeout` budget.
604    pub async fn wait(&self) -> Result<ExecResult, SailError> {
605        self.await_ended().await;
606        let exit = lock(&self.shared.exit).clone();
607        let (high_out, high_err) = *lock(&self.shared.high_seq);
608        let (out_dropped, err_dropped) = {
609            let state = lock(&self.shared.state);
610            (state.stdout.dropped, state.stderr.dropped)
611        };
612
613        // The local rings are authoritative only for a clean, untruncated, fully
614        // delivered stream. Otherwise fall back to WaitSailboxExec, which
615        // reattaches to the guest.
616        let truncated = exit.as_ref().is_some_and(|exit| {
617            exit.stdout_truncated || exit.stderr_truncated || out_dropped || err_dropped
618        });
619        let incomplete = exit
620            .as_ref()
621            .is_some_and(|exit| exit.stdout_seq > high_out || exit.stderr_seq > high_err);
622
623        // Whether the live stream reached each stream's final chunk. When true,
624        // a live consumer already saw the whole stream (the buffered tail below
625        // can only repeat its ending); when false, the buffered copy is the only
626        // way to recover the missing tail.
627        let stdout_complete = exit
628            .as_ref()
629            .is_some_and(|exit| exit.stdout_seq <= high_out);
630        let stderr_complete = exit
631            .as_ref()
632            .is_some_and(|exit| exit.stderr_seq <= high_err);
633
634        if exit.is_none() || truncated || incomplete {
635            let outcome = self
636                .shared
637                .worker
638                .wait_exec(
639                    &self.shared.params.exec_endpoint,
640                    &self.shared.params.sailbox_id,
641                    &self.exec_request_id,
642                    self.shared.params.retry_timeout,
643                )
644                .await?;
645            // Record the polled terminal outcome (when the stream carried no
646            // Exit) so poll()/returncode agree with this wait().
647            {
648                let mut exit_slot = lock(&self.shared.exit);
649                if exit_slot.is_none() {
650                    *exit_slot = Some(ExitInfo {
651                        status: outcome.status,
652                        exit_code: outcome.exit_code,
653                        timed_out: outcome.timed_out,
654                        stdout_truncated: outcome.stdout_truncated,
655                        stderr_truncated: outcome.stderr_truncated,
656                        error_message: String::new(),
657                        stdout_seq: 0,
658                        stderr_seq: 0,
659                    });
660                }
661            }
662            // A real terminal Exit was already witnessed on the live stream, so
663            // a worker_lost poll is stale: the worker can be lost between the
664            // command finishing and the persisted row being read. The witnessed
665            // completion is authoritative, so return it from the local rings
666            // rather than raising worker-lost.
667            if let Some(witnessed) = exit.as_ref() {
668                if outcome.status == pb::SailboxExecStatus::WorkerLost as i32 {
669                    let state = lock(&self.shared.state);
670                    let mut stderr = state.stderr.tail();
671                    if witnessed.status == pb::SailboxExecStatus::Failed as i32
672                        && !witnessed.error_message.is_empty()
673                    {
674                        stderr = witnessed.error_message.clone();
675                    }
676                    return Ok(ExecResult {
677                        stdout: state.stdout.tail(),
678                        stderr,
679                        exit_code: witnessed.exit_code,
680                        timed_out: witnessed.timed_out,
681                        stdout_truncated: witnessed.stdout_truncated
682                            || out_dropped
683                            || witnessed.stdout_seq > high_out,
684                        stderr_truncated: witnessed.stderr_truncated
685                            || err_dropped
686                            || witnessed.stderr_seq > high_err,
687                        stdout_complete,
688                        stderr_complete,
689                    });
690                }
691            }
692            if let Some(err) = terminal_status_error(outcome.status) {
693                return Err(err);
694            }
695            return Ok(ExecResult {
696                stdout: outcome.stdout,
697                stderr: outcome.stderr,
698                exit_code: outcome.exit_code,
699                timed_out: outcome.timed_out,
700                stdout_truncated: outcome.stdout_truncated,
701                stderr_truncated: outcome.stderr_truncated,
702                stdout_complete,
703                stderr_complete,
704            });
705        }
706
707        let exit = exit.expect("exit present on the clean path");
708        if let Some(err) = terminal_status_error(exit.status) {
709            return Err(err);
710        }
711        let state = lock(&self.shared.state);
712        let mut stderr = state.stderr.tail();
713        if exit.status == pb::SailboxExecStatus::Failed as i32 && !exit.error_message.is_empty() {
714            // A failed row persists its failure text as stderr; mirror the poll path.
715            stderr = exit.error_message.clone();
716        }
717        Ok(ExecResult {
718            stdout: state.stdout.tail(),
719            stderr,
720            exit_code: exit.exit_code,
721            timed_out: exit.timed_out,
722            stdout_truncated: false,
723            stderr_truncated: false,
724            stdout_complete,
725            stderr_complete,
726        })
727    }
728
729    /// Signal the command: [`CancelSignal::Interrupt`] (SIGINT) or
730    /// [`CancelSignal::Kill`] (SIGKILL).
731    pub async fn cancel(&self, signal: CancelSignal, retry: RetryBudget) -> Result<(), SailError> {
732        self.shared
733            .worker
734            .cancel_exec(
735                &self.shared.params.exec_endpoint,
736                &self.shared.params.sailbox_id,
737                &self.exec_request_id,
738                signal.is_force(),
739                retry.as_secs_f64(),
740            )
741            .await
742    }
743
744    /// Set the pty window for a `pty` exec. Advisory and best-effort: an
745    /// unknown, finished, or not-yet-placed exec is a server no-op, and a
746    /// transient transport error is swallowed (the next resize resends).
747    pub async fn resize(&self, cols: u32, rows: u32) {
748        let message = pb::ResizeSailboxExecRequest {
749            sailbox_id: self.shared.params.sailbox_id.clone(),
750            exec_request_id: self.exec_request_id.clone(),
751            cols,
752            rows,
753        };
754        let Ok(request) =
755            self.shared
756                .worker
757                .request_for(message, &[], Some(Duration::from_secs(5)))
758        else {
759            return;
760        };
761        if let Ok(mut client) = self
762            .shared
763            .worker
764            .client_for(&self.shared.params.exec_endpoint)
765        {
766            let _ = client.resize_sailbox_exec(request).await;
767        }
768    }
769
770    /// Write to the command's stdin. Chunked with absolute offsets; an uncertain
771    /// mid-flight failure poisons the writer (a stale-offset resume could
772    /// silently drop bytes). Blocks (with backoff) while the guest buffer is full.
773    pub async fn write_stdin(&self, data: &[u8]) -> Result<(), SailError> {
774        let mut stdin = self.shared.stdin.lock().await;
775        if stdin.eof_sent {
776            return Err(SailError::BrokenPipe {
777                message: "stdin is closed".to_string(),
778            });
779        }
780        if stdin.broken {
781            return Err(SailError::BrokenPipe {
782                message: "an earlier stdin write failed".to_string(),
783            });
784        }
785        if stdin.write_in_flight {
786            // The previous write held the lock across its send and never cleared
787            // this, so its future was cancelled mid-flight: bytes may have landed
788            // and the offset is uncertain. Poison rather than resume.
789            stdin.broken = true;
790            return Err(SailError::BrokenPipe {
791                message: "an earlier stdin write was interrupted".to_string(),
792            });
793        }
794        if data.is_empty() {
795            return Ok(());
796        }
797        stdin.write_in_flight = true;
798        let result = self.send_stdin(&mut stdin, data, /* eof */ false).await;
799        // Reached only if the send was not cancelled. A clean return clears the
800        // latch; an uncertain failure already set `broken` inside send_stdin.
801        stdin.write_in_flight = false;
802        result
803    }
804
805    /// Close the command's stdin (send EOF).
806    pub async fn close_stdin(&self) -> Result<(), SailError> {
807        let mut stdin = self.shared.stdin.lock().await;
808        if stdin.eof_sent {
809            return Ok(());
810        }
811        if stdin.broken || stdin.write_in_flight {
812            // An earlier write failed or was cancelled mid-flight, so the stream
813            // is undeliverable at a known offset.
814            stdin.eof_sent = true;
815            return Ok(());
816        }
817        // The EOF write carries no data and is idempotent, so a transient
818        // failure can't corrupt the offset: send directly without poisoning, and
819        // leave eof_sent false until it lands so a later eof retries it.
820        match self.send_stdin(&mut stdin, &[], /* eof */ true).await {
821            Ok(()) => {
822                stdin.eof_sent = true;
823                Ok(())
824            }
825            // The command already exited / closed stdin: nothing to deliver.
826            Err(SailError::BrokenPipe { .. }) => {
827                stdin.eof_sent = true;
828                Ok(())
829            }
830            Err(err) => Err(err),
831        }
832    }
833
834    /// Drive the chunked WriteSailboxExecStdin loop. `eof` latches only when the
835    /// final chunk is fully accepted. Poisons `stdin.broken` on an uncertain
836    /// mid-flight failure (anything but a clean broken-pipe).
837    async fn send_stdin(
838        &self,
839        stdin: &mut StdinState,
840        payload: &[u8],
841        eof: bool,
842    ) -> Result<(), SailError> {
843        let endpoint = &self.shared.params.exec_endpoint;
844        let sailbox_id = &self.shared.params.sailbox_id;
845        let mut deadline = retry_deadline(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS);
846        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
847        let mut sent = 0usize;
848        loop {
849            let end = (sent + STDIN_WRITE_CHUNK_BYTES).min(payload.len());
850            let chunk = &payload[sent..end];
851            let last = end >= payload.len();
852            let message = pb::WriteSailboxExecStdinRequest {
853                sailbox_id: sailbox_id.clone(),
854                exec_request_id: self.exec_request_id.clone(),
855                offset: stdin.offset,
856                data: chunk.to_vec(),
857                eof: eof && last,
858            };
859            // Bound each attempt so a stalled connection (one that never
860            // returns a status) times out and the retry/poison logic below
861            // runs, instead of one await blocking the whole budget.
862            let request = match self.shared.worker.request_for(
863                message,
864                &[],
865                Some(rpc_attempt_timeout(deadline)),
866            ) {
867                Ok(request) => request,
868                Err(err) => return Err(err),
869            };
870            let result = match self.shared.worker.client_for(endpoint) {
871                Ok(mut client) => client.write_sailbox_exec_stdin(request).await,
872                Err(err) => return Err(err),
873            };
874            match result {
875                Ok(resp) => {
876                    let accepted_through = resp.into_inner().accepted_through;
877                    // Clamp to the chunk we actually sent: a server that
878                    // over-reports accepted_through must not push `sent` past the
879                    // payload (a slice panic) or advance the idempotent offset
880                    // beyond delivered bytes.
881                    let accepted =
882                        ((accepted_through - stdin.offset).max(0) as usize).min(chunk.len());
883                    stdin.offset += accepted as i64;
884                    sent += accepted;
885                    if sent >= payload.len() && (!eof || (last && accepted == chunk.len())) {
886                        return Ok(());
887                    }
888                    // A success proves the transport healthy: restart the budget.
889                    deadline = retry_deadline(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS);
890                    if accepted > 0 {
891                        delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
892                    } else {
893                        // Guest buffer full: block like a pipe write, no deadline.
894                        delay = sleep_no_deadline(delay).await;
895                    }
896                }
897                Err(status) => {
898                    if matches!(status.code(), Code::NotFound | Code::FailedPrecondition) {
899                        // The exec is over or closed its stdin: a dead pipe.
900                        return Err(SailError::BrokenPipe {
901                            message: status.message().to_string(),
902                        });
903                    }
904                    if is_exec_not_ready(&status) {
905                        // Row open but guest not reachable yet (wake/migration):
906                        // wait it out against the exec's lifetime, no deadline,
907                        // resetting the transport budget on each "; retry".
908                        delay = sleep_no_deadline(delay).await;
909                        deadline = retry_deadline(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS);
910                        continue;
911                    }
912                    if !should_retry_transient_exec_rpc(&status, deadline) {
913                        // Uncertain whether bytes landed: poison so a stale-offset
914                        // resume can't silently drop overlapping bytes.
915                        stdin.broken = true;
916                        return Err(SailError::from_exec_status(&status));
917                    }
918                    tracing::warn!(code = ?status.code(), "retrying exec stdin write");
919                    if should_invalidate_channel(&status) {
920                        self.shared.worker.channels().invalidate(endpoint);
921                    }
922                    delay = sleep_before_retry(delay, deadline).await;
923                }
924            }
925        }
926    }
927}
928
929/// A cursor over one live output stream. [`StreamReader::next`] blocks up to a
930/// timeout for the next chunk.
931pub struct StreamReader {
932    shared: Arc<ExecShared>,
933    which: OutputStream,
934    cursor: usize,
935}
936
937impl StreamReader {
938    /// Block up to `timeout` for the next step: the next retained chunk, `Eof`
939    /// once the stream is closed and fully drained, or `Pending` if nothing new
940    /// arrived in time.
941    pub fn next(&mut self, timeout: Duration) -> ReadStep {
942        let mut state = lock(&self.shared.state);
943        loop {
944            let ring = state.ring(self.which);
945            if self.cursor < ring.first_idx {
946                self.cursor = ring.first_idx;
947            }
948            let available = ring.first_idx + ring.pieces.len();
949            if self.cursor < available {
950                let piece = ring.pieces[self.cursor - ring.first_idx].clone();
951                self.cursor += 1;
952                return ReadStep::Chunk(piece);
953            }
954            if state.ended {
955                return ReadStep::Eof;
956            }
957            let (next_state, timed_out) = self
958                .shared
959                .cond
960                .wait_timeout(state, timeout)
961                .expect("exec state mutex poisoned");
962            state = next_state;
963            if timed_out.timed_out() {
964                return ReadStep::Pending;
965            }
966        }
967    }
968}
969
970/// An async cursor over one live output stream, the awaiting counterpart of
971/// [`StreamReader`]. [`AsyncStreamReader::next`] yields the next chunk without
972/// parking a runtime thread, so many streams can be read on one event loop.
973pub struct AsyncStreamReader {
974    shared: Arc<ExecShared>,
975    which: OutputStream,
976    cursor: usize,
977}
978
979impl AsyncStreamReader {
980    /// The next retained chunk, or `None` once the stream is closed and fully
981    /// drained. A reader that falls more than the buffer cap behind skips the
982    /// dropped head rather than stalling.
983    pub async fn next(&mut self) -> Option<String> {
984        loop {
985            // Arm the wakeup before inspecting the ring: `notify_waiters` only
986            // wakes already-registered waiters, so enabling first closes the gap
987            // where an append between the check and the await would be missed.
988            let notified = self.shared.data_notify.notified();
989            tokio::pin!(notified);
990            notified.as_mut().enable();
991            {
992                let state = lock(&self.shared.state);
993                let ring = state.ring(self.which);
994                if self.cursor < ring.first_idx {
995                    self.cursor = ring.first_idx;
996                }
997                let available = ring.first_idx + ring.pieces.len();
998                if self.cursor < available {
999                    let piece = ring.pieces[self.cursor - ring.first_idx].clone();
1000                    self.cursor += 1;
1001                    return Some(piece);
1002                }
1003                if state.ended {
1004                    return None;
1005                }
1006            }
1007            notified.await;
1008        }
1009    }
1010}
1011
1012/// Sleep one backoff step with no deadline (used when the guest stdin buffer is
1013/// full or the guest is not reachable yet); returns the doubled delay.
1014async fn sleep_no_deadline(delay: f64) -> f64 {
1015    let sleep_for = delay.min(crate::worker::EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
1016    tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
1017    (delay * 2.0).min(crate::worker::EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
1018}
1019
1020fn is_exec_not_ready(status: &Status) -> bool {
1021    status.code() == Code::Unavailable && status.message().contains("; retry")
1022}
1023
1024/// Map a worker-lost status (no real return code) to its error; `None` for a
1025/// normal exit (succeeded/failed/timed-out, which carry a real return code).
1026fn terminal_status_error(status: i32) -> Option<SailError> {
1027    if status == pb::SailboxExecStatus::WorkerLost as i32 {
1028        return Some(SailError::WorkerLost {
1029            message: "the machine hosting your sailbox was lost before the command \
1030                      finished; run exec again to retry"
1031                .to_string(),
1032        });
1033    }
1034    None
1035}
1036
1037/// Open the exec stream and read the `Started` frame, retrying transient
1038/// failures. Non-zero resume seqs reattach a dropped stream.
1039async fn submit(
1040    worker: &Arc<WorkerProxy>,
1041    params: &ExecParams,
1042    stdout_resume_seq: i64,
1043    stderr_resume_seq: i64,
1044) -> Result<(String, Streaming<pb::StreamSailboxExecResponse>), SailError> {
1045    let deadline = retry_deadline(params.retry_timeout);
1046    let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
1047    loop {
1048        let message = pb::StreamSailboxExecRequest {
1049            sailbox_id: params.sailbox_id.clone(),
1050            argv: params.argv.clone(),
1051            timeout_seconds: params.timeout_seconds,
1052            idempotency_key: params.idempotency_key.clone(),
1053            open_stdin: params.open_stdin,
1054            stdout_resume_seq,
1055            stderr_resume_seq,
1056            pty: params.pty,
1057            term_cols: params.cols,
1058            term_rows: params.rows,
1059            term: params.term.clone(),
1060        };
1061        let request =
1062            worker.request_for(message, &params.extra_metadata, /* timeout */ None)?;
1063        let status = match worker
1064            .client_for(&params.exec_endpoint)?
1065            .stream_sailbox_exec(request)
1066            .await
1067        {
1068            Ok(resp) => {
1069                let mut stream = resp.into_inner();
1070                match stream.message().await {
1071                    Ok(Some(first)) => match first.frame {
1072                        Some(pb::stream_sailbox_exec_response::Frame::Started(started)) => {
1073                            return Ok((started.exec_request_id, stream));
1074                        }
1075                        _ => {
1076                            return Err(SailError::Execution {
1077                                code: crate::error::GrpcCode::Internal,
1078                                detail: "exec stream opened with a non-started frame".to_string(),
1079                            });
1080                        }
1081                    },
1082                    // On a fresh launch (resume seqs 0,0) a clean end before the
1083                    // Started frame is a transport-level teardown, not a server
1084                    // verdict — the server deliberately sends Started even for
1085                    // lost sessions on that path. Nothing was confirmed and the
1086                    // relaunch reuses the idempotency key, so route it through
1087                    // the transient-retry gate like any dropped connection. On a
1088                    // mid-run reconnect the same clean end IS a verdict (the box
1089                    // parked: autoslept, lost, or re-homing) — surface it so the
1090                    // caller falls back to wait(), which wakes the box, instead
1091                    // of re-submitting against a server that will not act.
1092                    Ok(None) if stdout_resume_seq == 0 && stderr_resume_seq == 0 => {
1093                        tonic::Status::unavailable(
1094                            "exec stream ended before the server confirmed the launch",
1095                        )
1096                    }
1097                    Ok(None) => {
1098                        return Err(SailError::Execution {
1099                            code: crate::error::GrpcCode::Internal,
1100                            detail: "exec stream ended before the server confirmed the launch"
1101                                .to_string(),
1102                        });
1103                    }
1104                    Err(status) => status,
1105                }
1106            }
1107            Err(status) => status,
1108        };
1109        if !should_retry_transient_exec_rpc(&status, deadline) {
1110            return Err(SailError::from_exec_status(&status));
1111        }
1112        tracing::warn!(
1113            code = ?status.code(),
1114            stdout_resume_seq,
1115            stderr_resume_seq,
1116            "reconnecting exec stream"
1117        );
1118        if should_invalidate_channel(&status) {
1119            worker.channels().invalidate(&params.exec_endpoint);
1120        }
1121        delay = sleep_before_retry(delay, deadline).await;
1122    }
1123}
1124
1125/// Drains exec frames into the rings while tracking the per-stream high-water
1126/// seqs (the resume points sent on reconnect) and carrying the incremental UTF-8
1127/// decoders across reconnects. Split out from the transport loop so the
1128/// resume/replay/decode state machine is testable in-process without a gRPC
1129/// stream: a mid-stream break is just "keep applying frames after a gap".
1130struct Pump {
1131    shared: Arc<ExecShared>,
1132    stdout_dec: Utf8Decoder,
1133    stderr_dec: Utf8Decoder,
1134    stdout_seq: i64,
1135    stderr_seq: i64,
1136}
1137
1138impl Pump {
1139    fn new(shared: Arc<ExecShared>) -> Pump {
1140        Pump {
1141            shared,
1142            stdout_dec: Utf8Decoder::default(),
1143            stderr_dec: Utf8Decoder::default(),
1144            stdout_seq: 0,
1145            stderr_seq: 0,
1146        }
1147    }
1148
1149    /// Apply one frame. Returns `true` for a terminal Exit frame (stop draining).
1150    /// Chunk seqs only advance the high-water mark, never rewind, so a server
1151    /// that replays an already-seen tail after reconnect can't lower the resume
1152    /// point.
1153    fn apply_frame(&mut self, frame: pb::StreamSailboxExecResponse) -> bool {
1154        match frame.frame {
1155            Some(pb::stream_sailbox_exec_response::Frame::Chunk(chunk)) => {
1156                let is_stderr = chunk.stream == pb::SailboxExecStream::Stderr as i32;
1157                let (decoder, which) = if is_stderr {
1158                    self.stderr_seq = self.stderr_seq.max(chunk.seq);
1159                    (&mut self.stderr_dec, OutputStream::Stderr)
1160                } else {
1161                    self.stdout_seq = self.stdout_seq.max(chunk.seq);
1162                    (&mut self.stdout_dec, OutputStream::Stdout)
1163                };
1164                let text = decoder.decode(&chunk.data);
1165                if !text.is_empty() {
1166                    let mut state = lock(&self.shared.state);
1167                    match which {
1168                        OutputStream::Stdout => state.stdout.append(text),
1169                        OutputStream::Stderr => state.stderr.append(text),
1170                    }
1171                    self.shared.cond.notify_all();
1172                    self.shared.data_notify.notify_waiters();
1173                }
1174                false
1175            }
1176            Some(pb::stream_sailbox_exec_response::Frame::Exit(exit)) => {
1177                *lock(&self.shared.exit) = Some(ExitInfo {
1178                    status: exit.status,
1179                    exit_code: exit.return_code,
1180                    timed_out: exit.timed_out,
1181                    stdout_truncated: exit.stdout_truncated,
1182                    stderr_truncated: exit.stderr_truncated,
1183                    error_message: exit.error_message,
1184                    stdout_seq: exit.stdout_seq,
1185                    stderr_seq: exit.stderr_seq,
1186                });
1187                true
1188            }
1189            _ => false,
1190        }
1191    }
1192
1193    /// Flush the decoders, publish the high-water seqs, close the rings, and wake
1194    /// every reader and waiter. Consumes the pump: nothing follows finalize.
1195    fn finalize(mut self) {
1196        let tail_out = self.stdout_dec.flush();
1197        let tail_err = self.stderr_dec.flush();
1198        {
1199            let mut state = lock(&self.shared.state);
1200            if !tail_out.is_empty() {
1201                state.stdout.append(tail_out);
1202            }
1203            if !tail_err.is_empty() {
1204                state.stderr.append(tail_err);
1205            }
1206            state.ended = true;
1207            self.shared.cond.notify_all();
1208            self.shared.data_notify.notify_waiters();
1209        }
1210        *lock(&self.shared.high_seq) = (self.stdout_seq, self.stderr_seq);
1211        self.shared.ended.store(true, Ordering::SeqCst);
1212        self.shared.ended_notify.notify_waiters();
1213    }
1214}
1215
1216/// Drain the output stream into the rings, reconnecting on a transient break,
1217/// until the Exit frame or an unrecoverable end. Always finalizes the rings.
1218async fn pump(shared: Arc<ExecShared>, mut stream: Streaming<pb::StreamSailboxExecResponse>) {
1219    let mut state = Pump::new(shared.clone());
1220    loop {
1221        if shared.closing.load(Ordering::SeqCst) {
1222            break;
1223        }
1224        let message = tokio::select! {
1225            biased;
1226            () = shared.close_notify.notified() => break,
1227            message = stream.message() => message,
1228        };
1229        match message {
1230            Ok(Some(frame)) => {
1231                if state.apply_frame(frame) {
1232                    break;
1233                }
1234            }
1235            // The stream ended cleanly without an Exit; leave it to wait()'s poll.
1236            Ok(None) => break,
1237            Err(_status) => {
1238                if shared.closing.load(Ordering::SeqCst) {
1239                    break;
1240                }
1241                // Reconnect from the last seq we saw, so the guest replays only
1242                // the unseen tail.
1243                match submit(
1244                    &shared.worker,
1245                    &shared.params,
1246                    state.stdout_seq,
1247                    state.stderr_seq,
1248                )
1249                .await
1250                {
1251                    Ok((_id, fresh)) => {
1252                        stream = fresh;
1253                        continue;
1254                    }
1255                    Err(_) => break,
1256                }
1257            }
1258        }
1259    }
1260    state.finalize();
1261}
1262
1263/// Fuzz entry point: drive an arbitrary byte stream through the incremental
1264/// UTF-8 decoder (split at data-derived boundaries) and the drop-oldest ring,
1265/// asserting the engine's invariants. Any violation panics, which libfuzzer
1266/// flags as a crash. Compiled only under `cfg(test)` or the `fuzzing` feature,
1267/// so it is never part of a production build.
1268#[cfg(any(test, feature = "fuzzing"))]
1269pub fn fuzz_decode_ring(data: &[u8]) {
1270    // Decoding the whole input at once.
1271    let mut whole = Utf8Decoder::default();
1272    let mut whole_out = whole.decode(data);
1273    whole_out.push_str(&whole.flush());
1274
1275    // Decoding the same bytes split at boundaries derived from the data itself
1276    // (cut after every odd byte), to exercise mid-char breaks.
1277    let mut chunked = Utf8Decoder::default();
1278    let mut chunked_out = String::new();
1279    let mut start = 0;
1280    for (i, byte) in data.iter().enumerate() {
1281        if byte & 1 == 1 {
1282            chunked_out.push_str(&chunked.decode(&data[start..=i]));
1283            start = i + 1;
1284        }
1285    }
1286    chunked_out.push_str(&chunked.decode(&data[start..]));
1287    chunked_out.push_str(&chunked.flush());
1288
1289    // The decoder is chunk-boundary invariant and agrees with a lossy decode.
1290    assert_eq!(
1291        chunked_out, whole_out,
1292        "decoder is not chunk-boundary invariant"
1293    );
1294    assert_eq!(
1295        whole_out,
1296        String::from_utf8_lossy(data),
1297        "decoder disagrees with a lossy decode of the whole input"
1298    );
1299
1300    // Push the decoded fuzz output through the ring, then overrun the cap by a
1301    // hair so the drop-oldest clip lands inside the fuzz-derived front piece:
1302    // the ring must stay within the cap and never split a char (no panic), and
1303    // its retained bytes must remain a suffix of everything appended.
1304    let mut ring = Ring::default();
1305    ring.append(whole_out.clone());
1306    let filler = STREAM_BUFFER_CAP_BYTES + 1 - whole_out.len().min(STREAM_BUFFER_CAP_BYTES);
1307    let filler = "a".repeat(filler);
1308    ring.append(filler.clone());
1309    assert!(
1310        ring.size <= STREAM_BUFFER_CAP_BYTES,
1311        "ring exceeded its cap"
1312    );
1313    let full = format!("{whole_out}{filler}");
1314    let tail = ring.tail();
1315    assert!(
1316        full.as_bytes().ends_with(tail.as_bytes()),
1317        "ring tail is not a suffix of the appended bytes"
1318    );
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324
1325    #[test]
1326    fn shell_argv_wraps_cwd_and_background() {
1327        let plain = shell_argv("echo hi", &ExecOptions::default()).unwrap();
1328        assert_eq!(plain, ["/bin/sh", "-lc", "echo hi"]);
1329
1330        let cwd = shell_argv(
1331            "echo hi",
1332            &ExecOptions {
1333                cwd: Some("/app".to_string()),
1334                ..ExecOptions::default()
1335            },
1336        )
1337        .unwrap();
1338        assert_eq!(cwd[2], "cd '/app' && exec /bin/sh -lc 'echo hi'");
1339
1340        let background = shell_argv(
1341            "echo hi",
1342            &ExecOptions {
1343                cwd: Some("/app".to_string()),
1344                background: true,
1345                ..ExecOptions::default()
1346            },
1347        )
1348        .unwrap();
1349        assert_eq!(
1350            background[2],
1351            "nohup /bin/sh -lc 'cd '\\''/app'\\'' && exec /bin/sh -lc '\\''echo hi'\\''' </dev/null >/dev/null 2>&1 &"
1352        );
1353    }
1354
1355    #[test]
1356    fn shell_argv_rejects_invalid_combinations() {
1357        assert!(shell_argv("", &ExecOptions::default()).is_err());
1358        assert!(shell_argv(
1359            "x",
1360            &ExecOptions {
1361                cwd: Some("   ".to_string()),
1362                ..ExecOptions::default()
1363            }
1364        )
1365        .is_err());
1366        for (open_stdin, pty) in [(true, false), (false, true)] {
1367            assert!(shell_argv(
1368                "x",
1369                &ExecOptions {
1370                    background: true,
1371                    open_stdin,
1372                    pty,
1373                    ..ExecOptions::default()
1374                }
1375            )
1376            .is_err());
1377        }
1378    }
1379
1380    #[test]
1381    fn fuzz_decode_ring_holds_on_samples() {
1382        // Verifies the fuzz entry point itself: hand-picked inputs covering valid
1383        // multibyte chars, split sequences, and invalid bytes.
1384        for sample in [
1385            &b""[..],
1386            b"hello",
1387            b"\xff\xfe\xfd",
1388            "€ µ é".as_bytes(),
1389            b"ab\xc3\xa9cd",
1390            &[0xC3, 0x28],
1391        ] {
1392            fuzz_decode_ring(sample);
1393        }
1394    }
1395
1396    #[test]
1397    fn ring_drops_oldest_past_cap_and_latches() {
1398        let mut ring = Ring::default();
1399        ring.append("a".repeat(STREAM_BUFFER_CAP_BYTES));
1400        assert!(!ring.dropped);
1401        ring.append("bbbb".to_string());
1402        assert!(ring.dropped);
1403        assert_eq!(ring.tail().len(), STREAM_BUFFER_CAP_BYTES);
1404        assert!(ring.tail().ends_with("bbbb"));
1405    }
1406
1407    #[test]
1408    fn ring_clip_on_split_multibyte_char_terminates() {
1409        // Overflow of 1 lands inside the leading 2-byte 'é'. The clip must drop
1410        // the split char and shrink the buffer; expanding it via lossy
1411        // replacement could keep size above the cap and spin the trim loop while
1412        // holding the state mutex. Reaching the asserts at all proves it ends.
1413        let mut ring = Ring::default();
1414        ring.append(format!("é{}", "a".repeat(STREAM_BUFFER_CAP_BYTES - 1)));
1415        assert!(ring.size <= STREAM_BUFFER_CAP_BYTES);
1416        assert!(ring.dropped);
1417        let tail = ring.tail();
1418        assert!(!tail.contains('é'));
1419        assert!(!tail.contains('\u{FFFD}'));
1420    }
1421
1422    #[test]
1423    fn decoder_carries_split_multibyte_char() {
1424        let mut dec = Utf8Decoder::default();
1425        let euro = "€".as_bytes(); // 3 bytes
1426        assert_eq!(dec.decode(&euro[..2]), "");
1427        assert_eq!(dec.decode(&euro[2..]), "€");
1428    }
1429
1430    #[test]
1431    fn decoder_replaces_invalid_bytes() {
1432        let mut dec = Utf8Decoder::default();
1433        assert_eq!(dec.decode(&[0xff, b'x']), "\u{FFFD}x");
1434    }
1435
1436    #[test]
1437    fn terminal_status_mapping() {
1438        assert!(matches!(
1439            terminal_status_error(pb::SailboxExecStatus::WorkerLost as i32),
1440            Some(SailError::WorkerLost { .. })
1441        ));
1442        assert!(terminal_status_error(pb::SailboxExecStatus::Succeeded as i32).is_none());
1443        // Retired wire values (canceled=9, interrupted_retryable=6, interrupted_unsafe_to_retry=7,
1444        // reserved in the proto) carry no error: an old backend that still emits one falls through
1445        // to a normal result rather than raising.
1446        for retired in [9, 6, 7] {
1447            assert!(terminal_status_error(retired).is_none());
1448        }
1449    }
1450
1451    fn test_shared() -> Arc<ExecShared> {
1452        Arc::new(ExecShared {
1453            worker: Arc::new(WorkerProxy::new("test-key").unwrap()),
1454            params: ExecParams {
1455                sailbox_id: "sb".into(),
1456                exec_endpoint: "endpoint".into(),
1457                argv: vec!["echo".into()],
1458                timeout_seconds: 0,
1459                idempotency_key: "idem".into(),
1460                open_stdin: false,
1461                pty: false,
1462                term: String::new(),
1463                cols: 0,
1464                rows: 0,
1465                retry_timeout: 0.0,
1466                extra_metadata: vec![],
1467            },
1468            state: Mutex::new(State::default()),
1469            cond: Condvar::new(),
1470            data_notify: Notify::new(),
1471            exit: Mutex::new(None),
1472            high_seq: Mutex::new((0, 0)),
1473            stdin: AsyncMutex::new(StdinState::default()),
1474            ended: AtomicBool::new(false),
1475            ended_notify: Notify::new(),
1476            closing: AtomicBool::new(false),
1477            close_notify: Notify::new(),
1478        })
1479    }
1480
1481    fn chunk(which: OutputStream, seq: i64, data: &[u8]) -> pb::StreamSailboxExecResponse {
1482        let stream = match which {
1483            OutputStream::Stdout => pb::SailboxExecStream::Stdout,
1484            OutputStream::Stderr => pb::SailboxExecStream::Stderr,
1485        };
1486        pb::StreamSailboxExecResponse {
1487            frame: Some(pb::stream_sailbox_exec_response::Frame::Chunk(
1488                pb::SailboxExecChunk {
1489                    stream: stream as i32,
1490                    data: data.to_vec(),
1491                    seq,
1492                },
1493            )),
1494        }
1495    }
1496
1497    fn exit_frame(status: pb::SailboxExecStatus, stdout_seq: i64) -> pb::StreamSailboxExecResponse {
1498        pb::StreamSailboxExecResponse {
1499            frame: Some(pb::stream_sailbox_exec_response::Frame::Exit(
1500                pb::SailboxExecExit {
1501                    status: status as i32,
1502                    return_code: 0,
1503                    timed_out: false,
1504                    stdout_truncated: false,
1505                    stderr_truncated: false,
1506                    error_message: String::new(),
1507                    stdout_seq,
1508                    stderr_seq: 0,
1509                },
1510            )),
1511        }
1512    }
1513
1514    /// The resume state machine: a multibyte char split across a mid-stream break
1515    /// decodes correctly (the decoder persists across reconnect), the high-water
1516    /// seq only advances (so a replayed tail can't lower the resume point), and
1517    /// finalize publishes the seqs `wait()` checks for completeness.
1518    #[tokio::test]
1519    async fn exec_resume_carries_utf8_and_tracks_seq_across_break() {
1520        let shared = test_shared();
1521        let mut pump = Pump::new(shared.clone());
1522
1523        // Pre-break: seq 1 ends mid-'é' (0xC3 0xA9), delivering only the lead byte.
1524        assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 1, b"ab\xc3")));
1525        assert_eq!(shared.state.lock().unwrap().stdout.tail(), "ab");
1526        // The reconnect would call submit(.., stdout_resume_seq = 1, ..).
1527        assert_eq!(pump.stdout_seq, 1);
1528
1529        // The socket breaks; the guest replays only seq > 1. Seq 2 supplies the
1530        // rest of 'é' plus more, and the persisted decoder completes the char.
1531        assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 2, b"\xa9cd")));
1532        assert_eq!(shared.state.lock().unwrap().stdout.tail(), "abécd");
1533        assert_eq!(pump.stdout_seq, 2);
1534
1535        // An out-of-order/replayed lower seq must not rewind the resume point.
1536        assert!(!pump.apply_frame(chunk(OutputStream::Stdout, 1, b"!")));
1537        assert_eq!(pump.stdout_seq, 2);
1538
1539        assert!(pump.apply_frame(exit_frame(pb::SailboxExecStatus::Succeeded, 2)));
1540        pump.finalize();
1541        assert_eq!(*shared.high_seq.lock().unwrap(), (2, 0));
1542        assert!(shared.ended.load(Ordering::SeqCst));
1543    }
1544
1545    /// A reader started before any output replays the retained tail in order, then
1546    /// follows live chunks (including ones that arrive after a reconnect gap), and
1547    /// stops at Eof once the pump finalizes.
1548    #[tokio::test]
1549    async fn exec_reader_follows_replayed_then_live() {
1550        let shared = test_shared();
1551        let mut reader = StreamReader {
1552            shared: shared.clone(),
1553            which: OutputStream::Stdout,
1554            cursor: 0,
1555        };
1556        let collector = tokio::task::spawn_blocking(move || {
1557            let mut out = String::new();
1558            loop {
1559                match reader.next(Duration::from_millis(50)) {
1560                    ReadStep::Chunk(piece) => out.push_str(&piece),
1561                    ReadStep::Eof => return out,
1562                    ReadStep::Pending => {}
1563                }
1564            }
1565        });
1566
1567        let mut pump = Pump::new(shared.clone());
1568        pump.apply_frame(chunk(OutputStream::Stdout, 1, b"hello "));
1569        tokio::time::sleep(Duration::from_millis(10)).await;
1570        // A reconnect gap, then the live tail resumes.
1571        pump.apply_frame(chunk(OutputStream::Stdout, 2, b"world"));
1572        tokio::time::sleep(Duration::from_millis(10)).await;
1573        pump.apply_frame(exit_frame(pb::SailboxExecStatus::Succeeded, 2));
1574        pump.finalize();
1575
1576        assert_eq!(collector.await.unwrap(), "hello world");
1577    }
1578
1579    /// A reader created after output is already buffered still replays the
1580    /// retained tail from the start (cursor 0), then sees Eof.
1581    #[test]
1582    fn exec_reader_started_late_replays_retained_tail() {
1583        let shared = test_shared();
1584        let mut pump = Pump::new(shared.clone());
1585        pump.apply_frame(chunk(OutputStream::Stdout, 1, b"early "));
1586        pump.apply_frame(chunk(OutputStream::Stdout, 2, b"output"));
1587        pump.finalize();
1588
1589        // Construct the reader only now, against an already-populated, ended ring.
1590        let mut reader = shared_reader(&shared, OutputStream::Stdout);
1591        let mut out = String::new();
1592        loop {
1593            match reader.next(Duration::from_millis(50)) {
1594                ReadStep::Chunk(piece) => out.push_str(&piece),
1595                ReadStep::Eof => break,
1596                ReadStep::Pending => panic!("ended ring should not return Pending"),
1597            }
1598        }
1599        assert_eq!(out, "early output");
1600    }
1601
1602    fn shared_reader(shared: &Arc<ExecShared>, which: OutputStream) -> StreamReader {
1603        StreamReader {
1604            shared: shared.clone(),
1605            which,
1606            cursor: 0,
1607        }
1608    }
1609
1610    use proptest::prelude::*;
1611
1612    /// Bytes that stress the UTF-8 decoder: random bytes (mostly invalid
1613    /// sequences) interleaved with the bytes of real Unicode strings (valid
1614    /// multibyte chars). Split points across these exercise mid-char breaks.
1615    fn utf8ish_bytes() -> impl Strategy<Value = Vec<u8>> {
1616        prop_oneof![
1617            proptest::collection::vec(any::<u8>(), 0..256),
1618            any::<String>().prop_map(String::into_bytes),
1619        ]
1620    }
1621
1622    proptest! {
1623        /// Under the cap the ring is lossless: it keeps every byte in order,
1624        /// reports no drop, and its accounting matches the input exactly.
1625        #[test]
1626        fn ring_without_overflow_is_lossless(
1627            pieces in proptest::collection::vec(any::<String>(), 0..32)
1628        ) {
1629            let concat: String = pieces.concat();
1630            prop_assume!(concat.len() <= STREAM_BUFFER_CAP_BYTES);
1631            let mut ring = Ring::default();
1632            for piece in &pieces {
1633                ring.append(piece.clone());
1634            }
1635            prop_assert!(!ring.dropped);
1636            prop_assert_eq!(ring.size, concat.len());
1637            prop_assert_eq!(ring.tail(), concat);
1638        }
1639    }
1640
1641    proptest! {
1642        // Each case allocates ~1 MiB, so keep the case count modest.
1643        #![proptest_config(ProptestConfig::with_cases(48))]
1644
1645        /// Once total output exceeds the cap, the ring drops oldest bytes: it
1646        /// stays within the cap, never splits a multibyte char into a U+FFFD
1647        /// artifact, and its retained bytes are always a suffix of everything
1648        /// appended (drops only ever come off the front).
1649        #[test]
1650        fn ring_eviction_keeps_valid_suffix_under_cap(
1651            overflow in 1usize..8192,
1652            tail_pieces in proptest::collection::vec("[a-z]{0,64}", 0..8),
1653        ) {
1654            // A head of 2-byte 'é' that overruns the cap, so the clip path lands
1655            // inside a multibyte char (cap is even; 2 * count = cap + 2*overflow).
1656            let head = "é".repeat(STREAM_BUFFER_CAP_BYTES / 2 + overflow);
1657            let mut full = head.clone();
1658            let mut ring = Ring::default();
1659            ring.append(head);
1660            for piece in &tail_pieces {
1661                ring.append(piece.clone());
1662                full.push_str(piece);
1663            }
1664            prop_assert!(ring.dropped);
1665            prop_assert!(ring.size <= STREAM_BUFFER_CAP_BYTES);
1666            let tail = ring.tail();
1667            // Clipping advances to a char boundary and slices the valid str, so
1668            // it never manufactures a replacement char from a split lead byte.
1669            let has_replacement_char = tail.contains('\u{FFFD}');
1670            prop_assert!(!has_replacement_char);
1671            prop_assert!(full.as_bytes().ends_with(tail.as_bytes()));
1672        }
1673    }
1674
1675    proptest! {
1676        /// The incremental decoder is chunk-boundary invariant: decoding a byte
1677        /// stream split at arbitrary points yields the same string as decoding
1678        /// it whole, and that string equals a lossy decode of the full input.
1679        #[test]
1680        fn decoder_is_chunk_boundary_invariant(
1681            data in utf8ish_bytes(),
1682            cuts in proptest::collection::vec(any::<prop::sample::Index>(), 0..16),
1683        ) {
1684            let mut whole = Utf8Decoder::default();
1685            let mut whole_out = whole.decode(&data);
1686            whole_out.push_str(&whole.flush());
1687
1688            let mut bounds: Vec<usize> = cuts.iter().map(|c| c.index(data.len() + 1)).collect();
1689            bounds.sort_unstable();
1690            let mut dec = Utf8Decoder::default();
1691            let mut chunked_out = String::new();
1692            let mut prev = 0;
1693            for bound in bounds.into_iter().chain(std::iter::once(data.len())) {
1694                let bound = bound.clamp(prev, data.len());
1695                chunked_out.push_str(&dec.decode(&data[prev..bound]));
1696                prev = bound;
1697            }
1698            chunked_out.push_str(&dec.flush());
1699
1700            prop_assert_eq!(&chunked_out, &whole_out);
1701            prop_assert_eq!(whole_out, String::from_utf8_lossy(&data).into_owned());
1702        }
1703    }
1704
1705    proptest! {
1706        /// The per-stream high-water seq is the running max of the seqs seen on
1707        /// that stream and only ever advances, regardless of frame order, so a
1708        /// replayed or out-of-order tail after a reconnect can't lower the
1709        /// resume point, and the two streams are tracked independently.
1710        #[test]
1711        fn pump_seq_is_monotonic_running_max_per_stream(
1712            frames in proptest::collection::vec(
1713                (any::<bool>(), 0i64..10_000, proptest::collection::vec(any::<u8>(), 0..8)),
1714                0..64,
1715            )
1716        ) {
1717            let mut pump = Pump::new(test_shared());
1718            let (mut expect_out, mut expect_err) = (0i64, 0i64);
1719            let (mut prev_out, mut prev_err) = (0i64, 0i64);
1720            for (is_stderr, seq, data) in frames {
1721                let which = if is_stderr { OutputStream::Stderr } else { OutputStream::Stdout };
1722                pump.apply_frame(chunk(which, seq, &data));
1723                if is_stderr {
1724                    expect_err = expect_err.max(seq);
1725                } else {
1726                    expect_out = expect_out.max(seq);
1727                }
1728                prop_assert_eq!(pump.stdout_seq, expect_out);
1729                prop_assert_eq!(pump.stderr_seq, expect_err);
1730                prop_assert!(pump.stdout_seq >= prev_out);
1731                prop_assert!(pump.stderr_seq >= prev_err);
1732                prev_out = pump.stdout_seq;
1733                prev_err = pump.stderr_seq;
1734            }
1735        }
1736    }
1737}