Skip to main content

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