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