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