Skip to main content

microvms_core/session/
complete.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Run one command to exactly one result: [`Session::run_to_completion`].
3//!
4//! The composition every harness wrote for itself (issue #222): start with a caller-minted
5//! exec id, stream output to a callback when there is one, fall back to wait-and-ack when the
6//! stream ends without its terminal `exit` event, and on a client-side deadline kill the
7//! process group, ack within a short grace, and synthesize exit code 124 when even that
8//! fails. `model/src/run.rs` is the checked specification of this module; BIND-6 through
9//! BIND-10 in `spec/core.symspec.json` are its requirements.
10
11use std::time::Duration;
12
13use futures_util::future::BoxFuture;
14
15use super::Session;
16use super::exec::{EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions};
17use super::sse::ExecEvent;
18use crate::error::{Error, WireKind};
19
20/// The default [`CompletionOptions::client_grace`]: how long past the daemon's own deadline
21/// the client waits before it kills, and how long it then waits for the killed exec's result.
22///
23/// Sixty seconds, the eval harvester's figure: the daemon escalates a timed-out group from
24/// SIGTERM to SIGKILL after its ten-second `kill_grace`, and the rest covers a slow proxy.
25pub const DEFAULT_CLIENT_GRACE: Duration = Duration::from_secs(60);
26
27/// The client deadline of a request with no `timeout_sec`: the longest a MicroVM can live
28/// (`MAX_DURATION_SEC`), since no command outlives the VM it runs in.
29pub const NO_TIMEOUT_CEILING: Duration =
30    Duration::from_secs(crate::constants::MAX_DURATION_SEC as u64);
31
32/// A callback for output. Only [`ExecEvent::Output`] is delivered; the result carries the
33/// exit status. Answering [`std::ops::ControlFlow::Break`] stops delivery, and the call
34/// still waits for and acks the exec, so nothing is left running unobserved (BIND-8).
35pub type OutputSink = Box<dyn FnMut(ExecEvent) -> OutputFlow + Send>;
36
37/// What an [`OutputSink`] answers: a boxed future of continue-or-stop, named so a caller
38/// needs no `futures` crate to spell it.
39pub type OutputFlow = BoxFuture<'static, std::ops::ControlFlow<()>>;
40
41/// The knobs of a run-to-completion call.
42#[derive(Clone, Debug)]
43pub struct CompletionOptions {
44    /// Added to the request's `timeout_sec` to make the client deadline, and the budget of
45    /// the wait-and-ack after a kill.
46    pub client_grace: Duration,
47    /// How the output stream behaves when a callback is given.
48    pub stream: StreamOptions,
49}
50
51impl Default for CompletionOptions {
52    fn default() -> Self {
53        Self {
54            client_grace: DEFAULT_CLIENT_GRACE,
55            stream: StreamOptions::default(),
56        }
57    }
58}
59
60/// The daemon's answer to the kill the client sent when its deadline expired.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub enum KillAnswer {
63    /// `killed: true`: a live process group was signalled.
64    Signalled,
65    /// `killed: false`: the group had already exited.
66    AlreadyGone,
67    /// The kill request failed, with this error.
68    Failed(String),
69}
70
71/// What the client did when its own deadline expired (BIND-9, BIND-10).
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ClientDeadline {
74    /// How long the client waited: `timeout_sec` plus the grace, or the ceiling.
75    pub after: Duration,
76    /// The daemon's answer to the kill.
77    pub kill: KillAnswer,
78    /// Why the post-kill wait-and-ack failed, when it did. Set means the result is
79    /// synthesized: exit code 124, output unknown.
80    pub ack_error: Option<String>,
81}
82
83/// A validated run-to-completion call, separable from the start request it was planned for.
84///
85/// Separate so a binding can start the exec under its sandbox lock and drive it after
86/// releasing the lock: a callback that reaches back into the sandbox must not find the lock
87/// held by the call that is invoking it.
88#[derive(Clone, Debug)]
89pub struct CompletionPlan {
90    deadline: Duration,
91    options: CompletionOptions,
92}
93
94impl CompletionPlan {
95    /// Plans a call for `request`, refusing a `timeout_sec` no deadline can be built from
96    /// before anything starts.
97    pub fn new(
98        request: &protocol::exec::StartRequest,
99        options: CompletionOptions,
100    ) -> Result<Self, Error> {
101        let deadline = match request.timeout_sec {
102            Some(seconds) => {
103                crate::cost::duration_of_secs_f64(seconds)?.saturating_add(options.client_grace)
104            }
105            None => NO_TIMEOUT_CEILING,
106        };
107        Ok(Self { deadline, options })
108    }
109
110    /// The client deadline, measured from the start of [`Self::drive`].
111    pub fn deadline(&self) -> Duration {
112        self.deadline
113    }
114
115    /// Drives a started exec to exactly one result.
116    ///
117    /// The steps, each the specified behavior in `model/src/run.rs`:
118    ///
119    /// 1. With a callback, stream to it under the client deadline. The terminal `exit` event
120    ///    means the output is final, so one ack returns it (BIND-8).
121    /// 2. Without a callback, or when the stream ended without its `exit` event (a cut past its
122    ///    reconnect budget, a fatal stream error, the callback answering `Break`), or when the
123    ///    ack after the exit event failed: wait and ack under what is left of the deadline
124    ///    (BIND-8). Polling is read-only, so this is safe whatever the stream saw.
125    /// 3. When the client deadline expires in either step: kill the process group, then wait
126    ///    and ack within the client grace (BIND-9). The kill is sent whatever its outcome turns
127    ///    out to be, and a failed kill still gets the ack: the command may have finished by
128    ///    itself, and its real result beats a synthesized one. The daemon answers the kill
129    ///    only once the group is gone, SIGKILLing it after its ten-second `kill_grace` if it
130    ///    must (measured 2026-09-24, us-east-1), so the kill can take that long before the
131    ///    grace starts.
132    /// 4. When that ack fails too: a synthesized result, exit code 124, naming both failures
133    ///    (BIND-10). Nothing else synthesizes.
134    ///
135    /// An error is returned only for a failure that says nothing about a deadline: a fatal
136    /// wait (the exec was collected, the token was refused). A retryable poll failure is
137    /// retried inside the wait, as `ExecHandle::wait` always has.
138    pub async fn drive(
139        &self,
140        handle: &ExecHandle,
141        on_output: Option<OutputSink>,
142    ) -> Result<ExecResult, Error> {
143        let deadline = tokio::time::Instant::now() + self.deadline;
144        if let Some(sink) = on_output {
145            match tokio::time::timeout_at(deadline, self.stream(handle, sink)).await {
146                Err(_elapsed) => return self.after_deadline(handle).await,
147                Ok(true) => match handle.ack().await {
148                    Ok(result) if result.done() && result.outcome.is_some() => return Ok(result),
149                    // A failed or empty ack falls through to the wait, which re-reads the
150                    // phase before acking again.
151                    Ok(_) | Err(_) => {}
152                },
153                Ok(false) => {}
154            }
155        }
156        match wait_and_ack_until(handle, deadline).await {
157            Ok(result) => Ok(result),
158            Err(error) if error.wire_kind() == Some(WireKind::ExecTimeout) => {
159                self.after_deadline(handle).await
160            }
161            Err(error) => Err(error),
162        }
163    }
164
165    /// Streams output to `sink`, answering whether the terminal `exit` event arrived.
166    ///
167    /// Everything else — a cut past the reconnect budget, a fatal stream error, the sink
168    /// stopping — answers `false`, and the caller falls back to wait and ack. The error itself
169    /// is dropped deliberately: the wait that follows re-reads the exec, and raises whatever
170    /// is fatal about it there.
171    async fn stream(&self, handle: &ExecHandle, mut sink: OutputSink) -> bool {
172        let end = handle
173            .for_each_event_async(self.options.stream.clone(), |event| match event {
174                ExecEvent::Output { .. } => sink(event),
175                ExecEvent::Gap { .. } | ExecEvent::Exit(_) => {
176                    Box::pin(std::future::ready(std::ops::ControlFlow::Continue(())))
177                }
178            })
179            .await;
180        matches!(
181            end,
182            Ok(StreamEnd {
183                reason: EndReason::Exited,
184                ..
185            })
186        )
187    }
188
189    /// Steps 3 and 4 of [`Self::drive`]: kill, then wait and ack within the grace.
190    async fn after_deadline(&self, handle: &ExecHandle) -> Result<ExecResult, Error> {
191        let kill = match handle.kill().await {
192            Ok(true) => KillAnswer::Signalled,
193            Ok(false) => KillAnswer::AlreadyGone,
194            Err(error) => KillAnswer::Failed(error.to_string()),
195        };
196        let grace = tokio::time::Instant::now() + self.options.client_grace;
197        match wait_and_ack_until(handle, grace).await {
198            Ok(mut result) => {
199                result.client_deadline = Some(ClientDeadline {
200                    after: self.deadline,
201                    kill,
202                    ack_error: None,
203                });
204                Ok(result)
205            }
206            // Any failure, fatal or not: the caller's deadline has passed, and the contract
207            // every harness reports for that is 124. The note names what failed, so a fatal
208            // error is visible rather than swallowed.
209            Err(error) => Ok(ExecResult {
210                exec_id: handle.exec_id().to_string(),
211                phase: protocol::exec::Phase::Running,
212                outcome: None,
213                client_deadline: Some(ClientDeadline {
214                    after: self.deadline,
215                    kill,
216                    ack_error: Some(error.to_string()),
217                }),
218            }),
219        }
220    }
221}
222
223/// How long to wait before retrying an ack that failed with a retryable error.
224const ACK_RETRY_INTERVAL: Duration = Duration::from_secs(1);
225
226/// [`ExecHandle::wait_and_ack`] until `deadline`, retrying an ack that failed retryably.
227///
228/// `wait_and_ack` retries a dropped poll but not a dropped ack: its ack is one request, and
229/// a transport failure there ends the call although the result is sitting in the daemon. The
230/// fuzz harness found that (a cut stream, then one failed ack, raised instead of returning),
231/// so here the pair is repeated until the deadline. Repeating is safe because the wait
232/// re-reads the phase first: an ack whose response was lost reads as `acked` and is not sent
233/// twice. Running out of time is the wait's own `ExecTimeout`, so the caller's deadline path
234/// is the same whichever request was in flight.
235async fn wait_and_ack_until(
236    handle: &ExecHandle,
237    deadline: tokio::time::Instant,
238) -> Result<ExecResult, Error> {
239    loop {
240        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
241        match handle.wait_and_ack(remaining).await {
242            Err(error) if error.retryable() && error.wire_kind() != Some(WireKind::ExecTimeout) => {
243                let left = deadline.saturating_duration_since(tokio::time::Instant::now());
244                if left.is_zero() {
245                    return Err(Error::wire(
246                        WireKind::ExecTimeout,
247                        format!(
248                            "exec {} was not acknowledged before the deadline; the last \
249                             attempt failed: {error}",
250                            handle.exec_id()
251                        ),
252                    ));
253                }
254                tokio::time::sleep(ACK_RETRY_INTERVAL.min(left)).await;
255            }
256            other => return other,
257        }
258    }
259}
260
261impl Session {
262    /// Starts `request` and drives it to exactly one result. See the module docs.
263    pub async fn run_to_completion(
264        &self,
265        request: protocol::exec::StartRequest,
266        options: CompletionOptions,
267        on_output: Option<OutputSink>,
268    ) -> Result<ExecResult, Error> {
269        let plan = CompletionPlan::new(&request, options)?;
270        let handle = self.run(request).await?;
271        plan.drive(&handle, on_output).await
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use std::sync::{Arc, Mutex};
278
279    use base64::Engine as _;
280
281    use super::*;
282    use crate::session::testing::{Recorder, Reply, session_with};
283
284    fn output(offset: u64, bytes: &[u8]) -> Vec<u8> {
285        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
286        format!(
287            "event: output\ndata: {{\"offset\":{offset},\"stream\":\"stdout\",\
288             \"output\":\"{encoded}\"}}\n\n"
289        )
290        .into_bytes()
291    }
292
293    fn exit_frame(code: Option<i32>, signal: Option<i32>, timed_out: bool, total: u64) -> Vec<u8> {
294        let event = protocol::exec::ExitEvent {
295            exit_code: code,
296            signal,
297            timed_out,
298            truncated: false,
299            writers_may_be_alive: false,
300            offset: total,
301        };
302        format!(
303            "event: exit\ndata: {}\n\n",
304            serde_json::to_string(&event).expect("serializes")
305        )
306        .into_bytes()
307    }
308
309    fn started() -> Reply {
310        Reply::ok(serde_json::json!({"exec_id": "e1", "phase": "running"}))
311    }
312
313    fn running() -> Reply {
314        Reply::ok(serde_json::json!({"exec_id": "e1", "phase": "running"}))
315    }
316
317    fn killed(killed: bool) -> Reply {
318        Reply::ok(serde_json::json!({"exec_id": "e1", "killed": killed}))
319    }
320
321    /// A poll or ack body carrying an outcome.
322    fn finished(phase: &str, code: Option<i32>, signal: Option<i32>, stdout: &str) -> Reply {
323        Reply::ok(serde_json::json!({
324            "exec_id": "e1", "phase": phase, "exit_code": code, "signal": signal,
325            "timed_out": false, "stdout": stdout, "stderr": "", "truncated": false,
326            "writers_may_be_alive": false,
327        }))
328    }
329
330    fn request(timeout_sec: Option<f64>) -> protocol::exec::StartRequest {
331        protocol::exec::StartRequest {
332            exec_id: "e1".into(),
333            command: vec!["bash".into(), "-c".into(), "echo hi".into()],
334            shell: false.into(),
335            cwd: None,
336            env: Default::default(),
337            user: None,
338            group: None,
339            timeout_sec,
340            stdin: false,
341            reap_group_on_exit: false,
342            inherit_image_env: false,
343        }
344    }
345
346    type Seen = Arc<Mutex<Vec<(u64, Vec<u8>)>>>;
347
348    /// A sink that records every chunk, and answers `flow` for each.
349    fn recording_sink(flow: std::ops::ControlFlow<()>) -> (OutputSink, Seen) {
350        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
351        let into = Arc::clone(&seen);
352        let sink: OutputSink = Box::new(move |event| {
353            if let ExecEvent::Output { offset, data, .. } = event {
354                into.lock().expect("unpoisoned").push((offset, data));
355            }
356            Box::pin(std::future::ready(flow))
357        });
358        (sink, seen)
359    }
360
361    /// The method and path of every request, the shape each test asserts its sequence on.
362    fn route(recorder: &Recorder) -> Vec<String> {
363        recorder
364            .requests()
365            .iter()
366            .map(|request| {
367                let path = request.path.split('?').next().unwrap_or_default();
368                format!("{} {path}", request.method)
369            })
370            .collect()
371    }
372
373    fn options(grace_secs: u64) -> CompletionOptions {
374        CompletionOptions {
375            client_grace: Duration::from_secs(grace_secs),
376            stream: StreamOptions {
377                reconnect: false,
378                ..StreamOptions::default()
379            },
380        }
381    }
382
383    // ── BIND-8: the stream, and the fallback when it ends without an exit event ──
384
385    /// **BIND-8: an exit event is acked directly, and the callback saw every chunk in order.**
386    ///
387    /// The ack is the request that releases the output, so it is the one result; a poll here
388    /// would be a wasted round trip on every command a harness runs.
389    #[tokio::test(start_paused = true)]
390    async fn a_streamed_exit_event_is_acked_directly_with_every_chunk_delivered() {
391        let recorder = Recorder::with([
392            started(),
393            Reply::Chunks(
394                200,
395                vec![
396                    output(0, b"AB"),
397                    output(2, b"CD"),
398                    exit_frame(Some(0), None, false, 4),
399                ],
400            ),
401            finished("acked", Some(0), None, "ABCD"),
402        ]);
403        let (session, _, _) = session_with(Arc::clone(&recorder));
404        let (sink, seen) = recording_sink(std::ops::ControlFlow::Continue(()));
405
406        let result = session
407            .run_to_completion(request(None), options(60), Some(sink))
408            .await
409            .expect("completes");
410
411        assert_eq!(result.stdout(), "ABCD");
412        assert_eq!(result.posix_exit_code(), Some(0));
413        assert!(result.client_deadline.is_none());
414        assert_eq!(
415            *seen.lock().expect("unpoisoned"),
416            [(0, b"AB".to_vec()), (2, b"CD".to_vec())],
417            "the callback missed or reordered a chunk"
418        );
419        assert_eq!(
420            route(&recorder),
421            [
422                "POST /v1/exec/start",
423                "GET /v1/exec/e1/stream",
424                "POST /v1/exec/e1/ack"
425            ],
426            "an exit event needs one ack and nothing else"
427        );
428    }
429
430    /// **BIND-8: a stream cut before its exit event falls back to wait and ack.**
431    ///
432    /// **Falsification** — return the streamed bytes when the stream ends `Cut` and this is red
433    /// on the route: no poll, no ack, and a result nobody acked.
434    #[tokio::test(start_paused = true)]
435    async fn a_cut_stream_falls_back_to_wait_and_ack() {
436        let recorder = Recorder::with([
437            started(),
438            Reply::Chunks(200, vec![output(0, b"AB")]),
439            finished("exited", Some(0), None, "ABCD"),
440            finished("acked", Some(0), None, "ABCD"),
441        ]);
442        let (session, _, _) = session_with(Arc::clone(&recorder));
443        let (sink, seen) = recording_sink(std::ops::ControlFlow::Continue(()));
444
445        let result = session
446            .run_to_completion(request(None), options(60), Some(sink))
447            .await
448            .expect("the fallback returns the result");
449
450        assert_eq!(
451            result.stdout(),
452            "ABCD",
453            "the ack's output, not the stream's"
454        );
455        assert_eq!(seen.lock().expect("unpoisoned").len(), 1);
456        assert_eq!(
457            route(&recorder),
458            [
459                "POST /v1/exec/start",
460                "GET /v1/exec/e1/stream",
461                "GET /v1/exec/e1",
462                "POST /v1/exec/e1/ack"
463            ],
464            "a cut stream must fall back to wait and ack"
465        );
466    }
467
468    /// **BIND-8: a failed ack after the exit event falls back to wait and ack.**
469    #[tokio::test(start_paused = true)]
470    async fn a_failed_ack_after_the_exit_event_falls_back_to_wait_and_ack() {
471        let recorder = Recorder::with([
472            started(),
473            Reply::Chunks(
474                200,
475                vec![output(0, b"x"), exit_frame(Some(3), None, false, 1)],
476            ),
477            Reply::Cut("connection reset"),
478            finished("exited", Some(3), None, "x"),
479            finished("acked", Some(3), None, "x"),
480        ]);
481        let (session, _, _) = session_with(Arc::clone(&recorder));
482        let (sink, _) = recording_sink(std::ops::ControlFlow::Continue(()));
483
484        let result = session
485            .run_to_completion(request(None), options(60), Some(sink))
486            .await
487            .expect("the fallback returns the result");
488        assert_eq!(result.posix_exit_code(), Some(3));
489        assert_eq!(route(&recorder).len(), 5, "{:?}", route(&recorder));
490    }
491
492    /// **BIND-8: a callback that stops still gets the exec waited for and acked.**
493    ///
494    /// Stopping delivery is the caller's choice; leaving the exec un-acked would leave its
495    /// output buffered in the daemon until the TTL, and the call would return nothing.
496    #[tokio::test(start_paused = true)]
497    async fn a_callback_that_stops_still_gets_one_acked_result() {
498        let recorder = Recorder::with([
499            started(),
500            Reply::Chunks(
501                200,
502                vec![
503                    output(0, b"A"),
504                    output(1, b"B"),
505                    exit_frame(Some(0), None, false, 2),
506                ],
507            ),
508            finished("exited", Some(0), None, "AB"),
509            finished("acked", Some(0), None, "AB"),
510        ]);
511        let (session, _, _) = session_with(Arc::clone(&recorder));
512        let (sink, seen) = recording_sink(std::ops::ControlFlow::Break(()));
513
514        let result = session
515            .run_to_completion(request(None), options(60), Some(sink))
516            .await
517            .expect("completes");
518        assert_eq!(result.stdout(), "AB");
519        assert_eq!(
520            seen.lock().expect("unpoisoned").len(),
521            1,
522            "delivery stopped"
523        );
524        assert_eq!(
525            route(&recorder).last().map(String::as_str),
526            Some("POST /v1/exec/e1/ack")
527        );
528    }
529
530    /// **BIND-8: without a callback the call is one wait and one ack.**
531    #[tokio::test(start_paused = true)]
532    async fn without_a_callback_the_call_waits_and_acks() {
533        let recorder = Recorder::with([
534            started(),
535            running(),
536            finished("exited", None, Some(11), ""),
537            finished("acked", None, Some(11), ""),
538        ]);
539        let (session, _, _) = session_with(Arc::clone(&recorder));
540
541        let result = session
542            .run_to_completion(request(Some(30.0)), options(60), None)
543            .await
544            .expect("completes");
545        assert_eq!(result.posix_exit_code(), Some(128 + 11));
546        assert!(!route(&recorder).iter().any(|r| r.contains("stream")));
547    }
548
549    /// **BIND-8: a dropped ack in the wait is retried, not raised.**
550    ///
551    /// `wait_and_ack` retries a dropped poll but not a dropped ack, so a transient failure on
552    /// the one request that releases the output ended the call with an error while the result
553    /// sat in the daemon. The fuzz harness found it (a cut stream, then one failed ack).
554    ///
555    /// **Falsification** — remove the retry arm in `wait_and_ack_until` and this raises
556    /// `connection reset`. Verified.
557    #[tokio::test(start_paused = true)]
558    async fn a_dropped_ack_in_the_wait_is_retried() {
559        let recorder = Recorder::with([
560            started(),
561            finished("exited", Some(0), None, "out"),
562            Reply::Cut("connection reset"),
563            finished("exited", Some(0), None, "out"),
564            finished("acked", Some(0), None, "out"),
565        ]);
566        let (session, _, _) = session_with(Arc::clone(&recorder));
567
568        let result = session
569            .run_to_completion(request(Some(30.0)), options(60), None)
570            .await
571            .expect("the retried ack returns the result");
572        assert_eq!(result.stdout(), "out");
573        assert!(result.client_deadline.is_none());
574        assert_eq!(route(&recorder).len(), 5, "{:?}", route(&recorder));
575    }
576
577    // ── BIND-9: the client deadline kills before it acks ─────────────────────
578
579    /// **BIND-9: the client deadline kills the group, then acks within the grace.**
580    ///
581    /// `timeout_sec` 1 plus grace 1 is a two-second deadline; the poll loop sees `running` at
582    /// 0, 1, and 2 seconds and gives up. The kill must precede the post-deadline ack, and the
583    /// result must say the client's deadline ended it.
584    ///
585    /// **Falsification** — skip the kill and the route has no `kill`; ack before killing and
586    /// the order assertion is red.
587    #[tokio::test(start_paused = true)]
588    async fn a_client_deadline_kills_then_acks_within_the_grace() {
589        let recorder = Recorder::with([
590            started(),
591            running(),
592            running(),
593            running(),
594            killed(true),
595            finished("exited", None, Some(15), "partial"),
596            finished("acked", None, Some(15), "partial"),
597        ]);
598        let (session, _, _) = session_with(Arc::clone(&recorder));
599
600        let result = session
601            .run_to_completion(request(Some(1.0)), options(1), None)
602            .await
603            .expect("the killed exec's result");
604
605        let seen = route(&recorder);
606        let kill = seen
607            .iter()
608            .position(|r| r.ends_with("/kill"))
609            .expect("a kill");
610        let ack = seen
611            .iter()
612            .rposition(|r| r.ends_with("/ack"))
613            .expect("an ack");
614        assert!(kill < ack, "the ack went out before the kill: {seen:?}");
615        assert_eq!(result.stdout(), "partial");
616        assert_eq!(
617            result.client_deadline,
618            Some(ClientDeadline {
619                after: Duration::from_secs(2),
620                kill: KillAnswer::Signalled,
621                ack_error: None,
622            })
623        );
624        assert!(!result.synthesized());
625        assert_eq!(
626            result.posix_exit_code(),
627            Some(124),
628            "the client deadline ended it"
629        );
630        assert!(
631            result
632                .notes()
633                .iter()
634                .any(|note| note.contains("client deadline")),
635            "{:?}",
636            result.notes()
637        );
638    }
639
640    /// **BIND-9: a deadline while streaming kills too.** The stream goes silent, so only the
641    /// deadline ends it.
642    #[tokio::test(start_paused = true)]
643    async fn a_deadline_while_streaming_kills_then_acks() {
644        let recorder = Recorder::with([
645            started(),
646            Reply::Stalled(vec![output(0, b"tick\n")]),
647            killed(true),
648            finished("exited", None, Some(15), "tick\n"),
649            finished("acked", None, Some(15), "tick\n"),
650        ]);
651        let (session, _, _) = session_with(Arc::clone(&recorder));
652        let (sink, seen) = recording_sink(std::ops::ControlFlow::Continue(()));
653
654        let result = session
655            .run_to_completion(request(Some(5.0)), options(5), Some(sink))
656            .await
657            .expect("the killed exec's result");
658        assert_eq!(seen.lock().expect("unpoisoned").len(), 1);
659        assert_eq!(result.posix_exit_code(), Some(124));
660        assert_eq!(
661            route(&recorder)[2..],
662            [
663                "POST /v1/exec/e1/kill",
664                "GET /v1/exec/e1",
665                "POST /v1/exec/e1/ack"
666            ]
667        );
668    }
669
670    /// **BIND-9: a failed kill still gets the post-kill ack, and a command that exited by
671    /// itself keeps its own exit code.**
672    #[tokio::test(start_paused = true)]
673    async fn a_failed_kill_still_acks_and_keeps_the_commands_own_status() {
674        let recorder = Recorder::with([
675            started(),
676            running(),
677            running(),
678            running(),
679            Reply::Cut("connection reset"),
680            finished("exited", Some(0), None, "done"),
681            finished("acked", Some(0), None, "done"),
682        ]);
683        let (session, _, _) = session_with(Arc::clone(&recorder));
684
685        let result = session
686            .run_to_completion(request(Some(1.0)), options(1), None)
687            .await
688            .expect("the result arrives after the failed kill");
689        assert!(matches!(
690            result.client_deadline.as_ref().map(|d| &d.kill),
691            Some(KillAnswer::Failed(_))
692        ));
693        assert_eq!(
694            result.posix_exit_code(),
695            Some(0),
696            "nothing the client did ended it"
697        );
698        assert!(!result.synthesized());
699    }
700
701    // ── BIND-10: 124 is synthesized only when the post-kill ack fails ────────
702
703    /// **BIND-10: a successful kill followed by a failed ack synthesizes 124.**
704    #[tokio::test(start_paused = true)]
705    async fn a_failed_post_kill_ack_synthesizes_124() {
706        let recorder = Recorder::with([
707            started(),
708            running(),
709            running(),
710            running(),
711            killed(true),
712            running(),
713            running(),
714        ]);
715        let (session, _, _) = session_with(Arc::clone(&recorder));
716
717        let result = session
718            .run_to_completion(request(Some(1.0)), options(1), None)
719            .await
720            .expect("a synthesized result, not an error");
721        assert!(result.synthesized());
722        assert_eq!(result.posix_exit_code(), Some(124));
723        assert_eq!(
724            result.phase,
725            protocol::exec::Phase::Running,
726            "nothing was observed"
727        );
728        assert!(result.outcome.is_none());
729        assert!(
730            result
731                .notes()
732                .iter()
733                .any(|note| note.contains("synthesized")),
734            "{:?}",
735            result.notes()
736        );
737        assert_eq!(
738            route(&recorder).last().map(String::as_str),
739            Some("GET /v1/exec/e1")
740        );
741    }
742
743    /// **BIND-10: a failed kill and a failed ack synthesize 124, naming both.**
744    #[tokio::test(start_paused = true)]
745    async fn a_failed_kill_and_a_failed_ack_synthesize_124_naming_both() {
746        let recorder = Recorder::with([
747            started(),
748            running(),
749            running(),
750            running(),
751            Reply::Cut("kill refused by the test"),
752            Reply::Cut("poll refused by the test"),
753            Reply::Cut("poll refused by the test"),
754        ]);
755        let (session, _, _) = session_with(Arc::clone(&recorder));
756
757        let result = session
758            .run_to_completion(request(Some(1.0)), options(1), None)
759            .await
760            .expect("a synthesized result");
761        assert!(result.synthesized());
762        assert_eq!(result.posix_exit_code(), Some(124));
763        let notes = result.notes().join("\n");
764        assert!(notes.contains("kill refused by the test"), "{notes}");
765    }
766
767    // ── the plan ─────────────────────────────────────────────────────────────
768
769    /// The deadline is `timeout_sec` plus the grace, the ceiling without a `timeout_sec`,
770    /// and a `timeout_sec` no duration can be made of is refused before anything starts.
771    #[tokio::test]
772    async fn the_plan_computes_the_deadline_and_refuses_before_starting() {
773        let plan = CompletionPlan::new(&request(Some(30.0)), options(60)).expect("plans");
774        assert_eq!(plan.deadline(), Duration::from_secs(90));
775        let plan = CompletionPlan::new(&request(None), options(60)).expect("plans");
776        assert_eq!(plan.deadline(), NO_TIMEOUT_CEILING);
777
778        let recorder = Recorder::with([]);
779        let (session, _, _) = session_with(Arc::clone(&recorder));
780        for bad in [f64::NAN, -1.0, f64::INFINITY] {
781            let error = session
782                .run_to_completion(request(Some(bad)), options(60), None)
783                .await
784                .expect_err("refused");
785            assert_eq!(error.kind(), crate::ErrorKind::InvalidArg, "{bad}: {error}");
786        }
787        assert!(
788            recorder.requests().is_empty(),
789            "a refused plan started an exec"
790        );
791    }
792}