Skip to main content

microvms_app/session/
exec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The handle a caller holds for one running exec.
3//!
4//! The whole reason this module exists is [`ExecHandle::stream`]. Everything else here
5//! is a thin wrapper over one route.
6//!
7//! # The cursor is the contract
8//!
9//! A reconnect passes `?offset=<bytes consumed>`, and the daemon replays from there.
10//! Without it a dropped connection means either losing everything after the drop or
11//! replaying from zero, and neither is distinguishable from correct behaviour with
12//! nothing to check against — E2B's cursorless `connect(pid)` and their issue #1352 are
13//! exactly that.
14//!
15//! Two rules the cursor arithmetic has to obey, and both are properties of *what the
16//! caller has seen* rather than of what arrived:
17//!
18//! * Advance only past bytes actually handed to the caller, so a reconnect never
19//!   re-delivers and never skips.
20//! * Advance past a `gap` too. The daemon has already moved its own cursor past evicted
21//!   bytes; if ours did not follow, a reconnect would ask for them again and be told
22//!   about the same gap forever.
23//!
24//! # The reconnect condition is a typed event, not a byte count
25//!
26//! A stream that ended *without* an `exit` event was cut. A stream that ended *with* one
27//! is over. Those two are the identical byte sequence on a raw stream, which is the
28//! whole argument for SSE framing here and why this is not a byte stream. The
29//! reconnect decision reads that distinction and nothing else.
30
31use std::sync::Arc;
32use std::time::Duration;
33
34use base64::Engine as _;
35use futures_util::Stream;
36
37use super::sse::{ExecEvent, SseParser, decode};
38use super::{HttpRequest, Transport};
39use crate::error::{Error, ErrorKind, WireKind};
40
41/// Backoff between reconnect attempts. Short, because the offset makes a reconnect
42/// cheap — the daemon replays from the cursor, so nothing already delivered is refetched.
43const RECONNECT_BACKOFF: [Duration; 5] = [
44    Duration::from_millis(250),
45    Duration::from_millis(500),
46    Duration::from_secs(1),
47    Duration::from_secs(2),
48    Duration::from_secs(4),
49];
50
51/// How long an attached stream may be silent before it is treated as dead.
52///
53/// Four times the daemon's fifteen-second SSE keepalive, so three missed keepalives are
54/// tolerated before a reconnect. Tighter than that turns a slow proxy into a reconnect
55/// loop.
56pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
57
58/// The deadline the bindings pass to [`ExecHandle::wait`], [`ExecHandle::wait_and_ack`] and a
59/// one-shot [`Session::run_sync`](super::Session::run_sync) when their caller names none: five
60/// minutes, the Python client's figure. Those methods take a deadline, so core itself never
61/// reads this; it's here so both bindings default to one figure.
62pub const DEFAULT_EXEC_WAIT: Duration = Duration::from_secs(300);
63
64/// How often to re-poll while waiting for an exec to finish.
65const POLL_INTERVAL: Duration = Duration::from_secs(1);
66
67/// An exec's phase and, once it has one, its outcome.
68///
69/// A thin wrapper over the daemon's [`protocol::exec::PollResponse`] rather than a
70/// re-modelling of it, so the two cannot disagree. What it adds is [`ExecResult::done`],
71/// which is the question every caller actually asks.
72#[derive(Debug)]
73pub struct ExecResult {
74    pub exec_id: String,
75    pub phase: protocol::exec::Phase,
76    /// `None` while running. Present once the child has exited.
77    pub outcome: Option<protocol::exec::Outcome>,
78    /// What the client did when its own deadline expired, for a result
79    /// [`super::complete::CompletionPlan::drive`] returned after one (BIND-9, BIND-10).
80    /// `None` for every other result: a poll, a wait, an ack, and a run that finished in
81    /// time.
82    pub client_deadline: Option<super::complete::ClientDeadline>,
83}
84
85impl From<protocol::exec::PollResponse> for ExecResult {
86    fn from(response: protocol::exec::PollResponse) -> Self {
87        Self {
88            exec_id: response.exec_id,
89            phase: response.phase,
90            outcome: response.result,
91            client_deadline: None,
92        }
93    }
94}
95
96impl ExecResult {
97    /// Whether the exec has finished, whichever way.
98    pub fn done(&self) -> bool {
99        matches!(
100            self.phase,
101            protocol::exec::Phase::Exited | protocol::exec::Phase::Acked
102        )
103    }
104
105    /// The exit code, when the child exited with one rather than dying to a signal.
106    pub fn exit_code(&self) -> Option<i32> {
107        self.outcome.as_ref().and_then(|outcome| outcome.exit_code)
108    }
109
110    /// Whether the command succeeded. `false` for a signal death and for a still-running
111    /// exec, since neither is a success.
112    pub fn succeeded(&self) -> bool {
113        self.outcome
114            .as_ref()
115            .is_some_and(|outcome| outcome.exit_code == Some(0) && !outcome.timed_out)
116    }
117
118    pub fn stdout(&self) -> &str {
119        self.outcome
120            .as_ref()
121            .map_or("", |outcome| outcome.stdout.as_str())
122    }
123
124    pub fn stderr(&self) -> &str {
125        self.outcome
126            .as_ref()
127            .map_or("", |outcome| outcome.stderr.as_str())
128    }
129
130    /// The exit code a POSIX shell would report for this exec (BIND-6).
131    ///
132    /// * **124** when a deadline ended the command: the daemon's own (`timed_out`), the
133    ///   client's kill of a live process group after its deadline, or a result synthesized
134    ///   because nothing came back after that kill. 124 is GNU `timeout(1)`'s code, which is
135    ///   what harnesses built on `docker exec` and friends already report.
136    /// * **128 + signal** for any other signal death, the shell's convention: an
137    ///   out-of-memory kill (SIGKILL, 137) is not a timeout.
138    /// * **The exit code** otherwise.
139    ///
140    /// `timed_out` wins over an exit code on purpose. A child that traps SIGTERM and exits 0
141    /// after the deadline fired has an exit code, and reporting it would call a timed-out
142    /// command a success while [`Self::succeeded`] says it failed. `crates/model/src/run.rs` checks
143    /// this rule and finds that case against the "exit code first" alternative.
144    ///
145    /// `None` only when there is nothing to report: a running exec with no client deadline.
146    pub fn posix_exit_code(&self) -> Option<i32> {
147        if self.deadline_ended() {
148            return Some(TIMED_OUT_EXIT_CODE);
149        }
150        let outcome = self.outcome.as_ref()?;
151        match (outcome.exit_code, outcome.signal) {
152            (Some(code), _) => Some(code),
153            (None, Some(signal)) => Some(128 + signal),
154            (None, None) => None,
155        }
156    }
157
158    /// Whether a deadline ended the command, which is when [`Self::posix_exit_code`] is 124
159    /// (BIND-6): the daemon's own (`timed_out`), the client's kill of a live process group
160    /// after its deadline, or a result synthesized because nothing came back after that kill.
161    ///
162    /// A kill that found the group already gone doesn't count: the command ended by itself,
163    /// and its own exit status is the answer.
164    pub fn deadline_ended(&self) -> bool {
165        let client = self.client_deadline.as_ref().is_some_and(|deadline| {
166            deadline.ack_error.is_some() || deadline.kill == super::complete::KillAnswer::Signalled
167        });
168        client
169            || self
170                .outcome
171                .as_ref()
172                .is_some_and(|outcome| outcome.timed_out)
173    }
174
175    /// Human-readable annotations on how to read this result, one per condition (BIND-7).
176    ///
177    /// For a caller that appends them to stderr without knowing the field names: truncated
178    /// output, an expired daemon deadline, writers left alive past the linger deadline, and
179    /// the client deadline (a kill, or a synthesized result). Empty for a clean result.
180    pub fn notes(&self) -> Vec<String> {
181        let mut notes = Vec::new();
182        if let Some(outcome) = &self.outcome {
183            if outcome.truncated {
184                notes.push(
185                    "output truncated: a stream reached the daemon's output cap \
186                     (AGENTD_MAX_OUTPUT_BYTES, 8 MiB by default) and the rest was dropped"
187                        .to_string(),
188                );
189            }
190            if outcome.timed_out {
191                notes.push(
192                    "timed out: the daemon's execution deadline (timeout_sec) expired and the \
193                     process group was sent SIGTERM, then SIGKILL"
194                        .to_string(),
195                );
196            }
197            if outcome.writers_may_be_alive {
198                notes.push(
199                    "a process the command started kept its output pipes open past the \
200                     daemon's linger deadline; anything it wrote afterwards was not captured"
201                        .to_string(),
202                );
203            }
204        }
205        if let Some(deadline) = &self.client_deadline {
206            let after = seconds(deadline.after);
207            let kill = match &deadline.kill {
208                super::complete::KillAnswer::Signalled => "its process group was killed".into(),
209                super::complete::KillAnswer::AlreadyGone => {
210                    "its process group had already exited".into()
211                }
212                super::complete::KillAnswer::Failed(error) => format!("the kill failed ({error})"),
213            };
214            notes.push(match &deadline.ack_error {
215                None => format!(
216                    "the client deadline of {after} expired before the exec finished; {kill}, \
217                     and the result was collected afterwards"
218                ),
219                Some(error) => format!(
220                    "the client deadline of {after} expired; {kill}, and the final ack failed \
221                     ({error}), so this result is synthesized: exit code 124, output unknown"
222                ),
223            });
224        }
225        notes
226    }
227
228    /// Whether this result was synthesized after the client deadline (BIND-10).
229    pub fn synthesized(&self) -> bool {
230        self.client_deadline
231            .as_ref()
232            .is_some_and(|deadline| deadline.ack_error.is_some())
233    }
234}
235
236/// The exit code GNU `timeout(1)` reports for a command its deadline ended, and the one
237/// [`ExecResult::posix_exit_code`] reports for a deadline (BIND-6).
238pub const TIMED_OUT_EXIT_CODE: i32 = 124;
239
240/// A duration as a note spells it: whole seconds as `90s`, anything else to a tenth.
241fn seconds(duration: Duration) -> String {
242    if duration.subsec_nanos() == 0 {
243        format!("{}s", duration.as_secs())
244    } else {
245        format!("{:.1}s", duration.as_secs_f64())
246    }
247}
248
249/// What a stdin write accomplished.
250pub type StdinAck = protocol::exec::StdinResponse;
251
252/// Why a stream stopped, and where a resume would pick up.
253///
254/// Returned by [`ExecHandle::for_each_event`] and
255/// [`ExecHandle::for_each_event_async`] so the three endings are distinguishable
256/// *without* a caller having to reason about which event it last saw. That distinction is
257/// the whole point of this module — a body that ended without an `exit` event was cut, and
258/// the byte sequence alone cannot say so — and asking every caller to re-derive it from a
259/// tally is how one of them gets it wrong and reports a truncated stream as a clean pass.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub struct StreamEnd {
262    pub reason: EndReason,
263    /// The offset a resume would pass as [`StreamOptions::offset`].
264    ///
265    /// **Core's cursor, not a count of what the callback saw.** The two rules in this
266    /// module's docs (advance only past delivered bytes, and advance past a `gap`) are
267    /// obeyed by the state machine below; handing the number back means a caller resuming
268    /// does not maintain a second cursor that disagrees with this one exactly when a
269    /// reconnect happened.
270    pub cursor: u64,
271}
272
273/// The three ways a stream ends without an error.
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub enum EndReason {
276    /// The terminal `exit` event arrived and was delivered. The command is over.
277    Exited,
278    /// The callback answered [`ControlFlow::Break`]. Nothing is wrong; the caller stopped
279    /// reading, and [`StreamEnd::cursor`] is where it stopped.
280    Stopped,
281    /// The body ended with **no** `exit` event and `reconnect` was off, so the connection
282    /// was cut and this client was told not to re-attach. The command's outcome is
283    /// unknown — not zero — and a caller reporting success here would pass a CI step on
284    /// evidence it never received.
285    Cut,
286}
287
288/// How a stream should behave. See [`ExecHandle::stream_with`].
289#[derive(Clone, Debug)]
290pub struct StreamOptions {
291    /// The byte to start at. Non-zero resumes a stream a previous process was reading.
292    pub offset: u64,
293    /// Whether to reconnect after a cut. `false` ends the stream at the cut instead,
294    /// which is what a caller doing its own reconnection wants.
295    pub reconnect: bool,
296    /// How many reconnects before giving up. A bound rather than forever, because a
297    /// stream that drops every time is a condition a caller needs reported.
298    pub max_reconnects: u32,
299    /// Turns a `gap` into an error instead of an event. What a caller that must have
300    /// complete output wants.
301    pub error_on_gap: bool,
302    /// How long the body may be silent before the connection is treated as dead.
303    pub idle_timeout: Duration,
304}
305
306impl Default for StreamOptions {
307    fn default() -> Self {
308        Self {
309            offset: 0,
310            reconnect: true,
311            max_reconnects: 20,
312            error_on_gap: false,
313            idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
314        }
315    }
316}
317
318/// A fresh exec id for the wall time `unix`, since the epoch: `x-` plus 16 hex characters,
319/// the Python client's shape.
320///
321/// The id needs to be distinct rather than unguessable — it is an idempotency key, not a
322/// credential, and the daemon rejects an unknown one — so the nanosecond clock mixed with a
323/// counter is enough. Code holding a [`crate::session::Session`] mints through the session's
324/// clock, so a test that pins the clock pins the id. `microvms_core::session::mint_exec_id` is
325/// this on the production clock.
326///
327/// Reads no clock: the time is the caller's. A process-wide counter still separates two ids
328/// minted for the same instant.
329pub fn exec_id_at(unix: Duration) -> String {
330    use std::sync::atomic::{AtomicU64, Ordering};
331    static SEQUENCE: AtomicU64 = AtomicU64::new(0);
332    exec_id_from(
333        unix.as_nanos() as u64,
334        SEQUENCE.fetch_add(1, Ordering::Relaxed),
335    )
336}
337
338/// The id's shape, from its two inputs.
339fn exec_id_from(nanos: u64, sequence: u64) -> String {
340    // The counter folds into the clock's high bits so two ids minted in the same
341    // nanosecond still differ.
342    format!("x-{:016x}", nanos ^ (sequence << 40))
343}
344
345/// One exec, addressed by its caller-minted id.
346///
347/// The id is the idempotency key, so a handle survives a process restart: rebuild it
348/// with the same id and every method still addresses the same server-side exec.
349pub struct ExecHandle {
350    transport: Arc<Transport>,
351    exec_id: String,
352}
353
354impl ExecHandle {
355    pub(crate) fn new(transport: Arc<Transport>, exec_id: String) -> Self {
356        Self { transport, exec_id }
357    }
358
359    pub fn exec_id(&self) -> &str {
360        &self.exec_id
361    }
362
363    /// Reads current status and output. Read-only server-side; safe to spin on.
364    pub async fn poll(&self) -> Result<ExecResult, Error> {
365        let response: protocol::exec::PollResponse = self
366            .transport
367            .send_json(HttpRequest::new(
368                "GET",
369                format!("/v1/exec/{}", self.exec_id),
370            ))
371            .await?;
372        Ok(response.into())
373    }
374
375    /// Polls until the exec is done, or fails with a client-side timeout.
376    ///
377    /// A timeout here has not touched the exec — polling is read-only and the output
378    /// lives until it is acked — so a caller that gives up can come back and poll again,
379    /// and the message says so.
380    ///
381    /// A retryable error mid-wait is swallowed rather than raised: a VM under load drops
382    /// a connection occasionally, and the whole point of a read-only poll is that
383    /// repeating it costs nothing. A fatal one ends the wait.
384    pub async fn wait(&self, timeout: Duration) -> Result<ExecResult, Error> {
385        let deadline = super::deadline_after(timeout);
386        let mut last_phase: Option<protocol::exec::Phase> = None;
387        loop {
388            match self.poll().await {
389                Ok(result) => {
390                    if result.done() {
391                        return Ok(result);
392                    }
393                    last_phase = Some(result.phase);
394                }
395                Err(err) if err.retryable() => {}
396                Err(err) => return Err(err),
397            }
398            let now = tokio::time::Instant::now();
399            if now >= deadline {
400                let phase = match last_phase {
401                    Some(protocol::exec::Phase::Running) => "running",
402                    Some(protocol::exec::Phase::Exited) => "exited",
403                    Some(protocol::exec::Phase::Acked) => "acked",
404                    None => "unknown",
405                };
406                return Err(Error::wire(
407                    WireKind::ExecTimeout,
408                    format!(
409                        "exec {} was still {phase} after {}s; the record and its output \
410                         are untouched and can be re-polled",
411                        self.exec_id,
412                        timeout.as_secs()
413                    ),
414                ));
415            }
416            tokio::time::sleep(POLL_INTERVAL.min(deadline - now)).await;
417        }
418    }
419
420    /// Yields output as it arrives, reconnecting at the last good offset.
421    pub fn stream(&self) -> impl Stream<Item = Result<ExecEvent, Error>> + '_ {
422        self.stream_with(StreamOptions::default())
423    }
424
425    /// [`Self::stream`] with the knobs. See [`StreamOptions`].
426    ///
427    /// Written as a generator over an explicit state machine rather than as a hand-rolled
428    /// `Stream` impl: the reconnect logic is a loop with an `await` in the middle of it,
429    /// and expressing that as a `poll_next` would mean storing the in-flight attach as a
430    /// pinned field — which is where a self-referential-future bug lives.
431    pub fn stream_with(
432        &self,
433        options: StreamOptions,
434    ) -> impl Stream<Item = Result<ExecEvent, Error>> + '_ {
435        futures_util::stream::unfold(
436            StreamState::Reconnect {
437                cursor: options.offset,
438                attempts: 0,
439            },
440            move |state| {
441                let options = options.clone();
442                async move { self.advance(state, &options).await }
443            },
444        )
445    }
446
447    /// Drives a stream to its end, handing every event to `on_event`.
448    ///
449    /// The same state machine [`Self::stream_with`] runs, driven by an ordinary loop
450    /// instead of wrapped in a [`Stream`]. Everything about the wire behaviour is
451    /// identical — same reconnects, same cursor, same terminal-event rule — and both share
452    /// [`Self::advance`], so there is one implementation of the property and two ways to
453    /// consume it.
454    ///
455    /// # Why a callback exists beside the `Stream`
456    ///
457    /// Because `Stream` is not in `std`. A consumer of [`Self::stream_with`] has to name
458    /// the crate that defines the trait in order to call `poll_next`, so a client that
459    /// wants to print a line per event acquires a dependency to advance a loop —
460    /// `microvms-cli` carried `futures-util` for exactly that and nothing else, and
461    /// `tests/thinness.rs` (CLI-2) asserts that crate's dependency set exactly. This
462    /// method is that dependency's replacement: `ControlFlow` and `async fn` are both std,
463    /// so a caller needs nothing but this crate.
464    ///
465    /// A **synchronous** callback is the right shape for a caller whose per-event work is
466    /// synchronous — the CLI writes a line and returns. A caller whose per-event work is an
467    /// `await` (both bindings: a bounded-channel `send`) wants
468    /// [`Self::for_each_event_async`], which is the same loop with an awaited callback.
469    /// Using this one there would mean `blocking_send` on a runtime worker, which is why
470    /// the overload exists.
471    ///
472    /// # What the return value says that a tally cannot
473    ///
474    /// [`StreamEnd::reason`] distinguishes an `exit` event from a cut body from a caller
475    /// that stopped reading. A caller counting events cannot: a cut stream and a finished
476    /// one differ only in the terminal event, which is the argument for SSE framing here in
477    /// the first place. [`StreamEnd::cursor`] is the state machine's own offset, so a
478    /// caller resuming does not maintain a second one.
479    ///
480    /// An error ends the drive with `Err` and the events already handed over **stay handed
481    /// over**. That asymmetry is deliberate: the bytes a callback already wrote are real
482    /// output the caller received, and there is nothing to unwind them with.
483    pub async fn for_each_event<F>(
484        &self,
485        options: StreamOptions,
486        mut on_event: F,
487    ) -> Result<StreamEnd, Error>
488    where
489        F: FnMut(ExecEvent) -> std::ops::ControlFlow<()>,
490    {
491        // Delegated rather than a second copy of the loop. A synchronous callback *is* an
492        // async one whose future is already complete, and `std::future::ready` says exactly
493        // that — so the ordering, the cursor read, the `Break` arm, and the three endings
494        // have one implementation instead of two that agree until one is edited.
495        self.for_each_event_async(options, |event| std::future::ready(on_event(event)))
496            .await
497    }
498
499    /// [`Self::for_each_event`] for a callback that **awaits**.
500    ///
501    /// The same loop and the same [`Self::advance`] state machine; the only difference is
502    /// that the per-event callback answers a future, which is `.await`ed before the next
503    /// step is taken. So the events are still delivered strictly in order and still one at a
504    /// time — a callback that awaits between events holds the drive rather than racing it,
505    /// which is what makes this usable as backpressure.
506    ///
507    /// # Why the overload exists, and what it closed
508    ///
509    /// Both bindings consume a stream by pushing into a **capacity-1** `mpsc` channel that a
510    /// foreign-language iterator drains (`bindings/microvms-py/src/exec.rs`, `bindings/microvms-js/src/exec.rs`).
511    /// The channel bound is deliberate — the daemon's SSE body is the backpressure signal —
512    /// and it means the channel is full whenever the host-language consumer is even slightly
513    /// behind, i.e. normally. With [`Self::for_each_event`] the only available send is
514    /// `blocking_send`, which would block the very runtime worker the driver is running on;
515    /// so both bindings kept the `Stream` path, and `futures-util` with it, for a reason
516    /// about backpressure rather than about the trait. `send(..).await` inside an
517    /// `AsyncFnMut` yields the worker instead, and both bindings dropped the dependency on
518    /// 2026-08-09.
519    ///
520    /// # `FnMut(ExecEvent) -> Fut` rather than `AsyncFnMut`
521    ///
522    /// Measured, not stylistic, and the measurement is the reason the signature looks older
523    /// than the edition. `AsyncFnMut` is the obvious spelling and it compiles here — but a
524    /// caller cannot `tokio::spawn` a drive that uses it. Proving the returned future `Send`
525    /// requires naming `F::CallRefFuture<'a>` under a `for<'a>` bound, which is the unstable
526    /// `async_fn_traits` feature; without it the spawn fails with *"`Send` would have to be
527    /// implemented for the type `&ExecHandle`… but `Send` is actually implemented for
528    /// `&'0 ExecHandle`, for some specific lifetime"*. Both bindings spawn this drive onto a
529    /// runtime, so that is not a corner they can avoid, and the unstable bound additionally
530    /// forces the callback's captures to `'static`.
531    ///
532    /// So `Fut` is a plain type parameter. The cost is that the callback's future cannot
533    /// *borrow* from the closure's captures — it has no lifetime to name them with — so a
534    /// caller sending on a channel clones the sender per event rather than holding `&sender`.
535    /// For a `tokio::sync::mpsc::Sender` that is one atomic increment, which is the right
536    /// price for a signature that can cross a spawn.
537    ///
538    /// The return value and the error asymmetry are [`Self::for_each_event`]'s, unchanged.
539    pub async fn for_each_event_async<F, Fut>(
540        &self,
541        options: StreamOptions,
542        mut on_event: F,
543    ) -> Result<StreamEnd, Error>
544    where
545        F: FnMut(ExecEvent) -> Fut,
546        Fut: std::future::Future<Output = std::ops::ControlFlow<()>>,
547    {
548        let mut state = StreamState::Reconnect {
549            cursor: options.offset,
550            attempts: 0,
551        };
552        // Seeded from the caller's own starting offset, so a drive that ends before any
553        // event arrives reports where it began rather than zero.
554        let mut cursor = options.offset;
555        loop {
556            let Some((item, next)) = self.advance(state, &options).await else {
557                // `advance` yields nothing only when the body ended without an `exit`
558                // event and reconnecting was refused — the `exit` case returns below,
559                // before the machine is stepped again. So this is the cut.
560                return Ok(StreamEnd {
561                    reason: EndReason::Cut,
562                    cursor,
563                });
564            };
565            // Read off the machine rather than recomputed from the event: the two rules in
566            // this module's docs live in `advance`, and a second `cursor.max(end)` here
567            // would be a second implementation that agrees until a `gap` arrives.
568            if let Some(advanced) = next.cursor() {
569                cursor = advanced;
570            }
571            state = next;
572
573            let event = item?;
574            let terminal = matches!(event, ExecEvent::Exit(_));
575            // Awaited before the loop advances, which is the whole point: the next attach
576            // read does not start until the callback's own work is done, so a slow consumer
577            // slows the stream rather than buffering behind it.
578            if on_event(event).await.is_break() {
579                return Ok(StreamEnd {
580                    reason: EndReason::Stopped,
581                    cursor,
582                });
583            }
584            if terminal {
585                return Ok(StreamEnd {
586                    reason: EndReason::Exited,
587                    cursor,
588                });
589            }
590        }
591    }
592
593    /// One step of the stream state machine.
594    ///
595    /// Returns `None` to end the stream, or the next item plus the state after it.
596    async fn advance(
597        &self,
598        state: StreamState,
599        options: &StreamOptions,
600    ) -> Option<(Result<ExecEvent, Error>, StreamState)> {
601        let mut state = state;
602        loop {
603            match state {
604                StreamState::Done => return None,
605                StreamState::Reconnect { cursor, attempts } => {
606                    if attempts > 0 {
607                        if !options.reconnect {
608                            return None;
609                        }
610                        if attempts > options.max_reconnects {
611                            return Some((
612                                Err(Error::new(
613                                    ErrorKind::Retryable,
614                                    format!(
615                                        "the stream of {} dropped {attempts} times without \
616                                         an exit event; last good offset {cursor}",
617                                        self.exec_id
618                                    ),
619                                )),
620                                StreamState::Done,
621                            ));
622                        }
623                        let backoff = RECONNECT_BACKOFF
624                            [((attempts - 1) as usize).min(RECONNECT_BACKOFF.len() - 1)];
625                        tokio::time::sleep(backoff).await;
626                    }
627                    match self.attach(cursor, options.idle_timeout).await {
628                        Ok(attach) => {
629                            state = StreamState::Attached {
630                                attach: Box::new(attach),
631                                cursor,
632                                attempts,
633                            };
634                        }
635                        // A cut connection or a failed mint. Neither says anything
636                        // about the exec, which is still running server-side, so both
637                        // are reconnected through rather than surfaced.
638                        Err(err) if err.retryable() && options.reconnect => {
639                            state = StreamState::Reconnect {
640                                cursor,
641                                attempts: attempts + 1,
642                            };
643                        }
644                        // Anything fatal — a 404 on a collected entry above all, where
645                        // reconnecting can never succeed — ends the stream with the
646                        // error rather than looping on it.
647                        Err(err) => return Some((Err(err), StreamState::Done)),
648                    }
649                }
650                StreamState::Attached {
651                    mut attach,
652                    mut cursor,
653                    attempts,
654                } => match attach.next_event().await {
655                    Ok(Some(ExecEvent::Output {
656                        stream,
657                        offset,
658                        data,
659                    })) => {
660                        // Advance only past bytes actually handed over.
661                        let end = offset + data.len() as u64;
662                        cursor = cursor.max(end);
663                        return Some((
664                            Ok(ExecEvent::Output {
665                                stream,
666                                offset,
667                                data,
668                            }),
669                            StreamState::Attached {
670                                attach,
671                                cursor,
672                                attempts,
673                            },
674                        ));
675                    }
676                    Ok(Some(ExecEvent::Gap { from, to })) => {
677                        // Follow the daemon past the evicted range, or a reconnect asks
678                        // for it again and is told about the same gap forever.
679                        cursor = cursor.max(to);
680                        let item = if options.error_on_gap {
681                            Err(Error::wire(
682                                WireKind::OutputGap,
683                                format!("output bytes [{from}, {to}) are unrecoverable"),
684                            ))
685                        } else {
686                            Ok(ExecEvent::Gap { from, to })
687                        };
688                        let next = if options.error_on_gap {
689                            StreamState::Done
690                        } else {
691                            StreamState::Attached {
692                                attach,
693                                cursor,
694                                attempts,
695                            }
696                        };
697                        return Some((item, next));
698                    }
699                    // The terminal event. The stream is over, and this is the only thing
700                    // that says so.
701                    Ok(Some(ExecEvent::Exit(exit))) => {
702                        return Some((Ok(ExecEvent::Exit(exit)), StreamState::Done));
703                    }
704                    // The body ended with no exit event, so the connection was cut.
705                    Ok(None) => {
706                        if !options.reconnect {
707                            return None;
708                        }
709                        state = StreamState::Reconnect {
710                            cursor,
711                            attempts: attempts + 1,
712                        };
713                    }
714                    Err(err) if err.retryable() && options.reconnect => {
715                        state = StreamState::Reconnect {
716                            cursor,
717                            attempts: attempts + 1,
718                        };
719                    }
720                    Err(err) => return Some((Err(err), StreamState::Done)),
721                },
722            }
723        }
724    }
725
726    /// Opens one attach at `offset`.
727    async fn attach(&self, offset: u64, idle_timeout: Duration) -> Result<Attach, Error> {
728        let path = format!("/v1/exec/{}/stream?offset={offset}", self.exec_id);
729        let mut request = HttpRequest::new("GET", path.clone());
730        request
731            .headers
732            .push(("accept".into(), "text/event-stream".into()));
733        // Headers are built here rather than in `Transport::request`, because the
734        // streaming path does not go through it — and the mint has to happen anyway,
735        // which is what makes a mid-stream reconnect re-mint an expired token.
736        request.headers.extend(self.transport.headers(None).await?);
737        request.timeout = None;
738
739        let (head, chunks) = self
740            .transport
741            .backend
742            .open_stream(request, idle_timeout)
743            .await?;
744        // The status is checked before any body byte, so a 404 on an unknown exec id
745        // surfaces as NotFound rather than as an empty stream.
746        head.error_for_status("GET", &path)?;
747        Ok(Attach {
748            chunks,
749            parser: SseParser::new(),
750            frames: std::collections::VecDeque::new(),
751        })
752    }
753
754    /// Writes to the child's stdin.
755    ///
756    /// Requires the exec to have been started with `stdin: true`, or the daemon answers
757    /// 409. `eof` in the same call is the common case for feeding a prompt: two round
758    /// trips would leave a window where the child has the bytes but not the EOF that
759    /// tells it the input is complete.
760    pub async fn write_stdin(&self, data: &[u8], eof: bool) -> Result<StdinAck, Error> {
761        let body = protocol::exec::StdinRequest {
762            data_b64: (!data.is_empty())
763                .then(|| base64::engine::general_purpose::STANDARD.encode(data)),
764            signal: eof.then(|| "eof".to_string()),
765        };
766        let mut request = HttpRequest::new("POST", format!("/v1/exec/{}/stdin", self.exec_id));
767        request
768            .headers
769            .push(("content-type".into(), "application/json".into()));
770        request.body = serde_json::to_vec(&body).map_err(|err| {
771            Error::invalid_arg(format!("the stdin body will not serialize: {err}"))
772        })?;
773        self.transport.send_json(request).await
774    }
775
776    /// Sends EOF.
777    ///
778    /// Nothing else closes stdin: the daemon's copy of the pipe outlives `Child::wait()`,
779    /// so a child blocked reading stdin hangs until its timeout unless someone calls
780    /// this.
781    pub async fn close_stdin(&self) -> Result<StdinAck, Error> {
782        self.write_stdin(&[], true).await
783    }
784
785    /// Releases the buffered output and starts the TTL clock.
786    ///
787    /// 409 means either the exec has not exited — output is still being written — or an
788    /// earlier ack already took it. Both are real states, not the same state, and the
789    /// daemon's detail string distinguishes them.
790    pub async fn ack(&self) -> Result<ExecResult, Error> {
791        let response: protocol::exec::PollResponse = self
792            .transport
793            .send_json(HttpRequest::new(
794                "POST",
795                format!("/v1/exec/{}/ack", self.exec_id),
796            ))
797            .await?;
798        Ok(response.into())
799    }
800
801    /// Signals the whole process group, not just the direct child.
802    ///
803    /// Returns whether anything was signalled. `false` means no pgid was ever captured,
804    /// i.e. the child had already been reaped — which is the outcome a kill wanted, and
805    /// is why it is a 200 rather than an error.
806    pub async fn kill(&self) -> Result<bool, Error> {
807        let response: protocol::exec::KillResponse = self
808            .transport
809            .send_json(HttpRequest::new(
810                "POST",
811                format!("/v1/exec/{}/kill", self.exec_id),
812            ))
813            .await?;
814        Ok(response.killed)
815    }
816
817    /// Waits, then acks, returning the result that carries the output.
818    ///
819    /// Which result is returned matters: the ack response carries the released output,
820    /// and a poll issued after the ack reports `acked` with no output at all. Returning
821    /// the wrong one is a silent empty-output bug, so the sequencing lives here rather
822    /// than at every call site.
823    pub async fn wait_and_ack(&self, timeout: Duration) -> Result<ExecResult, Error> {
824        let done = self.wait(timeout).await?;
825        if done.phase == protocol::exec::Phase::Acked {
826            return Ok(done);
827        }
828        self.ack().await
829    }
830}
831
832impl std::fmt::Debug for ExecHandle {
833    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
834        f.debug_struct("ExecHandle")
835            .field("exec_id", &self.exec_id)
836            .finish()
837    }
838}
839
840/// One live attach: a chunk source plus the parser holding its partial frame.
841struct Attach {
842    chunks: Box<dyn super::ChunkSource>,
843    parser: SseParser,
844    frames: std::collections::VecDeque<super::Frame>,
845}
846
847impl Attach {
848    /// The next event this attach can produce, or `None` when its body ends.
849    ///
850    /// A frame that decodes to nothing is skipped rather than returned, so an unknown
851    /// event name does not surface as a spurious end-of-stream.
852    async fn next_event(&mut self) -> Result<Option<ExecEvent>, Error> {
853        loop {
854            while let Some(frame) = self.frames.pop_front() {
855                if let Some(event) = decode(&frame)? {
856                    return Ok(Some(event));
857                }
858            }
859            let Some(chunk) = self.chunks.next_chunk().await? else {
860                return Ok(None);
861            };
862            // A parse failure here is the undelimited-data ceiling, which is
863            // `ErrorKind::Protocol` and therefore not retryable — so `advance` ends the
864            // stream with it rather than reconnecting into the same non-SSE body. That is
865            // the point: a proxy answering an error page would otherwise be retried
866            // `max_reconnects` times, filling the buffer again each time.
867            self.frames.extend(self.parser.feed(&chunk)?);
868        }
869    }
870}
871
872/// Where the stream is. Boxed attach because the enum otherwise carries a trait object
873/// inline and every state pays its size.
874enum StreamState {
875    /// Needs an attach. `attempts` is zero for the first one, which is why the backoff
876    /// and the max-reconnect check are both skipped there.
877    Reconnect {
878        cursor: u64,
879        attempts: u32,
880    },
881    Attached {
882        attach: Box<Attach>,
883        cursor: u64,
884        attempts: u32,
885    },
886    Done,
887}
888
889impl StreamState {
890    /// The cursor this state carries, or `None` for [`StreamState::Done`].
891    ///
892    /// `Done` has none on purpose: it is reached from three different places and inventing
893    /// one there would mean picking a number, where the caller
894    /// ([`ExecHandle::for_each_event`]) already holds the last real one. `None` says "this
895    /// state advanced nothing" rather than "the cursor is zero".
896    fn cursor(&self) -> Option<u64> {
897        match self {
898            StreamState::Reconnect { cursor, .. } | StreamState::Attached { cursor, .. } => {
899                Some(*cursor)
900            }
901            StreamState::Done => None,
902        }
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    /// The id is `x-` and sixteen hex characters of the time XOR the counter's high bits.
911    #[test]
912    fn an_exec_id_is_the_time_with_the_counter_in_its_high_bits() {
913        assert_eq!(exec_id_from(0x1234, 0), "x-0000000000001234");
914        assert_eq!(exec_id_from(0x1234, 1), "x-0000010000001234");
915    }
916
917    /// Two ids for the same instant still differ, which is what the counter is for.
918    #[test]
919    fn two_ids_for_one_instant_differ() {
920        let instant = Duration::from_secs(1_767_225_600);
921        assert_ne!(exec_id_at(instant), exec_id_at(instant));
922    }
923    use crate::session::testing::{Recorder, Reply, session_with};
924    use futures_util::StreamExt as _;
925
926    /// One SSE `output` frame on the wire.
927    fn output(offset: u64, bytes: &[u8]) -> Vec<u8> {
928        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
929        format!(
930            "event: output\ndata: {{\"offset\":{offset},\"stream\":\"stdout\",\
931             \"output\":\"{encoded}\"}}\n\n"
932        )
933        .into_bytes()
934    }
935
936    fn exit(total: u64) -> Vec<u8> {
937        format!(
938            "event: exit\ndata: {{\"exit_code\":0,\"signal\":null,\"truncated\":false,\
939             \"writers_may_be_alive\":false,\"offset\":{total}}}\n\n"
940        )
941        .into_bytes()
942    }
943
944    fn gap(from: u64, to: u64) -> Vec<u8> {
945        format!("event: gap\ndata: {{\"from\":{from},\"to\":{to}}}\n\n").into_bytes()
946    }
947
948    /// Collects a stream's bytes and its events.
949    async fn collect(
950        handle: &ExecHandle,
951        options: StreamOptions,
952    ) -> (Vec<u8>, Vec<Result<ExecEvent, Error>>) {
953        let mut bytes = Vec::new();
954        let mut events = Vec::new();
955        let mut stream = std::pin::pin!(handle.stream_with(options));
956        while let Some(item) = stream.next().await {
957            if let Ok(ExecEvent::Output { data, .. }) = &item {
958                bytes.extend_from_slice(data);
959            }
960            events.push(item);
961        }
962        (bytes, events)
963    }
964
965    /// The offset a stream request asked for.
966    fn requested_offset(request: &HttpRequest) -> u64 {
967        request
968            .path
969            .split("offset=")
970            .nth(1)
971            .expect("a stream request always carries an offset")
972            .parse()
973            .expect("the offset is a number")
974    }
975
976    /// The reconnect property, asserted on the reassembled byte sequence.
977    ///
978    /// The verdict is the bytes, not the absence of an error: a client that reconnected
979    /// at zero would deliver every byte too, and only the seam shows the difference. So
980    /// this checks the join *and* the offset the second attach asked for.
981    #[tokio::test(start_paused = true)]
982    async fn a_cut_mid_stream_reconnects_at_the_cursor_losing_and_duplicating_nothing() {
983        let recorder = Recorder::with([
984            // First attach: two output frames, then the body ends with no exit event.
985            Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
986            // Second attach: the daemon replays from the requested offset.
987            Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
988        ]);
989        let (session, _, _) = session_with(Arc::clone(&recorder));
990        let handle = session.exec("resume");
991
992        let (bytes, events) = collect(&handle, StreamOptions::default()).await;
993
994        assert_eq!(
995            String::from_utf8_lossy(&bytes),
996            "AAAA\nBBBB\nCCCC\n",
997            "the two attaches did not reconstruct the output, so the resume \
998             duplicated or lost bytes at the seam"
999        );
1000        assert!(
1001            events.iter().all(Result::is_ok),
1002            "a cut surfaced as an error rather than a reconnect: {events:#?}"
1003        );
1004        assert!(
1005            matches!(events.last(), Some(Ok(ExecEvent::Exit(_)))),
1006            "the stream did not end on the terminal event: {events:#?}"
1007        );
1008
1009        let seen = recorder.requests();
1010        assert_eq!(
1011            seen.len(),
1012            2,
1013            "the cut did not produce exactly one reconnect"
1014        );
1015        assert_eq!(requested_offset(&seen[0]), 0);
1016        assert_eq!(
1017            requested_offset(&seen[1]),
1018            10,
1019            "the reconnect asked for the wrong byte, so the seam is wrong"
1020        );
1021    }
1022
1023    /// A stream that ends *with* an exit event does not reconnect.
1024    ///
1025    /// The mirror of the test above, and the reason the transport is framed: the two
1026    /// cases are the same byte count and differ only in the terminal event.
1027    #[tokio::test(start_paused = true)]
1028    async fn a_stream_that_ended_with_an_exit_event_does_not_reconnect() {
1029        let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"hi\n"), exit(3)])]);
1030        let (session, _, _) = session_with(Arc::clone(&recorder));
1031        let handle = session.exec("finished");
1032
1033        let (bytes, _) = collect(&handle, StreamOptions::default()).await;
1034        assert_eq!(bytes, b"hi\n");
1035        assert_eq!(
1036            recorder.requests().len(),
1037            1,
1038            "a finished stream was reattached, which would replay a completed exec \
1039             forever"
1040        );
1041    }
1042
1043    /// A gap advances the cursor past the evicted range.
1044    ///
1045    /// Without this the reconnect asks for bytes the daemon has already dropped and is
1046    /// told about the same gap forever — a livelock that looks like a slow stream.
1047    #[tokio::test(start_paused = true)]
1048    async fn a_gap_advances_the_cursor_so_a_reconnect_does_not_ask_for_evicted_bytes() {
1049        let recorder = Recorder::with([
1050            Reply::Chunks(200, vec![output(0, b"AA"), gap(2, 900)]),
1051            Reply::Chunks(200, vec![output(900, b"ZZ"), exit(902)]),
1052        ]);
1053        let (session, _, _) = session_with(Arc::clone(&recorder));
1054        let handle = session.exec("lagged");
1055
1056        let (bytes, events) = collect(&handle, StreamOptions::default()).await;
1057        assert_eq!(bytes, b"AAZZ");
1058        assert!(
1059            events
1060                .iter()
1061                .any(|event| matches!(event, Ok(ExecEvent::Gap { from: 2, to: 900 }))),
1062            "the gap was swallowed, so a truncated log reads as a complete one: \
1063             {events:#?}"
1064        );
1065        assert_eq!(
1066            requested_offset(&recorder.requests()[1]),
1067            900,
1068            "the reconnect asked for bytes the daemon had already evicted"
1069        );
1070    }
1071
1072    /// `error_on_gap` turns a gap into a typed error and ends the stream.
1073    #[tokio::test(start_paused = true)]
1074    async fn error_on_gap_surfaces_the_lost_range_as_an_error() {
1075        let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"AA"), gap(2, 900)])]);
1076        let (session, _, _) = session_with(recorder);
1077        let handle = session.exec("lagged");
1078
1079        let (_, events) = collect(
1080            &handle,
1081            StreamOptions {
1082                error_on_gap: true,
1083                ..StreamOptions::default()
1084            },
1085        )
1086        .await;
1087        let err = events
1088            .last()
1089            .expect("an event")
1090            .as_ref()
1091            .expect_err("the gap is an error");
1092        assert_eq!(err.wire_kind(), Some(WireKind::OutputGap));
1093        assert!(err.to_string().contains("[2, 900)"), "{err}");
1094    }
1095
1096    /// A transport failure opening the attach is reconnected through, and the mint that
1097    /// the reconnect performs is what carries a fresh token mid-stream (TRAP-9).
1098    #[tokio::test(start_paused = true)]
1099    async fn a_failed_attach_is_retried_and_the_reconnect_re_mints_an_expired_token() {
1100        let recorder = Recorder::with([
1101            Reply::Cut("connection reset"),
1102            Reply::Chunks(200, vec![output(0, b"ok\n"), exit(3)]),
1103        ]);
1104        let (session, auth, clock) = session_with(Arc::clone(&recorder));
1105        let handle = session.exec("retried");
1106
1107        // Age the cached token past the refresh window before the stream starts, so the
1108        // attach has to mint. The reconnect then crosses the window again.
1109        clock.advance(super::super::DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1110
1111        let (bytes, events) = collect(&handle, StreamOptions::default()).await;
1112        assert_eq!(bytes, b"ok\n");
1113        assert!(events.iter().all(Result::is_ok), "{events:#?}");
1114        assert_eq!(
1115            auth.mint_count(),
1116            1,
1117            "the attach did not mint inside the request path"
1118        );
1119        assert_eq!(recorder.requests().len(), 2);
1120    }
1121
1122    /// A 404 is fatal even with reconnect on: the entry was collected, so reattaching
1123    /// can never succeed.
1124    #[tokio::test(start_paused = true)]
1125    async fn a_collected_exec_ends_the_stream_rather_than_reconnecting_forever() {
1126        let recorder = Recorder::with([Reply::Body(404, b"{\"error\":\"unknown_exec\"}".to_vec())]);
1127        let (session, _, _) = session_with(Arc::clone(&recorder));
1128        let handle = session.exec("gone");
1129
1130        let (_, events) = collect(&handle, StreamOptions::default()).await;
1131        assert_eq!(events.len(), 1);
1132        let err = events[0].as_ref().expect_err("404 is an error");
1133        assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
1134        assert_eq!(
1135            recorder.requests().len(),
1136            1,
1137            "a fatal status was reattached, which can never succeed"
1138        );
1139    }
1140
1141    /// The reconnect budget is bounded, and running out is reported with the last good
1142    /// offset rather than as a silent end.
1143    #[tokio::test(start_paused = true)]
1144    async fn a_stream_that_never_completes_gives_up_naming_its_last_good_offset() {
1145        let mut replies = vec![Reply::Chunks(200, vec![output(0, b"AA")])];
1146        for _ in 0..3 {
1147            replies.push(Reply::Chunks(200, vec![]));
1148        }
1149        let recorder = Recorder::with(replies);
1150        let (session, _, _) = session_with(Arc::clone(&recorder));
1151        let handle = session.exec("flaky");
1152
1153        let (bytes, events) = collect(
1154            &handle,
1155            StreamOptions {
1156                max_reconnects: 2,
1157                ..StreamOptions::default()
1158            },
1159        )
1160        .await;
1161        assert_eq!(bytes, b"AA");
1162        let err = events
1163            .last()
1164            .expect("an event")
1165            .as_ref()
1166            .expect_err("running out of reconnects is an error");
1167        assert!(err.retryable(), "the exec is still alive server-side");
1168        assert!(
1169            err.to_string().contains("last good offset 2"),
1170            "the give-up must name where to resume: {err}"
1171        );
1172        assert_eq!(recorder.requests().len(), 3, "the budget was not respected");
1173    }
1174
1175    /// `reconnect: false` ends the stream at the cut, for a caller doing its own.
1176    #[tokio::test(start_paused = true)]
1177    async fn reconnect_off_ends_the_stream_at_the_cut() {
1178        let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"AA")])]);
1179        let (session, _, _) = session_with(Arc::clone(&recorder));
1180        let handle = session.exec("once");
1181
1182        let (bytes, events) = collect(
1183            &handle,
1184            StreamOptions {
1185                reconnect: false,
1186                ..StreamOptions::default()
1187            },
1188        )
1189        .await;
1190        assert_eq!(bytes, b"AA");
1191        assert!(events.iter().all(Result::is_ok));
1192        assert_eq!(recorder.requests().len(), 1);
1193    }
1194
1195    /// A non-zero starting offset is what a second process resuming a stream passes.
1196    #[tokio::test(start_paused = true)]
1197    async fn a_stream_can_start_at_an_offset_a_previous_process_left_off_at() {
1198        let recorder = Recorder::with([Reply::Chunks(200, vec![output(64, b"tail"), exit(68)])]);
1199        let (session, _, _) = session_with(Arc::clone(&recorder));
1200        let handle = session.exec("resumed");
1201
1202        let (bytes, _) = collect(
1203            &handle,
1204            StreamOptions {
1205                offset: 64,
1206                ..StreamOptions::default()
1207            },
1208        )
1209        .await;
1210        assert_eq!(bytes, b"tail");
1211        assert_eq!(requested_offset(&recorder.last()), 64);
1212    }
1213
1214    /// `wait_and_ack` returns the ack's result, which is the one carrying the output.
1215    ///
1216    /// Returning the poll's instead is a silent empty-output bug: a poll after the ack
1217    /// reports `acked` with nothing in it.
1218    #[tokio::test(start_paused = true)]
1219    async fn wait_and_ack_returns_the_ack_result_that_carries_the_output() {
1220        let recorder = Recorder::with([
1221            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
1222            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"exited"})),
1223            Reply::ok(serde_json::json!({
1224                "exec_id":"e1","phase":"exited","exit_code":0,"signal":null,
1225                "stdout":"the output","stderr":"","truncated":false,
1226                "writers_may_be_alive":false
1227            })),
1228        ]);
1229        let (session, _, _) = session_with(Arc::clone(&recorder));
1230        let handle = session.exec("e1");
1231
1232        let result = handle
1233            .wait_and_ack(Duration::from_secs(30))
1234            .await
1235            .expect("waits then acks");
1236        assert_eq!(result.stdout(), "the output");
1237        assert!(result.succeeded());
1238
1239        let seen = recorder.requests();
1240        assert_eq!(seen.len(), 3);
1241        assert_eq!(seen[2].path, "/v1/exec/e1/ack");
1242        assert_eq!(seen[2].method, "POST");
1243    }
1244
1245    /// An already-acked exec is not acked twice: the second ack is a 409, and the phase
1246    /// is what tells the difference before the request goes out.
1247    #[tokio::test(start_paused = true)]
1248    async fn wait_and_ack_does_not_ack_an_already_acked_exec() {
1249        let recorder = Recorder::with([Reply::ok(serde_json::json!({
1250            "exec_id":"e1","phase":"acked","exit_code":0,"signal":null,
1251            "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
1252        }))]);
1253        let (session, _, _) = session_with(Arc::clone(&recorder));
1254
1255        session
1256            .exec("e1")
1257            .wait_and_ack(Duration::from_secs(30))
1258            .await
1259            .expect("an acked exec is done");
1260        assert_eq!(
1261            recorder.requests().len(),
1262            1,
1263            "a second ack went out, which the daemon answers 409"
1264        );
1265    }
1266
1267    /// A retryable failure mid-wait is swallowed, because polling is read-only and
1268    /// repeating it costs nothing.
1269    #[tokio::test(start_paused = true)]
1270    async fn a_dropped_connection_mid_wait_is_retried_rather_than_raised() {
1271        let recorder = Recorder::with([
1272            Reply::Cut("connection reset"),
1273            Reply::ok(serde_json::json!({
1274                "exec_id":"e1","phase":"exited","exit_code":3,"signal":null,
1275                "stdout":"","stderr":"boom","truncated":false,"writers_may_be_alive":false
1276            })),
1277        ]);
1278        let (session, _, _) = session_with(recorder);
1279
1280        let result = session
1281            .exec("e1")
1282            .wait(Duration::from_secs(30))
1283            .await
1284            .expect("the retry lands");
1285        assert_eq!(result.exit_code(), Some(3));
1286        assert!(!result.succeeded());
1287        assert_eq!(result.stderr(), "boom");
1288    }
1289
1290    /// A wait that times out says the exec is untouched, because it is.
1291    #[tokio::test(start_paused = true)]
1292    async fn a_wait_timeout_says_the_record_can_be_re_polled() {
1293        let replies: Vec<Reply> = (0..10)
1294            .map(|_| Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})))
1295            .collect();
1296        let recorder = Recorder::with(replies);
1297        let (session, _, _) = session_with(recorder);
1298
1299        let err = session
1300            .exec("e1")
1301            .wait(Duration::from_secs(3))
1302            .await
1303            .expect_err("the exec never finishes");
1304        assert_eq!(err.wire_kind(), Some(WireKind::ExecTimeout));
1305        assert_eq!(err.kind(), ErrorKind::Timeout);
1306        assert!(err.to_string().contains("still running"), "{err}");
1307        assert!(err.to_string().contains("re-polled"), "{err}");
1308    }
1309
1310    /// A stdin write base64s its data and names the eof signal, and an empty write with
1311    /// eof is a valid close.
1312    #[tokio::test]
1313    async fn a_stdin_write_carries_base64_and_the_eof_signal() {
1314        let recorder = Recorder::with([
1315            Reply::ok(serde_json::json!({"exec_id":"e1","written":3,"eof":true})),
1316            Reply::ok(serde_json::json!({"exec_id":"e1","written":0,"eof":true})),
1317        ]);
1318        let (session, _, _) = session_with(Arc::clone(&recorder));
1319        let handle = session.exec("e1");
1320
1321        let ack = handle.write_stdin(b"go\n", true).await.expect("writes");
1322        assert_eq!(ack.written, 3);
1323        assert!(ack.eof);
1324        let body: serde_json::Value =
1325            serde_json::from_slice(&recorder.last().body).expect("json body");
1326        assert_eq!(body["data_b64"], "Z28K");
1327        assert_eq!(body["signal"], "eof");
1328
1329        handle.close_stdin().await.expect("closes");
1330        let body: serde_json::Value =
1331            serde_json::from_slice(&recorder.last().body).expect("json body");
1332        assert!(
1333            body.get("data_b64").is_none_or(serde_json::Value::is_null),
1334            "a bare close must not send an empty data field: {body}"
1335        );
1336        assert_eq!(body["signal"], "eof");
1337    }
1338
1339    /// `kill` reports whether anything was signalled, and `false` with a 200 is a real
1340    /// answer rather than a failure.
1341    #[tokio::test]
1342    async fn kill_reports_false_for_a_group_that_had_already_exited() {
1343        let recorder = Recorder::with([
1344            Reply::ok(serde_json::json!({"exec_id":"e1","killed":true})),
1345            Reply::ok(serde_json::json!({"exec_id":"e1","killed":false})),
1346        ]);
1347        let (session, _, _) = session_with(Arc::clone(&recorder));
1348
1349        assert!(session.kill("e1").await.expect("kills"));
1350        assert!(!session.kill("e1").await.expect("already gone"));
1351        assert_eq!(recorder.last().path, "/v1/exec/e1/kill");
1352    }
1353
1354    /// A running poll has no outcome, and that absence is not an error — it is the shape
1355    /// of every poll before the last one.
1356    #[tokio::test]
1357    async fn a_running_poll_carries_no_outcome_and_is_not_an_error() {
1358        let recorder = Recorder::with([Reply::ok(
1359            serde_json::json!({"exec_id":"e1","phase":"running"}),
1360        )]);
1361        let (session, _, _) = session_with(recorder);
1362
1363        let result = session.exec("e1").poll().await.expect("polls");
1364        assert!(!result.done());
1365        assert!(result.outcome.is_none());
1366        assert_eq!(result.stdout(), "");
1367        assert_eq!(result.exit_code(), None);
1368        assert!(!result.succeeded());
1369    }
1370
1371    // ── BIND-6 and BIND-7: the POSIX exit code and the notes ─────────────────
1372
1373    fn finished(
1374        exit_code: Option<i32>,
1375        signal: Option<i32>,
1376        timed_out: bool,
1377        client_deadline: Option<super::super::complete::ClientDeadline>,
1378    ) -> ExecResult {
1379        ExecResult {
1380            exec_id: "e1".into(),
1381            phase: protocol::exec::Phase::Acked,
1382            outcome: Some(protocol::exec::Outcome {
1383                exit_code,
1384                signal,
1385                timed_out,
1386                ..protocol::exec::Outcome::default()
1387            }),
1388            client_deadline,
1389        }
1390    }
1391
1392    fn client(
1393        kill: super::super::complete::KillAnswer,
1394        ack_error: Option<&str>,
1395    ) -> Option<super::super::complete::ClientDeadline> {
1396        Some(super::super::complete::ClientDeadline {
1397            after: Duration::from_secs(90),
1398            kill,
1399            ack_error: ack_error.map(str::to_string),
1400        })
1401    }
1402
1403    /// **BIND-6: the table `crates/model/src/run.rs` checks, row for row.**
1404    ///
1405    /// 124 when a deadline ended the command (the daemon's, the client's kill of a live group,
1406    /// or a synthesized result), `128 + signal` for any other signal death, else the exit code.
1407    /// The row the issue's literal wording gets wrong is the SIGTERM-trapping child that exits
1408    /// 0 after the daemon deadline: it is a timeout, which is what `succeeded()` already says.
1409    ///
1410    /// **Falsification** — return `exit_code()` and the timed-out rows are red.
1411    #[test]
1412    fn the_posix_exit_code_follows_the_models_table() {
1413        use super::super::complete::KillAnswer::{AlreadyGone, Failed, Signalled};
1414        let rows = [
1415            (finished(Some(0), None, false, None), Some(0)),
1416            (finished(Some(3), None, false, None), Some(3)),
1417            (finished(None, Some(9), false, None), Some(137)),
1418            (finished(None, Some(11), false, None), Some(139)),
1419            (finished(None, Some(15), true, None), Some(124)),
1420            (finished(None, Some(9), true, None), Some(124)),
1421            (finished(Some(0), None, true, None), Some(124)),
1422            (
1423                finished(None, Some(15), false, client(Signalled, None)),
1424                Some(124),
1425            ),
1426            (
1427                finished(Some(0), None, false, client(AlreadyGone, None)),
1428                Some(0),
1429            ),
1430            (
1431                finished(None, Some(11), false, client(Failed("reset".into()), None)),
1432                Some(139),
1433            ),
1434            (
1435                ExecResult {
1436                    exec_id: "e1".into(),
1437                    phase: protocol::exec::Phase::Running,
1438                    outcome: None,
1439                    client_deadline: client(Failed("reset".into()), Some("poll reset")),
1440                },
1441                Some(124),
1442            ),
1443            (
1444                ExecResult {
1445                    exec_id: "e1".into(),
1446                    phase: protocol::exec::Phase::Running,
1447                    outcome: None,
1448                    client_deadline: None,
1449                },
1450                None,
1451            ),
1452        ];
1453        for (result, expected) in rows {
1454            assert_eq!(result.posix_exit_code(), expected, "{result:?}");
1455            // Each 124 in the table is a deadline's, and `deadline_ended` is that condition.
1456            assert_eq!(result.deadline_ended(), expected == Some(124), "{result:?}");
1457        }
1458        // A command that exits 124 by itself reports 124 and ended on no deadline, which is
1459        // why the CLI reads `deadline_ended` and not the code.
1460        let by_itself = finished(Some(124), None, false, None);
1461        assert_eq!(by_itself.posix_exit_code(), Some(124));
1462        assert!(!by_itself.deadline_ended());
1463    }
1464
1465    /// **BIND-7: one note per condition, and none for a clean result.**
1466    #[test]
1467    fn each_condition_carries_one_note_and_a_clean_result_none() {
1468        assert!(finished(Some(0), None, false, None).notes().is_empty());
1469
1470        let mut truncated = finished(Some(0), None, false, None);
1471        let outcome = truncated.outcome.as_mut().expect("an outcome");
1472        outcome.truncated = true;
1473        outcome.writers_may_be_alive = true;
1474        let notes = truncated.notes();
1475        assert_eq!(notes.len(), 2, "{notes:?}");
1476        assert!(notes[0].contains("output cap"), "{notes:?}");
1477        assert!(notes[1].contains("linger"), "{notes:?}");
1478
1479        let notes = finished(None, Some(15), true, None).notes();
1480        assert_eq!(notes.len(), 1, "{notes:?}");
1481        assert!(notes[0].contains("timeout_sec"), "{notes:?}");
1482
1483        let notes = finished(
1484            None,
1485            Some(15),
1486            false,
1487            client(super::super::complete::KillAnswer::Signalled, None),
1488        )
1489        .notes();
1490        assert_eq!(notes.len(), 1, "{notes:?}");
1491        assert!(notes[0].contains("client deadline of 90s"), "{notes:?}");
1492
1493        let synthesized = ExecResult {
1494            exec_id: "e1".into(),
1495            phase: protocol::exec::Phase::Running,
1496            outcome: None,
1497            client_deadline: client(
1498                super::super::complete::KillAnswer::Signalled,
1499                Some("poll reset"),
1500            ),
1501        };
1502        let notes = synthesized.notes();
1503        assert_eq!(notes.len(), 1, "{notes:?}");
1504        assert!(notes[0].contains("synthesized"), "{notes:?}");
1505        assert!(notes[0].contains("poll reset"), "{notes:?}");
1506    }
1507
1508    // ── the callback driver (`for_each_event`) ───────────────────────────────
1509    //
1510    // Four tests, mirroring the four `stream_with` properties that matter to a caller: the
1511    // events arrive in order, `Break` stops the drive, a reconnect still joins at the
1512    // cursor, and a cut without an exit event is *reported as a cut*. They are separate
1513    // tests rather than a re-run of the loop above on purpose — the driver is what the CLI
1514    // and (next) the bindings actually call, and "the `Stream` version works" is not a
1515    // statement about it.
1516
1517    /// Drives a stream with `for_each_event` and returns what the callback saw.
1518    async fn drive(
1519        handle: &ExecHandle,
1520        options: StreamOptions,
1521    ) -> (Vec<ExecEvent>, Result<StreamEnd, Error>) {
1522        let mut seen = Vec::new();
1523        let end = handle
1524            .for_each_event(options, |event| {
1525                seen.push(event);
1526                std::ops::ControlFlow::Continue(())
1527            })
1528            .await;
1529        (seen, end)
1530    }
1531
1532    /// **Events reach the callback in wire order, and the end names the terminal event.**
1533    ///
1534    /// Order is the property a callback API can silently break where a `Stream` cannot —
1535    /// buffering to hand over in a batch would reverse or coalesce, and the reader of a
1536    /// child's stdout would see the second chunk first. Asserted on the *offsets* rather
1537    /// than only on the reassembled bytes, because two chunks concatenated the wrong way
1538    /// round still yield the right total length.
1539    ///
1540    /// **Falsification** — push events onto a `Vec` inside `for_each_event` and call the
1541    /// callback after the loop, and the reason still reads `Exited` while the offsets come
1542    /// out of order only if the wire order was wrong; swap the two `Reply` chunks and this
1543    /// is red on `[0, 5]`. Verified: reversing the callback's argument order (delivering
1544    /// the second frame first) fails the offsets assertion.
1545    #[tokio::test(start_paused = true)]
1546    async fn the_driver_hands_every_event_to_the_callback_in_order() {
1547        let recorder = Recorder::with([Reply::Chunks(
1548            200,
1549            vec![output(0, b"AAAAA"), output(5, b"BBBBB"), exit(10)],
1550        )]);
1551        let (session, _, _) = session_with(Arc::clone(&recorder));
1552        let handle = session.exec("ordered");
1553
1554        let (seen, end) = drive(&handle, StreamOptions::default()).await;
1555        let end = end.expect("the stream completes");
1556
1557        let offsets: Vec<u64> = seen
1558            .iter()
1559            .filter_map(|event| match event {
1560                ExecEvent::Output { offset, .. } => Some(*offset),
1561                _ => None,
1562            })
1563            .collect();
1564        assert_eq!(offsets, [0, 5], "the callback saw the frames out of order");
1565        assert!(
1566            matches!(seen.last(), Some(ExecEvent::Exit(_))),
1567            "the terminal event has to be delivered, not merely observed: {seen:#?}"
1568        );
1569        assert_eq!(
1570            end,
1571            StreamEnd {
1572                reason: EndReason::Exited,
1573                cursor: 10,
1574            },
1575            "the end has to name the exit event and the total"
1576        );
1577        assert_eq!(recorder.requests().len(), 1);
1578    }
1579
1580    /// **`ControlFlow::Break` stops the drive, and says so rather than reporting a cut.**
1581    ///
1582    /// Two assertions and the second is the load-bearing one. Stopping has to be
1583    /// distinguishable from a truncated stream: both end without an exit event, and a
1584    /// caller that read `Cut` after its own `Break` would report "the command's outcome is
1585    /// unknown" about a command it chose not to watch. The cursor is where the callback
1586    /// stopped, so passing it back as `--from-offset` resumes exactly there.
1587    ///
1588    /// **Falsification** — return `EndReason::Cut` for the break arm and the reason
1589    /// assertion is red; drop the `is_break()` check and the drive runs to the exit event,
1590    /// so `seen.len()` reads 3 instead of 1. Verified: removing the early return makes the
1591    /// event-count assertion fail with 3.
1592    #[tokio::test(start_paused = true)]
1593    async fn control_flow_break_stops_the_stream_and_is_not_reported_as_a_cut() {
1594        let recorder = Recorder::with([Reply::Chunks(
1595            200,
1596            vec![output(0, b"first\n"), output(6, b"second\n"), exit(13)],
1597        )]);
1598        let (session, _, _) = session_with(Arc::clone(&recorder));
1599        let handle = session.exec("halted");
1600
1601        let mut seen = Vec::new();
1602        let end = handle
1603            .for_each_event(StreamOptions::default(), |event| {
1604                seen.push(event);
1605                // Stop on the first event, which is what a consumer with a `head -1` does.
1606                std::ops::ControlFlow::Break(())
1607            })
1608            .await
1609            .expect("stopping is not a failure");
1610
1611        assert_eq!(
1612            seen.len(),
1613            1,
1614            "the drive kept going after Break, so a caller that stopped reading is still \
1615             being read to: {seen:#?}"
1616        );
1617        assert_eq!(
1618            end,
1619            StreamEnd {
1620                reason: EndReason::Stopped,
1621                cursor: 6,
1622            },
1623            "a caller's own stop must not read as a cut stream, and the cursor is where it \
1624             stopped"
1625        );
1626    }
1627
1628    /// **A cut mid-drive still reconnects at the cursor, losing and duplicating nothing.**
1629    ///
1630    /// The same property as the `Stream` version's first test, asserted through the
1631    /// driver, because that is the path the CLI takes: if the driver dropped the cursor the
1632    /// reconnect would ask for byte zero and the caller would see `AAAA` twice, and the
1633    /// total byte count would look right. So the verdict is the reassembled bytes *and*
1634    /// the offset the second attach asked for.
1635    ///
1636    /// **Falsification** — seed the driver's `cursor` from zero instead of
1637    /// `options.offset`, or drop the `next.cursor()` read, and the reported end cursor is
1638    /// wrong while the bytes still join (the machine's own cursor is what drives the
1639    /// reconnect). Verified: replacing `next.cursor()` with `None` fails the `StreamEnd`
1640    /// assertion at cursor 0.
1641    #[tokio::test(start_paused = true)]
1642    async fn the_driver_reconnects_at_the_cursor_across_a_cut() {
1643        let recorder = Recorder::with([
1644            Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
1645            Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
1646        ]);
1647        let (session, _, _) = session_with(Arc::clone(&recorder));
1648        let handle = session.exec("resumed-driver");
1649
1650        let (seen, end) = drive(&handle, StreamOptions::default()).await;
1651        let end = end.expect("the reconnect lands");
1652
1653        let mut bytes = Vec::new();
1654        for event in &seen {
1655            if let ExecEvent::Output { data, .. } = event {
1656                bytes.extend_from_slice(data);
1657            }
1658        }
1659        assert_eq!(
1660            String::from_utf8_lossy(&bytes),
1661            "AAAA\nBBBB\nCCCC\n",
1662            "the two attaches did not reconstruct the output through the driver"
1663        );
1664        assert_eq!(end.reason, EndReason::Exited);
1665        assert_eq!(end.cursor, 15);
1666
1667        let seen_requests = recorder.requests();
1668        assert_eq!(seen_requests.len(), 2, "the cut produced no reconnect");
1669        assert_eq!(
1670            requested_offset(&seen_requests[1]),
1671            10,
1672            "the reconnect asked for the wrong byte, so the seam is wrong"
1673        );
1674    }
1675
1676    /// **A body that ended with no exit event and no reconnect is reported as `Cut`.**
1677    ///
1678    /// The one ending a byte count cannot detect, and the reason this returns a typed
1679    /// reason at all: `reconnect: false` is what a caller doing its own reconnection
1680    /// passes, and it must be able to tell "the command finished" from "the connection
1681    /// dropped and you told me not to retry". `cursor` is where its own reconnect starts.
1682    ///
1683    /// **Falsification** — report `EndReason::Exited` for the `advance` returned `None`
1684    /// arm and this is red on the reason; a caller acting on that would report exit code
1685    /// 0 for a command whose outcome nobody knows. Verified.
1686    #[tokio::test(start_paused = true)]
1687    async fn a_cut_with_reconnect_off_ends_the_drive_naming_the_cut() {
1688        let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"partial")])]);
1689        let (session, _, _) = session_with(Arc::clone(&recorder));
1690        let handle = session.exec("truncated");
1691
1692        let (seen, end) = drive(
1693            &handle,
1694            StreamOptions {
1695                reconnect: false,
1696                ..StreamOptions::default()
1697            },
1698        )
1699        .await;
1700        let end = end.expect("a cut is not an error when reconnect is off");
1701
1702        assert_eq!(seen.len(), 1);
1703        assert!(
1704            !seen.iter().any(|event| matches!(event, ExecEvent::Exit(_))),
1705            "there was no terminal event to deliver"
1706        );
1707        assert_eq!(
1708            end,
1709            StreamEnd {
1710                reason: EndReason::Cut,
1711                cursor: 7,
1712            },
1713            "a stream with no exit event is a cut, and reporting anything else would let a \
1714             caller pass a CI step on output it never received"
1715        );
1716        assert_eq!(recorder.requests().len(), 1);
1717    }
1718
1719    /// A fatal error ends the drive with `Err`, and the events already delivered stay
1720    /// delivered.
1721    ///
1722    /// The asymmetry `for_each_event`'s docs state, asserted: a 404 mid-stream is fatal
1723    /// (the entry was collected, so reattaching can never succeed) and there is nothing to
1724    /// unwind the bytes a callback already wrote with.
1725    #[tokio::test(start_paused = true)]
1726    async fn a_fatal_status_ends_the_drive_with_an_error() {
1727        let recorder = Recorder::with([Reply::Body(404, b"{\"error\":\"unknown_exec\"}".to_vec())]);
1728        let (session, _, _) = session_with(Arc::clone(&recorder));
1729        let handle = session.exec("collected");
1730
1731        let (seen, end) = drive(&handle, StreamOptions::default()).await;
1732        assert!(seen.is_empty(), "nothing was delivered: {seen:#?}");
1733        let error = end.expect_err("a 404 on the attach is fatal");
1734        assert_eq!(error.wire_kind(), Some(WireKind::NotFound));
1735        assert_eq!(
1736            recorder.requests().len(),
1737            1,
1738            "a fatal status was reattached, which can never succeed"
1739        );
1740    }
1741
1742    // ── the async callback driver (`for_each_event_async`) ────────────────────
1743    //
1744    // Four tests. Three mirror `for_each_event`'s — order, `Break` stops, a reconnect joins at
1745    // the cursor — because "the sync driver works" is not a statement about this one even though
1746    // the sync driver is now written in terms of it: the delegation could be inverted tomorrow,
1747    // and the bindings call *this* method. The fourth is the property only an awaited callback
1748    // has, and it is the reason the overload exists at all: a callback that awaits between
1749    // events loses none of them.
1750
1751    /// Drives a stream with the async driver and returns what the callback saw.
1752    async fn drive_async(
1753        handle: &ExecHandle,
1754        options: StreamOptions,
1755    ) -> (Vec<ExecEvent>, Result<StreamEnd, Error>) {
1756        let mut seen = Vec::new();
1757        let end = handle
1758            .for_each_event_async(options, |event| {
1759                seen.push(event);
1760                std::future::ready(std::ops::ControlFlow::Continue(()))
1761            })
1762            .await;
1763        (seen, end)
1764    }
1765
1766    /// **Events reach an async callback in wire order, and the end names the terminal event.**
1767    ///
1768    /// Asserted on the *offsets* rather than only on the reassembled bytes, because two chunks
1769    /// concatenated the wrong way round still yield the right total length. Order is the
1770    /// property an awaited callback could break where the sync one cannot: a driver that
1771    /// launched each callback future without awaiting it, or awaited them out of order, would
1772    /// deliver a child's second stdout chunk before its first.
1773    ///
1774    /// **Falsification** — remove the `.await` on `on_event(event)` and the loop does not
1775    /// compile; replace it with a driver that collects the futures and joins them at the end
1776    /// and the offsets come out in completion order rather than wire order. Verified by
1777    /// reversing the `Reply::Chunks` frame order, which fails this assertion on `[5, 0]`.
1778    #[tokio::test(start_paused = true)]
1779    async fn the_async_driver_hands_every_event_to_the_callback_in_order() {
1780        let recorder = Recorder::with([Reply::Chunks(
1781            200,
1782            vec![output(0, b"AAAAA"), output(5, b"BBBBB"), exit(10)],
1783        )]);
1784        let (session, _, _) = session_with(Arc::clone(&recorder));
1785        let handle = session.exec("ordered-async");
1786
1787        let (seen, end) = drive_async(&handle, StreamOptions::default()).await;
1788        let end = end.expect("the stream completes");
1789
1790        let offsets: Vec<u64> = seen
1791            .iter()
1792            .filter_map(|event| match event {
1793                ExecEvent::Output { offset, .. } => Some(*offset),
1794                _ => None,
1795            })
1796            .collect();
1797        assert_eq!(offsets, [0, 5], "the callback saw the frames out of order");
1798        assert!(
1799            matches!(seen.last(), Some(ExecEvent::Exit(_))),
1800            "the terminal event has to be delivered, not merely observed: {seen:#?}"
1801        );
1802        assert_eq!(
1803            end,
1804            StreamEnd {
1805                reason: EndReason::Exited,
1806                cursor: 10,
1807            },
1808            "the end has to name the exit event and the total"
1809        );
1810        assert_eq!(recorder.requests().len(), 1);
1811    }
1812
1813    /// **`ControlFlow::Break` from an async callback stops the drive and is not a cut.**
1814    ///
1815    /// The load-bearing half is the reason: stopping has to be distinguishable from a truncated
1816    /// stream, because both end without an exit event. A binding whose host-language iterator
1817    /// was dropped answers `Break` here — a failed channel send — and it must not read as "the
1818    /// command's outcome is unknown".
1819    ///
1820    /// **Falsification** — return `EndReason::Cut` from the break arm and the reason assertion
1821    /// is red; drop the `is_break()` check and the drive runs to the exit event, so `seen.len()`
1822    /// reads 3 instead of 1. Verified: deleting the early return fails the event-count assertion
1823    /// with 3.
1824    #[tokio::test(start_paused = true)]
1825    async fn an_async_callback_breaking_stops_the_stream_and_is_not_reported_as_a_cut() {
1826        let recorder = Recorder::with([Reply::Chunks(
1827            200,
1828            vec![output(0, b"first\n"), output(6, b"second\n"), exit(13)],
1829        )]);
1830        let (session, _, _) = session_with(Arc::clone(&recorder));
1831        let handle = session.exec("halted-async");
1832
1833        let mut seen = Vec::new();
1834        let end = handle
1835            .for_each_event_async(StreamOptions::default(), |event| {
1836                seen.push(event);
1837                // Stop on the first event, which is what a dropped binding iterator does.
1838                std::future::ready(std::ops::ControlFlow::Break(()))
1839            })
1840            .await
1841            .expect("stopping is not a failure");
1842
1843        assert_eq!(
1844            seen.len(),
1845            1,
1846            "the drive kept going after Break, so a consumer that stopped reading is still \
1847             being read to: {seen:#?}"
1848        );
1849        assert_eq!(
1850            end,
1851            StreamEnd {
1852                reason: EndReason::Stopped,
1853                cursor: 6,
1854            },
1855            "a consumer's own stop must not read as a cut stream, and the cursor is where it \
1856             stopped"
1857        );
1858    }
1859
1860    /// **A cut mid-drive still reconnects at the cursor through the async driver.**
1861    ///
1862    /// The same seam property, asserted through the path the bindings take. If the driver lost
1863    /// the cursor the reconnect would ask for byte zero and the consumer would see `AAAA` twice
1864    /// while the total byte count still looked plausible — so the verdict is the reassembled
1865    /// bytes *and* the offset the second attach asked for.
1866    ///
1867    /// **Falsification** — seed `cursor` from zero instead of `options.offset`, or drop the
1868    /// `next.cursor()` read, and the reported end cursor is wrong while the bytes still join.
1869    /// Verified: replacing `next.cursor()` with `None` fails the end-cursor assertion at 0.
1870    #[tokio::test(start_paused = true)]
1871    async fn the_async_driver_reconnects_at_the_cursor_across_a_cut() {
1872        let recorder = Recorder::with([
1873            Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
1874            Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
1875        ]);
1876        let (session, _, _) = session_with(Arc::clone(&recorder));
1877        let handle = session.exec("resumed-async");
1878
1879        let (seen, end) = drive_async(&handle, StreamOptions::default()).await;
1880        let end = end.expect("the reconnect lands");
1881
1882        let mut bytes = Vec::new();
1883        for event in &seen {
1884            if let ExecEvent::Output { data, .. } = event {
1885                bytes.extend_from_slice(data);
1886            }
1887        }
1888        assert_eq!(
1889            String::from_utf8_lossy(&bytes),
1890            "AAAA\nBBBB\nCCCC\n",
1891            "the two attaches did not reconstruct the output through the async driver"
1892        );
1893        assert_eq!(end.reason, EndReason::Exited);
1894        assert_eq!(end.cursor, 15);
1895
1896        let seen_requests = recorder.requests();
1897        assert_eq!(seen_requests.len(), 2, "the cut produced no reconnect");
1898        assert_eq!(
1899            requested_offset(&seen_requests[1]),
1900            10,
1901            "the reconnect asked for the wrong byte, so the seam is wrong"
1902        );
1903    }
1904
1905    /// **A callback that awaits between events loses none of them.**
1906    ///
1907    /// The one property only this overload has, set up as the exact shape both bindings use: a
1908    /// **capacity-1** `mpsc` channel the callback `send`s into and a separate consumer drains,
1909    /// with the consumer deliberately slower than the producer so the channel is full for every
1910    /// event after the first. That is the configuration the sync driver could not serve — its
1911    /// only available send is `blocking_send`, which would park the runtime worker the driver
1912    /// itself is running on — and it is why `microvms-py` and `microvms-js` kept the `Stream`
1913    /// path until this method existed.
1914    ///
1915    /// Five events go in and five come out, in wire order, terminal event included. The
1916    /// verdict is the sequence and not a count: a driver that dropped an event under
1917    /// backpressure would still report `Exited` with the right cursor, because the cursor is the
1918    /// state machine's and not the callback's.
1919    ///
1920    /// **Falsification** — replace the awaited `send` with `try_send` and ignore the failure
1921    /// (the shape of a driver that does not respect backpressure) and this is red with three
1922    /// of five events delivered. Verified.
1923    #[tokio::test(start_paused = true)]
1924    async fn an_async_callback_that_awaits_between_events_loses_none_of_them() {
1925        let recorder = Recorder::with([Reply::Chunks(
1926            200,
1927            vec![
1928                output(0, b"AA"),
1929                output(2, b"BB"),
1930                output(4, b"CC"),
1931                output(6, b"DD"),
1932                exit(8),
1933            ],
1934        )]);
1935        let (session, _, _) = session_with(Arc::clone(&recorder));
1936        let handle = session.exec("backpressured");
1937
1938        // Capacity 1, the bindings' own bound: the SSE body is the backpressure signal, and
1939        // buffering here would defeat the cursor the core reconnects at.
1940        let (sender, mut receiver) = tokio::sync::mpsc::channel::<String>(1);
1941        let consumer = tokio::spawn(async move {
1942            let mut drained = Vec::new();
1943            while let Some(described) = receiver.recv().await {
1944                // Slower than the producer, so every send after the first waits.
1945                tokio::time::sleep(Duration::from_millis(50)).await;
1946                drained.push(described);
1947            }
1948            drained
1949        });
1950
1951        let end = handle
1952            .for_each_event_async(StreamOptions::default(), |event| {
1953                let described = match &event {
1954                    ExecEvent::Output { offset, data, .. } => {
1955                        format!("out@{offset}:{}", String::from_utf8_lossy(data))
1956                    }
1957                    ExecEvent::Gap { from, to } => format!("gap@{from}..{to}"),
1958                    ExecEvent::Exit(exit) => format!("exit:{:?}", exit.exit_code),
1959                };
1960                // Cloned per event rather than borrowed, which is what the plain-`Fut`
1961                // signature requires and what both bindings do. One atomic increment.
1962                let sender = sender.clone();
1963                async move {
1964                    match sender.send(described).await {
1965                        Ok(()) => std::ops::ControlFlow::Continue(()),
1966                        // A closed receiver is the dropped-iterator case, not this path.
1967                        Err(_) => std::ops::ControlFlow::Break(()),
1968                    }
1969                }
1970            })
1971            .await
1972            .expect("the stream completes even though the consumer lags");
1973        // Closing the sender is what ends the consumer's `recv` loop.
1974        drop(sender);
1975
1976        let drained = consumer.await.expect("the consumer task does not panic");
1977        assert_eq!(
1978            drained,
1979            [
1980                "out@0:AA",
1981                "out@2:BB",
1982                "out@4:CC",
1983                "out@6:DD",
1984                "exit:Some(0)",
1985            ],
1986            "a lagging capacity-1 consumer lost or reordered events, which is the failure the \
1987             awaited callback exists to prevent"
1988        );
1989        assert_eq!(
1990            end,
1991            StreamEnd {
1992                reason: EndReason::Exited,
1993                cursor: 8,
1994            },
1995            "the ending must still name the exit event and the total"
1996        );
1997    }
1998
1999    /// A timeout too large for the clock to add to now still waits, rather than panicking with
2000    /// the exec already started (#268): every surface accepts up to what a `Duration` holds,
2001    /// and `Instant + Duration` overflows past about 9.2e18 seconds. The exec runs on the first
2002    /// poll and exits on the second, so a deadline that saturated into the past would time out
2003    /// between them instead of waiting.
2004    ///
2005    /// **Falsification**: `verify/guards/faults/seconds-flags.toml` entry `app-wait-deadline-overflows`
2006    /// restores the unchecked `Instant::now() + after` in `deadline_after`, and the wait panics.
2007    #[tokio::test(start_paused = true)]
2008    async fn a_wait_longer_than_the_clock_can_hold_still_waits() {
2009        let recorder = Recorder::with([
2010            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
2011            Reply::ok(serde_json::json!({
2012                "exec_id":"e1","phase":"exited","exit_code":0,"signal":null,
2013                "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
2014            })),
2015        ]);
2016        let (session, _, _) = session_with(Arc::clone(&recorder));
2017
2018        let result = session
2019            .exec("e1")
2020            .wait(Duration::MAX)
2021            .await
2022            .expect("a far deadline waits for the exit");
2023        assert_eq!(result.exit_code(), Some(0));
2024        assert_eq!(
2025            recorder.requests().len(),
2026            2,
2027            "the wait polled past the running phase"
2028        );
2029    }
2030}