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}