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