Skip to main content

microvms_core/
sandbox.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The `Sandbox` lifecycle: one VM's whole life, with the state machine the symspec
3//! model describes made true of the code.
4//!
5//! Composes [`crate::control`] and [`crate::session`] into the surface the CLI and the
6//! bindings use: build, run, suspend, resume, terminate, the launch-time suspended
7//! window (STATE-12), and teardown in the order that does not leak.
8//!
9//! # The lifecycle is a field, and every transition is guarded
10//!
11//! [`Lifecycle`] is the symspec's `vm_state` verbatim, and [`Sandbox`] carries the other
12//! four variables beside it — `token_installed`, `image_exists`, `was_terminated`,
13//! `bootstrap_count`. The Z3 proofs over that model (bootstrap at most once, suspend from
14//! non-RUNNING unreachable, TERMINATED never returns to RUNNING) are proofs about *this*
15//! struct's reachable states, which is only worth something if the transitions here are
16//! the only way to move it. They are: every one of those fields is private, and every
17//! mutation happens in one of the five methods below.
18//!
19//! # Runtime-checked rather than typestate, deliberately
20//!
21//! The packet offered `Sandbox<Running>` returning a `Suspended` handle, which would make
22//! STATE-5's wrong call a compile error rather than a local refusal — strictly stronger on
23//! the ladder in [`crate`]. It is not what landed, for the reason the packet names as
24//! acceptable: T-W3-8 wraps **one** object for PyO3 and napi-rs, and a type whose Rust
25//! identity changes on every transition cannot be one `#[pyclass]`. A typestate sandbox
26//! would be re-erased into a runtime-checked enum at the binding boundary, so the check
27//! would exist twice with the binding's copy being the one most callers actually hit.
28//!
29//! What is kept from the typestate idea is the part that costs nothing: the check happens
30//! **before** the wire call, so a suspend from SUSPENDED is refused with zero
31//! control-plane calls rather than answered by AWS. The test asserts the call count, which
32//! is the observable that distinguishes the two.
33//!
34//! # The suspended window is the client's alone (STATE-12)
35//!
36//! `suspendedDurationSeconds` exists only in the `RunMicrovm` **request**. `GetMicrovm`
37//! does not return it, so the client that sent the launch is the only party that can name
38//! the window it asked for — and the launch-time `idlePolicy` *terminates* a suspended VM
39//! once that window passes, which means "resume later" silently stops working. A resume
40//! past the window is refused locally, before `ResumeMicrovm`, because the alternative is
41//! calling and reading the failure: the service answers about a terminated id, which is
42//! not the same statement as "the window you set at launch closed", and getting there
43//! costs the full poll timeout first.
44//!
45//! # Teardown never raises, and the log group is last
46//!
47//! [`Sandbox::terminate`] returns a [`TeardownReport`] rather than a `Result`. It runs
48//! where a caller's `finally` would, and an error raised there replaces the real failure
49//! with a teardown failure — the real one being the one worth reading.
50//!
51//! The order is VM, then image (retrying), then the log group **last**, because the
52//! service can recreate a group deleted before its image. See
53//! [`TeardownReport::undeleted`] for what this crate can and cannot delete.
54//!
55//! # There is no `Drop` that tears down
56//!
57//! Rust has no context manager and `Drop` cannot await. A `Drop` that blocked on a runtime
58//! would deadlock inside one; a `Drop` that spawned would race the process exit. So
59//! [`Sandbox`]'s `Drop` only **warns** about a live VM, naming the id, and the rule is
60//! that a caller calls [`Sandbox::terminate`] explicitly.
61
62use std::sync::Arc;
63use std::time::Duration;
64
65use crate::control::{
66    ControlPlane, CreateImageRequest, Image, Microvm, RunHookPayload, RunMicrovmRequest, WaitOpts,
67};
68use crate::error::{Error, ErrorKind};
69use crate::region::Region;
70use crate::session::{Session, TokenMinter};
71
72/// The default launch wait: five minutes, matching the Python client's `ready_timeout_sec`.
73pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(300);
74
75/// The default lifecycle wait for suspend, resume, and terminate.
76pub const DEFAULT_LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(300);
77
78/// How many times a teardown retries the image delete.
79///
80/// Twenty, from the Python client: an image in `CREATING` refuses deletion and a VM still
81/// terminating holds a reference, so this is the difference between a clean account and a
82/// billed leak rather than politeness.
83pub const DEFAULT_DELETE_ATTEMPTS: u32 = 20;
84
85/// The gap between image-delete attempts.
86pub const DEFAULT_DELETE_BACKOFF: Duration = Duration::from_secs(15);
87
88/// How often a lifecycle wait polls.
89const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
90
91/// The symspec's `vm_state`, verbatim.
92///
93/// Six states and no others, which is the S1 half of this module: a lifecycle held as a
94/// `String` would let `"RUNNING "` and `"Running"` both exist, and every guard below would
95/// have to decide which it meant.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum Lifecycle {
98    /// The initial state, and the state a launch is accepted into (STATE-1).
99    Pending,
100    /// The run hook answered with a success status and the token is installed (STATE-2).
101    Running,
102    /// A suspend was accepted and the platform has not yet reported it complete (STATE-4).
103    Suspending,
104    /// The platform reported suspension complete (STATE-6).
105    Suspended,
106    /// A terminate was accepted (STATE-9).
107    Terminating,
108    /// The platform reported termination complete (STATE-10).
109    Terminated,
110}
111
112impl Lifecycle {
113    /// The name the service uses for this state, which is also what an error message says.
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Lifecycle::Pending => "PENDING",
117            Lifecycle::Running => "RUNNING",
118            Lifecycle::Suspending => "SUSPENDING",
119            Lifecycle::Suspended => "SUSPENDED",
120            Lifecycle::Terminating => "TERMINATING",
121            Lifecycle::Terminated => "TERMINATED",
122        }
123    }
124
125    /// Whether a VM in this state is still billing, which is what a `Drop` warning is for.
126    pub fn is_live(self) -> bool {
127        matches!(
128            self,
129            Lifecycle::Pending | Lifecycle::Running | Lifecycle::Suspending | Lifecycle::Suspended
130        )
131    }
132}
133
134impl std::fmt::Display for Lifecycle {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(self.as_str())
137    }
138}
139
140/// Everything a launch needs, with the defaults the Python client measured.
141///
142/// `agent_token` is optional because the common case is a per-VM secret nobody needs to
143/// see; a caller who has one already — a harness minting its own, or a retry that must
144/// reuse the first attempt's — passes it.
145#[derive(Clone, Debug)]
146pub struct RunRequest {
147    /// The image to launch, or `None` for the one [`Sandbox::build_image`] built.
148    pub image_identifier: Option<String>,
149    /// `imageVersion`, or `None` for the image's own latest active version.
150    ///
151    /// Pinning is what makes a canary a canary: without it a launch takes whatever version is
152    /// latest at the moment the call lands, which is not necessarily the one the caller just
153    /// built. Paired with [`crate::control::ControlPlane::set_image_version_status`] it is also
154    /// the rollback — set the bad version INACTIVE, re-pin here to the good one — and a version
155    /// set INACTIVE refuses to launch when named here.
156    ///
157    /// See [`crate::control::RunMicrovmRequest::image_version`], which this forwards to.
158    pub image_version: Option<String>,
159    /// The execution role. Optional in the model; every real launch needs one.
160    pub execution_role_arn: Option<String>,
161    /// The bearer token the daemon will accept, or `None` to mint one.
162    pub agent_token: Option<String>,
163    /// Base environment for every exec in the launched VM, delivered in the same
164    /// `runHookPayload` as the token.
165    ///
166    /// The daemon applies this *under* each request's own `env`, so a per-exec value
167    /// wins on a key both set. Empty by default, and an empty map produces byte-for-byte
168    /// the payload this client always sent — a caller who never touches this field
169    /// cannot be affected by the field existing.
170    ///
171    /// It shares the token's 4096-byte payload budget, and the check is local:
172    /// [`crate::control::RunHookPayload::for_launch`] refuses an over-ceiling payload
173    /// before any call, naming the byte count and how much of it the env is. That
174    /// matters here more than for the token, because one bearer token has always fit
175    /// with room to spare and a map of credentials does not.
176    pub launch_env: std::collections::HashMap<String, String>,
177    /// Whether to request the egress connector. Off means no outbound network.
178    pub egress: bool,
179    /// `idlePolicy.maxIdleDurationSeconds`.
180    pub max_idle_sec: u32,
181    /// `idlePolicy.suspendedDurationSeconds` — the window STATE-12 refuses past.
182    pub suspended_sec: u32,
183    /// `idlePolicy.autoResumeEnabled`.
184    pub auto_resume: bool,
185    /// `maximumDurationInSeconds`, checked against 1..=28800 before the call.
186    pub max_duration_sec: u32,
187    /// How long to wait for RUNNING.
188    pub ready_timeout: Duration,
189    /// A label for the run token (TRAP-1). Never the token.
190    pub token_scope: Option<String>,
191}
192
193impl Default for RunRequest {
194    fn default() -> Self {
195        Self {
196            image_identifier: None,
197            image_version: None,
198            execution_role_arn: None,
199            agent_token: None,
200            launch_env: std::collections::HashMap::new(),
201            egress: false,
202            max_idle_sec: 600,
203            suspended_sec: 600,
204            auto_resume: false,
205            max_duration_sec: 3_600,
206            ready_timeout: DEFAULT_READY_TIMEOUT,
207            token_scope: None,
208        }
209    }
210}
211
212impl RunRequest {
213    /// A launch with the measured defaults: ingress only, ten-minute idle and suspended
214    /// windows, a one-hour maximum duration, no auto-resume.
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Launches `identifier` rather than the built image.
220    #[must_use]
221    pub fn with_image(mut self, identifier: impl Into<String>) -> Self {
222        self.image_identifier = Some(identifier.into());
223        self
224    }
225
226    /// Requests the egress connector, which is what gives the VM outbound network.
227    #[must_use]
228    pub fn with_egress(mut self) -> Self {
229        self.egress = true;
230        self
231    }
232
233    /// Pins the launch to one `imageVersion`. See the field for why.
234    #[must_use]
235    pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
236        self.image_version = Some(version.into());
237        self
238    }
239
240    /// Sets the suspended window this sandbox will refuse a resume past (STATE-12).
241    #[must_use]
242    pub fn with_suspended_sec(mut self, seconds: u32) -> Self {
243        self.suspended_sec = seconds;
244        self
245    }
246
247    /// Reuses a caller-supplied agent token rather than minting one.
248    #[must_use]
249    pub fn with_agent_token(mut self, token: impl Into<String>) -> Self {
250        self.agent_token = Some(token.into());
251        self
252    }
253
254    /// Adds one launch-environment variable, which every exec in the VM starts with.
255    ///
256    /// One pair per call rather than a whole map, because that is how a caller builds
257    /// one — from flags, from a config file, one credential at a time — and a
258    /// map-taking setter makes the second call silently discard the first. The field is
259    /// public for a caller who really does hold a map.
260    #[must_use]
261    pub fn with_launch_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
262        self.launch_env.insert(key.into(), value.into());
263        self
264    }
265}
266
267/// What a teardown should delete beyond the VM itself.
268///
269/// Both deletions are opt-in, because both destroy something a caller may still want: the
270/// image is reusable across runs, and the log group is where a failed build's only evidence
271/// lives.
272#[derive(Clone, Copy, Debug)]
273pub struct TeardownOpts {
274    /// Whether to delete the image the sandbox built.
275    pub delete_image: bool,
276    /// Whether the build log group should be deleted.
277    ///
278    /// This crate **cannot** delete it — CloudWatch is not in the dependency set — so
279    /// asking names the group in [`TeardownReport::undeleted`] rather than removing it.
280    /// See that field for why naming is the honest answer rather than a silent success.
281    pub delete_log_group: bool,
282    /// How many times the image delete is retried.
283    pub delete_attempts: u32,
284    /// The gap between image-delete attempts.
285    pub delete_backoff: Duration,
286    /// How long to wait for TERMINATED, or `None` to return as soon as the terminate call
287    /// is accepted.
288    ///
289    /// `None` by default, matching the Python client: the caller is on the way out, and a
290    /// teardown that blocked five minutes on a state nobody reads is five minutes of a CI
291    /// job. A caller that needs STATE-10 *observed* passes
292    /// [`TeardownOpts::waiting_for_terminated`].
293    pub wait_for_terminated: Option<Duration>,
294}
295
296impl Default for TeardownOpts {
297    fn default() -> Self {
298        Self {
299            delete_image: false,
300            delete_log_group: false,
301            delete_attempts: DEFAULT_DELETE_ATTEMPTS,
302            delete_backoff: DEFAULT_DELETE_BACKOFF,
303            wait_for_terminated: None,
304        }
305    }
306}
307
308impl TeardownOpts {
309    /// Deletes the image as well as the VM.
310    #[must_use]
311    pub fn deleting_image(mut self) -> Self {
312        self.delete_image = true;
313        self
314    }
315
316    /// Asks for the log group too, which names it rather than deleting it.
317    #[must_use]
318    pub fn deleting_log_group(mut self) -> Self {
319        self.delete_log_group = true;
320        self
321    }
322
323    /// Waits for TERMINATED before returning (STATE-10).
324    #[must_use]
325    pub fn waiting_for_terminated(mut self) -> Self {
326        self.wait_for_terminated = Some(DEFAULT_LIFECYCLE_TIMEOUT);
327        self
328    }
329}
330
331/// What a teardown did, and what it left behind.
332///
333/// Returned rather than raised. See the module docs: this runs where a `finally` would.
334#[derive(Clone, Debug, Default, Eq, PartialEq)]
335pub struct TeardownReport {
336    /// Identifiers of everything a caller asked to have deleted that still exists.
337    ///
338    /// The CLI emits these (CLI-6), which is the whole reason they are identifiers rather
339    /// than a boolean: a leak nobody can name is a leak nobody can clean up.
340    ///
341    /// Two things land here. A delete that was attempted and failed — an image whose twenty
342    /// attempts all hit a conflict. And the build **log group**, which this crate cannot
343    /// delete at all, because CloudWatch is absent from the dependency set T-W2-2 froze and
344    /// this lane adds only source files. Naming it is strictly better than the
345    /// alternatives: deleting it is unavailable, and silently succeeding would report a
346    /// clean teardown over six accumulated log groups — which is how the leak was found in
347    /// the first place.
348    pub undeleted: Vec<String>,
349    /// Whether the terminate call was accepted.
350    pub terminate_accepted: bool,
351    /// Whether the image was deleted, or `None` when deletion was not asked for.
352    pub image_deleted: Option<bool>,
353    /// The lifecycle state the sandbox ended in.
354    pub lifecycle: Option<Lifecycle>,
355    /// Every failure the teardown swallowed, in the order it hit them.
356    ///
357    /// Kept because a teardown that never raises is a teardown whose failures are invisible
358    /// otherwise, and the first one is usually the cause of the rest.
359    pub failures: Vec<String>,
360}
361
362impl TeardownReport {
363    /// Whether anything a caller asked for was left behind.
364    pub fn leaked(&self) -> bool {
365        !self.undeleted.is_empty()
366    }
367}
368
369/// Mints proxy tokens for one MicroVM through the control plane.
370///
371/// The bridge between the two lanes: `ControlPlane::mint_auth_token` answers a
372/// `control::ProxyToken`, [`TokenMinter`] wants a `session::ProxyToken`, and the `From`
373/// impl at `session/proxy.rs` is the conversion — one `.into()` at the boundary, which is
374/// why that impl is `From` rather than a named function.
375///
376/// The plane sits behind an `Arc` rather than a reference because the minter must outlive
377/// the call that built the session: minting happens inside the request path on every later
378/// request, which is what makes it happen at all (TRAP-9).
379struct ControlPlaneMinter {
380    control: Arc<ControlPlane>,
381    microvm_id: String,
382}
383
384impl TokenMinter for ControlPlaneMinter {
385    fn mint(
386        &self,
387    ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
388        Box::pin(async move {
389            let minted = self.control.mint_auth_token(&self.microvm_id).await?;
390            Ok(minted.into())
391        })
392    }
393
394    /// Overrides the default, which ignores the ports and delegates.
395    ///
396    /// This is the minter that has a control plane behind it, so it is the one that can
397    /// actually widen a token's scope — and without this override
398    /// `Session::connect_headers(8080)` would keep answering a header pair behind a token
399    /// scoped to 9000 only, which the proxy refuses with 403 `Access to port denied`. See
400    /// [`TokenMinter::mint_for_ports`] for the measurement.
401    fn mint_for_ports(
402        &self,
403        ports: &[u16],
404    ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
405        let specs: Vec<crate::control::ops::PortSpecification> = ports
406            .iter()
407            .map(|port| crate::control::ops::PortSpecification::port(*port))
408            .collect();
409        Box::pin(async move {
410            let minted = self
411                .control
412                .mint_auth_token_for(&self.microvm_id, &specs)
413                .await?;
414            Ok(minted.into())
415        })
416    }
417}
418
419/// One MicroVM's whole life.
420///
421/// See the module docs for the state machine, the window, and why teardown is explicit.
422pub struct Sandbox {
423    control: Arc<ControlPlane>,
424    image: Option<Image>,
425    microvm: Option<Microvm>,
426    session: Option<Session>,
427    /// A backend for the session [`Sandbox::run`] builds, or `None` for the real HTTP one.
428    ///
429    /// The daemon-side sibling of `with_control_plane`'s transport seam: a test that
430    /// scripts the control plane can launch, but the session `run` then builds would dial
431    /// a real endpoint. This lets the same test script the daemon too. Never set in
432    /// production — `Session::builder` picks reqwest when no backend is given.
433    session_backend: Option<crate::session::SharedBackend>,
434
435    // ── the symspec's five variables ─────────────────────────────────────────
436    lifecycle: Lifecycle,
437    token_installed: bool,
438    image_exists: bool,
439    was_terminated: bool,
440    bootstrap_count: u32,
441
442    /// The window from *our own* `RunMicrovm` request. `GetMicrovm` does not return it.
443    suspended_window: Option<Duration>,
444    /// The clock reading when the suspend call was accepted, or `None` when not suspended.
445    suspended_at: Option<Duration>,
446    /// Set by [`Sandbox::terminate`], so `Drop` can tell an abandoned VM from a torn-down
447    /// one.
448    torn_down: bool,
449}
450
451impl std::fmt::Debug for Sandbox {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        // No agent token: it is a credential, and a `Debug` that printed it would put it in
454        // every log line that formats a sandbox.
455        f.debug_struct("Sandbox")
456            .field("lifecycle", &self.lifecycle)
457            .field("microvm", &self.microvm.as_ref().map(|vm| &vm.id))
458            .field("image", &self.image.as_ref().map(|image| &image.identifier))
459            .field("token_installed", &self.token_installed)
460            .field("bootstrap_count", &self.bootstrap_count)
461            .field("was_terminated", &self.was_terminated)
462            .field("suspended_window", &self.suspended_window)
463            .finish_non_exhaustive()
464    }
465}
466
467impl Sandbox {
468    /// Resolves credentials for `region` and returns a sandbox with nothing launched.
469    ///
470    /// TRAP-6 is closed by [`Region`] before this is reached, which is why there is no
471    /// region check here.
472    pub async fn new(region: Region) -> Result<Self, Error> {
473        Ok(Self::with_control_plane(ControlPlane::new(region).await?))
474    }
475
476    /// A sandbox over an already-built [`ControlPlane`].
477    ///
478    /// The seam every test uses, and public for the same reason `ControlPlane::with_transport`
479    /// is: a caller who wants a non-default port, or a fake transport, has already built the
480    /// plane, and a sandbox constructible only from a [`Region`] would be a sandbox no test
481    /// can drive without AWS.
482    ///
483    /// The plane's own clock is what times the suspended window, rather than a second one
484    /// this type would own. Two clocks in one lifecycle is the trap the daemon's turmoil
485    /// tests record: a window measured on one clock while the poll loop sleeps on another
486    /// calls a closed window open.
487    pub fn with_control_plane(control: ControlPlane) -> Self {
488        Self {
489            control: Arc::new(control),
490            image: None,
491            microvm: None,
492            session: None,
493            session_backend: None,
494            lifecycle: Lifecycle::Pending,
495            token_installed: false,
496            image_exists: false,
497            was_terminated: false,
498            bootstrap_count: 0,
499            suspended_window: None,
500            suspended_at: None,
501            torn_down: false,
502        }
503    }
504
505    /// Routes the session [`Sandbox::run`] builds through `backend` instead of real HTTP.
506    ///
507    /// The daemon-side half of the test seam `with_control_plane` opens: scripting the
508    /// control plane gets a test through the launch, and this gets it through everything
509    /// the launched session then does — uploads, execs, downloads — without a daemon.
510    pub fn with_session_backend(mut self, backend: crate::session::SharedBackend) -> Self {
511        self.session_backend = Some(backend);
512        self
513    }
514
515    // ── the symspec's five variables, readable ───────────────────────────────
516
517    /// The lifecycle state, which is the symspec's `vm_state`.
518    pub fn lifecycle(&self) -> Lifecycle {
519        self.lifecycle
520    }
521
522    /// Whether the agent token has been installed (STATE-2).
523    pub fn token_installed(&self) -> bool {
524        self.token_installed
525    }
526
527    /// Whether an image is recorded as existing (STATE-1).
528    pub fn image_exists(&self) -> bool {
529        self.image_exists
530    }
531
532    /// Whether this VM was ever terminated (STATE-11).
533    pub fn was_terminated(&self) -> bool {
534        self.was_terminated
535    }
536
537    /// How many times the token has been installed. Never above one (STATE-3).
538    pub fn bootstrap_count(&self) -> u32 {
539        self.bootstrap_count
540    }
541
542    /// The image, once built.
543    pub fn image(&self) -> Option<&Image> {
544        self.image.as_ref()
545    }
546
547    /// The VM as the service last described it.
548    pub fn microvm(&self) -> Option<&Microvm> {
549        self.microvm.as_ref()
550    }
551
552    /// The session, once launched.
553    pub fn session(&self) -> Option<&Session> {
554        self.session.as_ref()
555    }
556
557    /// The suspended window this sandbox asked for at launch, once it has launched.
558    pub fn suspended_window(&self) -> Option<Duration> {
559        self.suspended_window
560    }
561
562    // ── build ────────────────────────────────────────────────────────────────
563
564    /// Builds an image and waits for it to become usable.
565    ///
566    /// Every local guard runs inside [`ControlPlane::create_image`], before the call —
567    /// which matters because the create happens *after* the caller's artifact upload, so a
568    /// rejection AWS raises costs the upload first.
569    pub async fn build_image(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
570        let size = request.size;
571        let created = self.control.create_image(request).await?;
572        let built = self
573            .control
574            .wait_for_image(&created.identifier, size, WaitOpts::default())
575            .await?;
576        // Recorded before any launch, because a built image exists whether or not anything
577        // is ever run from it — and the teardown has to be able to name it either way.
578        self.image_exists = true;
579        self.image = Some(built);
580        Ok(self.image.as_ref().expect("just assigned"))
581    }
582
583    /// The artifact bytes to upload to the request's `code_artifact_uri`.
584    ///
585    /// The upload is the caller's: S3 is not in this crate's dependency set.
586    pub fn build_artifact_for(&self, request: &CreateImageRequest) -> Result<Vec<u8>, Error> {
587        self.control.build_artifact_for(request)
588    }
589
590    /// Every local guard [`Self::build_image`] runs, callable **before** the artifact upload.
591    ///
592    /// A delegation to [`ControlPlane::preflight`], on this type so the CLI's build path
593    /// reaches it through the one sandbox it already holds. Local; zero calls. The point is
594    /// ordering: `build_image`'s guards run after the caller's upload, so a request this
595    /// client itself refuses would still cost the S3 PUT unless the caller checks first.
596    pub fn preflight(&self, request: &CreateImageRequest) -> Result<(), Error> {
597        self.control.preflight(request)
598    }
599
600    /// The ARN for `identifier`: an ARN passes through with zero calls, a bare name is
601    /// resolved through the image listing by exact match.
602    ///
603    /// A read-only delegation to [`ControlPlane::resolve_image_arn`], on this type so the
604    /// CLI's launch path reaches it through the one sandbox it already holds. It touches
605    /// none of the five state-machine variables — resolution is a question about the
606    /// account, not about this VM's lifecycle.
607    pub async fn resolve_image_arn(&self, identifier: &str) -> Result<String, Error> {
608        self.control.resolve_image_arn(identifier).await
609    }
610
611    /// The image with exactly `name`, or `None`. Read-only; see
612    /// [`ControlPlane::find_image_by_name`] for the pagination and exact-match rules.
613    pub async fn find_image_by_name(
614        &self,
615        name: &str,
616    ) -> Result<Option<crate::control::ops::MicrovmImageSummaryWire>, Error> {
617        self.control.find_image_by_name(name).await
618    }
619
620    /// The content hash `build --reuse` keys an image name to. Local; zero calls.
621    pub fn artifact_content_hash_for(&self, request: &CreateImageRequest) -> String {
622        self.control.artifact_content_hash_for(request)
623    }
624
625    /// The versions of a managed base image, for pinning
626    /// [`CreateImageRequest::base_image_version`].
627    ///
628    /// A read-only delegation to [`crate::control::ControlPlane::managed_base_versions`], on
629    /// this type for the reason [`Self::resolve_image_arn`] gives: it touches none of the five
630    /// state-machine variables, because a question about AWS's published bases is not a
631    /// question about this VM's lifecycle.
632    pub async fn managed_base_versions(
633        &self,
634        base_image_arn: &str,
635    ) -> Result<Vec<crate::control::ops::ManagedMicrovmImageVersionWire>, Error> {
636        self.control.managed_base_versions(base_image_arn).await
637    }
638
639    /// One version's configuration, state, and availability status. Read-only.
640    pub async fn image_version(
641        &self,
642        identifier: &str,
643        version: &str,
644    ) -> Result<crate::control::ops::MicrovmImageVersionSummaryWire, Error> {
645        self.control.get_image_version(identifier, version).await
646    }
647
648    // ── run (STATE-1, STATE-2, STATE-3) ──────────────────────────────────────
649
650    /// Launches a MicroVM, waits for RUNNING, and returns its session.
651    ///
652    /// # The three state requirements this is
653    ///
654    /// STATE-1: the accepted launch moves the lifecycle to PENDING and records the image as
655    /// existing. STATE-2: the platform reporting RUNNING is what marks the token installed
656    /// — not the launch call, because the run hook is what delivers it and a launch that
657    /// dies during startup delivered nothing. STATE-3: `bootstrap_count` is incremented
658    /// exactly here, and a second `run` on the same sandbox is refused, which is what makes
659    /// "at most once per VM lifetime" a property of the type rather than of a caller's
660    /// discipline.
661    ///
662    /// The agent token rides in `runHookPayload`, which is what keeps it out of the shared
663    /// image snapshot. That is safe because the platform forwards no external traffic until
664    /// the run hook returns 200, so a per-VM secret delivered at launch wins the
665    /// first-writer race through the endpoint.
666    pub async fn run(&mut self, request: RunRequest) -> Result<&mut Session, Error> {
667        // STATE-3's local half. A sandbox that has already bootstrapped cannot bootstrap
668        // again, and the refusal is here rather than in a comment because `run` twice is
669        // the plausible mistake — a retry loop around a launch that timed out.
670        if self.bootstrap_count > 0 || self.microvm.is_some() {
671            return Err(Error::invalid_arg(format!(
672                "this sandbox has already launched a VM ({} bootstrap(s), lifecycle {}), and the \
673                 agent token is installed at most once per VM lifetime (STATE-3). A second VM \
674                 needs a second Sandbox — reusing this one would either re-deliver a run-hook \
675                 payload to a daemon whose one-shot bootstrap refuses it, or silently address \
676                 two guests through one handle.",
677                self.bootstrap_count, self.lifecycle,
678            )));
679        }
680
681        let Some(identifier) = request
682            .image_identifier
683            .clone()
684            .or_else(|| self.image.as_ref().map(|image| image.identifier.clone()))
685        else {
686            return Err(Error::new(
687                ErrorKind::Precondition,
688                "no image to launch: pass RunRequest::with_image or call build_image first."
689                    .to_string(),
690            ));
691        };
692
693        let agent_token = request.agent_token.clone().unwrap_or_else(mint_agent_token);
694        // Checked even though this builds the JSON itself, because neither half is ours:
695        // the token may be caller-supplied, so someone passing a signed blob rather than a
696        // bearer token is exactly who this catches (TRAP-5), and the launch env is entirely
697        // the caller's. This is the pre-flight refusal — over-ceiling fails here with the
698        // byte count, before the launch, rather than as a `ValidationException` on a member
699        // the caller did not know they were filling.
700        let payload = RunHookPayload::for_launch(&agent_token, &request.launch_env)?;
701
702        let mut wire = RunMicrovmRequest::new(&identifier, payload);
703        wire.image_version = request.image_version.clone();
704        wire.execution_role_arn = request.execution_role_arn.clone();
705        wire.max_idle_sec = request.max_idle_sec;
706        wire.suspended_sec = request.suspended_sec;
707        wire.auto_resume = request.auto_resume;
708        wire.max_duration_sec = request.max_duration_sec;
709        wire.token_scope = request.token_scope.clone();
710        if request.egress {
711            wire = wire.with_egress();
712        }
713
714        let launched = self.control.run_microvm(wire).await?;
715
716        // STATE-1: the launch was accepted.
717        self.lifecycle = Lifecycle::Pending;
718        self.image_exists = true;
719        // Recorded here rather than after the wait, because the window the idlePolicy
720        // enforces was set by *this* request and a launch that then fails still leaves a VM
721        // the caller may have to reason about.
722        self.suspended_window = Some(Duration::from_secs(u64::from(request.suspended_sec)));
723        let id = launched.id.clone();
724        self.microvm = Some(launched);
725
726        let running = self
727            .control
728            .wait_for_running(
729                &id,
730                WaitOpts {
731                    timeout: request.ready_timeout,
732                    poll_interval: LIFECYCLE_POLL_INTERVAL,
733                    stall_grace: Duration::MAX,
734                },
735            )
736            .await?;
737
738        // STATE-2. The platform reported the run hook succeeded, so the token is in the
739        // guest's memory — and this is the one place that counts it (STATE-3).
740        self.lifecycle = Lifecycle::Running;
741        self.token_installed = true;
742        self.bootstrap_count += 1;
743        let endpoint = running.endpoint.clone();
744        self.microvm = Some(running);
745
746        let minter = Arc::new(ControlPlaneMinter {
747            control: Arc::clone(&self.control),
748            microvm_id: id,
749        });
750        let mut builder = Session::builder(endpoint, agent_token)
751            .with_minter(minter)
752            .with_port(self.control.port());
753        if let Some(backend) = &self.session_backend {
754            builder = builder.with_backend(Arc::clone(backend));
755        }
756        self.session = Some(builder.build()?);
757        Ok(self.session.as_mut().expect("just assigned"))
758    }
759
760    // ── suspend (STATE-4, STATE-5, STATE-6) ──────────────────────────────────
761
762    /// Freezes the VM and waits for the platform to report it.
763    ///
764    /// A freeze and restore rather than a stop and start: the guest keeps its memory, so the
765    /// token, the filesystem, and every exec record survive. The one thing that does not is
766    /// the guest's view of time — it observes the whole suspension as a single jump, so any
767    /// timeout, lease, or TLS session a running command holds expires at once on resume.
768    ///
769    /// # STATE-5 is checked before the wire, not after
770    ///
771    /// A suspend from anything but RUNNING is refused here with **zero** control-plane
772    /// calls. That is the observable difference between this and a client that lets AWS
773    /// answer, and it is what the test asserts on.
774    pub async fn suspend(&mut self) -> Result<(), Error> {
775        let id = self.require_microvm("suspend")?;
776
777        // STATE-5.
778        if self.lifecycle != Lifecycle::Running {
779            return Err(Error::invalid_arg(format!(
780                "microvm {id} is {} and a suspend is only issued from RUNNING (STATE-5). Refused \
781                 here rather than by the service, because the service's answer about a \
782                 non-running id does not say which of the two things went wrong — and a suspend \
783                 issued from SUSPENDED is a caller who believes they resumed.",
784                self.lifecycle,
785            )));
786        }
787
788        // STATE-4: accepted, so the lifecycle moves before the wait. Acceptance is the
789        // wire call succeeding, so the assignment comes after it: moving to SUSPENDING
790        // first would leave a failed call (a throttle, a dead transport) stuck in a state
791        // neither suspend nor resume accepts, bricking the handle over one bad request.
792        self.control.suspend(&id).await?;
793        self.lifecycle = Lifecycle::Suspending;
794        // Stamped after the call and before the wait, not after the wait: the idlePolicy's
795        // window starts when the platform begins suspending, so timing it from SUSPENDED
796        // would under-count the transition and call a closed window open.
797        self.suspended_at = Some(self.control.clock().elapsed());
798
799        let settled = self
800            .control
801            .wait_for_state(
802                &id,
803                &crate::control::microvm::SUSPEND_WANTED,
804                &[],
805                self.lifecycle_wait(),
806            )
807            .await?;
808
809        // STATE-6, and the TERMINATED case beside it. A VM that dies while suspending is a
810        // state to report rather than an exception out of the middle of a teardown, so the
811        // wait *wants* TERMINATED — and recording it here is what stops a resume from being
812        // offered afterwards (STATE-11).
813        self.lifecycle = match settled.state.as_str() {
814            "SUSPENDED" => Lifecycle::Suspended,
815            "TERMINATED" => {
816                self.was_terminated = true;
817                Lifecycle::Terminated
818            }
819            other => {
820                return Err(Error::new(
821                    ErrorKind::Platform,
822                    format!(
823                        "the suspend wait returned {other}, which is neither SUSPENDED nor \
824                         TERMINATED — and those two are what this client asked for."
825                    ),
826                ));
827            }
828        };
829        self.microvm = Some(settled);
830        Ok(())
831    }
832
833    // ── resume (STATE-7, STATE-8, STATE-12) ──────────────────────────────────
834
835    /// Thaws the VM and returns a usable session.
836    ///
837    /// # What is deliberately not re-delivered (STATE-7)
838    ///
839    /// Nothing. No run-hook payload, no token, no bootstrap. The in-memory token survived
840    /// the freeze, and re-delivering it would hit the daemon's one-shot bootstrap and be
841    /// refused — a 409 that reads like a broken VM.
842    ///
843    /// # The window is checked first (STATE-12)
844    ///
845    /// Before any wire call, because the answer is already known and calling costs the poll
846    /// timeout to learn something worse. The falsification is a service reporting
847    /// TERMINATED: without this check the resume burns the full deadline and then reports a
848    /// state the client could have named at once.
849    ///
850    /// # The proxy token is dropped (STATE-8)
851    ///
852    /// [`Session::rebind`] invalidates it. The endpoint URL does not change across
853    /// suspend/resume, so the rebind is usually a no-op on the URL — but a token minted
854    /// against the pre-suspend instance may no longer validate, and that rejection reads
855    /// exactly like a dead daemon.
856    pub async fn resume(&mut self) -> Result<&mut Session, Error> {
857        let id = self.require_microvm("resume")?;
858
859        // STATE-11's local half: a terminated VM never returns to RUNNING, so the refusal
860        // comes before the window check and before any call.
861        if self.was_terminated || self.lifecycle == Lifecycle::Terminated {
862            return Err(Error::invalid_arg(format!(
863                "microvm {id} was terminated, and a terminated VM never returns to RUNNING \
864                 (STATE-11). There is nothing to resume: the guest's memory is gone, so even a \
865                 call the service accepted would hand back a different machine."
866            )));
867        }
868        if self.lifecycle != Lifecycle::Suspended {
869            return Err(Error::invalid_arg(format!(
870                "microvm {id} is {} and a resume is only issued from SUSPENDED (STATE-7).",
871                self.lifecycle,
872            )));
873        }
874
875        // STATE-12, first and locally.
876        self.require_open_suspended_window(&id)?;
877
878        self.control.resume(&id).await?;
879        // `fail_on` is the *dead* states rather than the terminal ones: SUSPENDED is the
880        // state this call was made from, so failing on it would fail every resume. A VM the
881        // idlePolicy terminated during suspension never reaches RUNNING, and waiting only
882        // for RUNNING there burns the full timeout and then reports a timeout message
883        // hiding a cause the service had already stated in `stateReason`.
884        let running = self
885            .control
886            .wait_for_state(
887                &id,
888                &["RUNNING"],
889                &crate::constants::DEAD_STATES,
890                self.lifecycle_wait(),
891            )
892            .await?;
893
894        self.lifecycle = Lifecycle::Running;
895        // STATE-8, through the endpoint the service just reported rather than the one held:
896        // the URL is measured not to change, and reading it from the response is what makes
897        // that a fact this code depends on rather than an assumption it encodes.
898        let endpoint = running.endpoint.clone();
899        self.microvm = Some(running);
900        if let Some(session) = self.session.as_mut() {
901            session.rebind(endpoint);
902        }
903        // Cleared on success so the next cycle's window is measured from the next suspend.
904        // Leaving it set would accumulate every suspension's elapsed time into one total and
905        // reject a resume whose own window is wide open.
906        self.suspended_at = None;
907
908        self.session.as_mut().ok_or_else(|| {
909            Error::new(
910                ErrorKind::Unexpected,
911                "the VM resumed but this sandbox holds no session, which run() always builds"
912                    .to_string(),
913            )
914        })
915    }
916
917    /// Rejects a resume the launch-time `idlePolicy` has already made impossible (STATE-12).
918    ///
919    /// The message names the elapsed time, the window, and the `idlePolicy` finding, because
920    /// "cannot resume" alone sends a reader looking for the flag that reopens it.
921    fn require_open_suspended_window(&self, id: &str) -> Result<(), Error> {
922        let (Some(window), Some(since)) = (self.suspended_window, self.suspended_at) else {
923            // No window recorded means this sandbox did not send the launch — the attach
924            // path — and guessing a default would refuse a resume the service would honour.
925            return Ok(());
926        };
927        let elapsed = self.control.clock().elapsed().saturating_sub(since);
928        if elapsed <= window {
929            return Ok(());
930        }
931        Err(Error::new(
932            ErrorKind::WindowClosed,
933            format!(
934                "microvm {id} has been suspended {}s, past the {}s suspendedDurationSeconds \
935                 window set at launch — the idlePolicy terminates a suspended VM once that window \
936                 passes, so there is nothing left to resume (docs/PLATFORM.md, '`idlePolicy`'). \
937                 Refused before ResumeMicrovm because suspendedDurationSeconds exists only in the \
938                 RunMicrovm request: GetMicrovm does not return it, so this client is the only \
939                 party that can name the number. A longer window has to be set at launch on the \
940                 next VM; there is no call that extends this one.",
941                elapsed.as_secs(),
942                window.as_secs(),
943            ),
944        ))
945    }
946
947    // ── terminate (STATE-9, STATE-10) ────────────────────────────────────────
948
949    /// Tears down, best-effort, never erroring.
950    ///
951    /// Order: VM, then image, then the log group **last**. The log group is last because the
952    /// service can recreate a group deleted before its image — and see
953    /// [`TeardownReport::undeleted`] for why this crate names it rather than deleting it.
954    pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
955        let mut report = TeardownReport::default();
956        self.torn_down = true;
957
958        // The session first: it holds a cached proxy token whose only remaining use would be
959        // a request against a VM that is going away.
960        self.session = None;
961
962        // 1. The VM.
963        if let Some(id) = self.microvm.as_ref().map(|vm| vm.id.clone()) {
964            // STATE-9. Recorded before the call, so a terminate whose call fails still marks
965            // the VM as one this client asked to destroy — which is what stops a later
966            // resume (STATE-11) rather than leaving the sandbox looking resumable.
967            self.lifecycle = Lifecycle::Terminating;
968            self.was_terminated = true;
969
970            match self.control.terminate(&id).await {
971                Ok(()) => report.terminate_accepted = true,
972                Err(error) => {
973                    report.failures.push(format!("terminate {id}: {error}"));
974                    report.undeleted.push(id.clone());
975                }
976            }
977
978            if report.terminate_accepted
979                && let Some(timeout) = opts.wait_for_terminated
980            {
981                let wait = WaitOpts {
982                    timeout,
983                    poll_interval: LIFECYCLE_POLL_INTERVAL,
984                    stall_grace: Duration::MAX,
985                };
986                match self
987                    .control
988                    .wait_for_state(&id, &["TERMINATED"], &[], wait)
989                    .await
990                {
991                    // STATE-10.
992                    Ok(settled) => {
993                        self.lifecycle = Lifecycle::Terminated;
994                        self.microvm = Some(settled);
995                    }
996                    // Not a leak: the platform accepted the terminate, so the VM is on its
997                    // way out and the lifecycle stays TERMINATING honestly.
998                    Err(error) => report
999                        .failures
1000                        .push(format!("waiting for {id} to reach TERMINATED: {error}")),
1001                }
1002            }
1003        }
1004
1005        // 2. The image, retrying — an image in CREATING refuses deletion and a VM still
1006        //    terminating holds a reference.
1007        if opts.delete_image {
1008            match self.image.as_ref().map(|image| image.identifier.clone()) {
1009                Some(identifier) => {
1010                    let deleted = self
1011                        .control
1012                        .delete_image(&identifier, opts.delete_attempts, opts.delete_backoff)
1013                        .await;
1014                    report.image_deleted = Some(deleted);
1015                    if deleted {
1016                        self.image_exists = false;
1017                    } else {
1018                        report.failures.push(format!(
1019                            "the image {identifier} survived {} delete attempts",
1020                            opts.delete_attempts.max(1)
1021                        ));
1022                        report.undeleted.push(identifier);
1023                    }
1024                }
1025                None => report.image_deleted = Some(false),
1026            }
1027        }
1028
1029        // 3. The log group, LAST. Named rather than deleted; see TeardownReport::undeleted.
1030        if opts.delete_log_group
1031            && let Some(group) = self.image.as_ref().map(Image::build_log_group)
1032        {
1033            report.failures.push(format!(
1034                "the build log group {group} was not deleted: CloudWatch Logs is not in this \
1035                 crate's dependency set, so it is reported rather than removed. It is \
1036                 service-created, which means no Terraform stack owns it and `terraform destroy` \
1037                 leaves it behind — six accumulated before anyone noticed."
1038            ));
1039            report.undeleted.push(group);
1040        }
1041
1042        report.lifecycle = Some(self.lifecycle);
1043        report
1044    }
1045
1046    // ── internals ────────────────────────────────────────────────────────────
1047
1048    /// The VM id, or a precondition error naming what was attempted.
1049    fn require_microvm(&self, what: &str) -> Result<String, Error> {
1050        self.microvm
1051            .as_ref()
1052            .map(|vm| vm.id.clone())
1053            .ok_or_else(|| {
1054                Error::new(
1055                    ErrorKind::Precondition,
1056                    format!("nothing to {what}: this sandbox has not launched a VM."),
1057                )
1058            })
1059    }
1060
1061    /// Five minutes at five-second polls, for suspend, resume, and terminate.
1062    fn lifecycle_wait(&self) -> WaitOpts {
1063        WaitOpts {
1064            timeout: DEFAULT_LIFECYCLE_TIMEOUT,
1065            poll_interval: LIFECYCLE_POLL_INTERVAL,
1066            stall_grace: Duration::MAX,
1067        }
1068    }
1069}
1070
1071/// Warns about a live VM rather than tearing it down.
1072///
1073/// See the module docs: `Drop` cannot await, so a teardown here would either deadlock
1074/// inside a runtime or race the process exit. The warning names the id, because the only
1075/// useful thing a drop can do is tell whoever reads stderr what to go delete.
1076///
1077/// `eprintln!` rather than a log macro because this crate has no logging dependency, and
1078/// taking one on to warn about a leak would be a dependency for a diagnostic.
1079impl Drop for Sandbox {
1080    fn drop(&mut self) {
1081        if self.torn_down {
1082            return;
1083        }
1084        if let Some(vm) = self.microvm.as_ref()
1085            && self.lifecycle.is_live()
1086        {
1087            eprintln!(
1088                "warning: the Sandbox for microvm {} was dropped in {} without terminate(). \
1089                 Nothing was torn down — Drop cannot await, so a teardown here would deadlock \
1090                 inside a runtime. The VM bills until its maximumDurationInSeconds ceiling: \
1091                 terminate it with `microvm terminate {}`.",
1092                vm.id, self.lifecycle, vm.id,
1093            );
1094        }
1095    }
1096}
1097
1098/// A fresh per-VM bearer token: 32 bytes of randomness as 64 hex characters.
1099///
1100/// # Why `/dev/urandom` and not a crate
1101///
1102/// The dependency set T-W2-2 froze carries no CSPRNG and this lane adds only source files,
1103/// so what remains is the kernel pool directly — which is what `getrandom` reaches on Linux
1104/// anyway, and the daemon this drives is Linux-only. `control::token` reads it the same way
1105/// for the same reason; the duplication is deliberate rather than shared, because unifying
1106/// it would mean editing another lane's module for four lines.
1107///
1108/// # Why the fallback is not silent
1109///
1110/// A failed read falls back to `RandomState` mixed with the nanosecond clock and a stack
1111/// address, which is weaker and still per-VM distinct. That is the property that matters
1112/// here — this token is delivered once, over a channel the platform does not forward until
1113/// the hook returns 200, to a daemon that installs it at most once — and the alternative,
1114/// failing a launch for want of 32 bytes, turns an unreadable `/dev/urandom` into an
1115/// unusable client.
1116fn mint_agent_token() -> String {
1117    use std::fmt::Write as _;
1118    use std::io::Read as _;
1119
1120    let mut bytes = [0u8; 32];
1121    let read = std::fs::File::open("/dev/urandom")
1122        .ok()
1123        .and_then(|mut file| file.read_exact(&mut bytes).ok())
1124        .is_some();
1125    if !read {
1126        use std::collections::hash_map::RandomState;
1127        use std::hash::{BuildHasher as _, Hasher as _};
1128        for chunk in bytes.chunks_mut(8) {
1129            let mut hasher = RandomState::new().build_hasher();
1130            hasher.write_usize(chunk.as_ptr() as usize);
1131            hasher.write_u128(
1132                std::time::SystemTime::now()
1133                    .duration_since(std::time::UNIX_EPOCH)
1134                    .map(|since| since.as_nanos())
1135                    .unwrap_or_default(),
1136            );
1137            let mixed = hasher.finish().to_le_bytes();
1138            chunk.copy_from_slice(&mixed[..chunk.len()]);
1139        }
1140    }
1141
1142    let mut token = String::with_capacity(bytes.len() * 2);
1143    for byte in bytes {
1144        let _ = write!(token, "{byte:02x}");
1145    }
1146    token
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152    use crate::control::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1153    use crate::control::transport::Method;
1154
1155    /// A sandbox over the contract recorder, plus the two handles a test asserts through.
1156    ///
1157    /// The recorder is T-W2-4's, deliberately: its answers are literal JSON in the service
1158    /// model's spelling rather than values serialized from this crate's types, so a member
1159    /// this lane misreads cannot be misread identically by the fake.
1160    fn planted() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1161        let recorder = Arc::new(FakeControlPlane::new());
1162        let clock = Arc::new(TestClock::new());
1163        let plane = ControlPlane::with_transport(
1164            Arc::clone(&recorder) as Arc<dyn crate::control::transport::Transport>,
1165            Region::UsEast1,
1166            Arc::clone(&clock) as Arc<dyn crate::control::Clock>,
1167        );
1168        (Sandbox::with_control_plane(plane), recorder, clock)
1169    }
1170
1171    /// Queues everything a launch to RUNNING needs.
1172    fn answer_launch(recorder: &FakeControlPlane) {
1173        recorder
1174            .answer(
1175                "RunMicrovm",
1176                Answer::ok(fake::microvm_response("PENDING", None)),
1177            )
1178            .answer(
1179                "GetMicrovm",
1180                Answer::ok(fake::microvm_response("RUNNING", None)),
1181            )
1182            .answer(
1183                "CreateMicrovmAuthToken",
1184                Answer::ok(fake::auth_token_response("proxy-token")),
1185            );
1186    }
1187
1188    /// A launched sandbox in RUNNING, which is where four of the twelve keys start.
1189    async fn launched() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1190        let (mut sandbox, recorder, clock) = planted();
1191        answer_launch(&recorder);
1192        sandbox
1193            .run(RunRequest::new().with_image("arn:image"))
1194            .await
1195            .expect("the launch reaches RUNNING");
1196        (sandbox, recorder, clock)
1197    }
1198
1199    /// Drives a launched sandbox to SUSPENDED, which is where resume starts.
1200    ///
1201    /// Both `GetMicrovm` answers are queued **before** the launch rather than one before
1202    /// each wait, and that is not a style choice. The recorder repeats its last queued
1203    /// answer, so a queue left at `[RUNNING]` after the launch and then appended to becomes
1204    /// `[RUNNING, SUSPENDED]` — the suspend wait pops the stale RUNNING, sleeps a poll
1205    /// interval, and the fake's `sleep` **advances the clock**. That five seconds is
1206    /// invisible until it lands in the middle of the STATE-12 window arithmetic, which is
1207    /// exactly where it first showed up. Queueing both up front means every wait matches on
1208    /// its first poll and no test clock moves except when a test moves it.
1209    async fn suspended_with_window(
1210        suspended_sec: u32,
1211    ) -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1212        let (mut sandbox, recorder, clock) = planted();
1213        recorder
1214            .answer(
1215                "RunMicrovm",
1216                Answer::ok(fake::microvm_response("PENDING", None)),
1217            )
1218            .answer(
1219                "GetMicrovm",
1220                Answer::ok(fake::microvm_response("RUNNING", None)),
1221            )
1222            .answer(
1223                "GetMicrovm",
1224                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1225            )
1226            .answer(
1227                "CreateMicrovmAuthToken",
1228                Answer::ok(fake::auth_token_response("proxy-token")),
1229            )
1230            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1231
1232        sandbox
1233            .run(
1234                RunRequest::new()
1235                    .with_image("arn:image")
1236                    .with_suspended_sec(suspended_sec),
1237            )
1238            .await
1239            .expect("launches");
1240        sandbox.suspend().await.expect("suspends");
1241        assert_eq!(
1242            clock.now(),
1243            Duration::ZERO,
1244            "no wait may have slept, or the window arithmetic below is measuring the fake's \
1245             poll interval as well as the elapsed suspension"
1246        );
1247        (sandbox, recorder, clock)
1248    }
1249
1250    /// A suspended sandbox at the default ten-minute window.
1251    async fn suspended() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1252        suspended_with_window(600).await
1253    }
1254
1255    /// **STATE-1 and STATE-2.** A launch accepted is PENDING with the image recorded as
1256    /// existing; the platform reporting the run hook succeeded is what marks the token
1257    /// installed.
1258    ///
1259    /// The two halves are asserted with the *same* fake because they are the same call, and
1260    /// what distinguishes them is which fact is recorded when: `image_exists` is true from
1261    /// the accepted launch, `token_installed` only from the RUNNING report. A client that
1262    /// set both at the launch call would pass an end-state assertion and be wrong about a
1263    /// VM that died during startup — which is the next test.
1264    #[tokio::test]
1265    async fn a_launch_records_the_image_and_the_running_report_installs_the_token() {
1266        let (mut sandbox, recorder, _) = planted();
1267        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1268        assert!(
1269            !sandbox.image_exists(),
1270            "nothing is recorded before a launch"
1271        );
1272        assert!(!sandbox.token_installed());
1273
1274        answer_launch(&recorder);
1275        let session = sandbox
1276            .run(RunRequest::new().with_image("arn:image"))
1277            .await
1278            .expect("launches");
1279        assert_eq!(
1280            session.endpoint(),
1281            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1282        );
1283
1284        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1285        assert!(sandbox.image_exists(), "STATE-1: the image is recorded");
1286        assert!(sandbox.token_installed(), "STATE-2: the token is installed");
1287        assert_eq!(sandbox.bootstrap_count(), 1);
1288        assert_eq!(sandbox.microvm().expect("launched").id, "mvm-abc123");
1289
1290        // The payload really carried the token, read off the wire member rather than from
1291        // the request type — a field on a struct proves nothing about what was emitted.
1292        let body = recorder.first_body("RunMicrovm");
1293        let payload = body["runHookPayload"].as_str().expect("a string");
1294        assert!(payload.starts_with(r#"{"agent_token":"#), "{payload}");
1295        assert_eq!(body["idlePolicy"]["suspendedDurationSeconds"], 600);
1296    }
1297
1298    /// A launch env reaches the wire member the daemon parses, and it is the only thing
1299    /// that changes about the request.
1300    ///
1301    /// Read off `runHookPayload` rather than off the request struct: a field on a struct
1302    /// proves nothing about what was emitted, which is the same argument the test above
1303    /// makes about the token.
1304    #[tokio::test]
1305    async fn a_launch_env_reaches_the_run_hook_payload_on_the_wire() {
1306        let (mut sandbox, recorder, _) = planted();
1307        answer_launch(&recorder);
1308        sandbox
1309            .run(
1310                RunRequest::new()
1311                    .with_image("arn:image")
1312                    .with_launch_env("ANTHROPIC_BASE_URL", "https://gateway.example")
1313                    .with_launch_env("PATH", "/usr/local/bin:/usr/bin:/bin"),
1314            )
1315            .await
1316            .expect("launches");
1317
1318        let body = recorder.first_body("RunMicrovm");
1319        let payload = body["runHookPayload"].as_str().expect("a string");
1320        // One parse deeper, because that is where the daemon reads it from too.
1321        let inner: serde_json::Value =
1322            serde_json::from_str(payload).expect("the payload is itself JSON");
1323        assert!(
1324            inner["agent_token"].as_str().is_some_and(|t| !t.is_empty()),
1325            "the token still rides alongside: {payload}"
1326        );
1327        assert_eq!(
1328            inner["env"]["ANTHROPIC_BASE_URL"],
1329            "https://gateway.example"
1330        );
1331        assert_eq!(inner["env"]["PATH"], "/usr/local/bin:/usr/bin:/bin");
1332    }
1333
1334    /// **A pinned `imageVersion` reaches the wire through the sandbox**, and an unpinned
1335    /// launch omits the member entirely.
1336    ///
1337    /// This is the layer a caller actually uses, so the forwarding is what needs asserting:
1338    /// [`RunRequest::image_version`] is a field on a struct and proves nothing about the
1339    /// emitted body until it is read off the wire member.
1340    ///
1341    /// **Falsification** — run 2026-08-16. Drop `wire.image_version = ...` from `run` and the
1342    /// pinned assertion goes red with the member absent, while every other launch test still
1343    /// passes — which is why this one is here.
1344    #[tokio::test]
1345    async fn a_pinned_image_version_is_forwarded_to_the_launch_request() {
1346        let (mut sandbox, recorder, _) = planted();
1347        answer_launch(&recorder);
1348        sandbox
1349            .run(
1350                RunRequest::new()
1351                    .with_image("arn:image")
1352                    .with_image_version("2.0"),
1353            )
1354            .await
1355            .expect("launches");
1356        assert_eq!(
1357            recorder.first_body("RunMicrovm")["imageVersion"],
1358            "2.0",
1359            "a canary has to launch against the version it means to test"
1360        );
1361
1362        let (mut sandbox, recorder, _) = planted();
1363        answer_launch(&recorder);
1364        sandbox
1365            .run(RunRequest::new().with_image("arn:image"))
1366            .await
1367            .expect("launches");
1368        assert!(
1369            recorder
1370                .first_body("RunMicrovm")
1371                .get("imageVersion")
1372                .is_none(),
1373            "an unpinned launch sends what this client always sent"
1374        );
1375    }
1376
1377    /// **The local refusal, with zero control-plane calls.**
1378    ///
1379    /// The observable difference between this client and one that lets AWS answer, and the
1380    /// same shape as STATE-5's suspend refusal: an over-budget launch env fails before the
1381    /// launch rather than as a `ValidationException` on a member the caller did not know
1382    /// they were filling. botocore does not enforce the ceiling client-side, so without
1383    /// this there is no local signal at all.
1384    ///
1385    /// **Falsification** — build the payload after `run_microvm` instead of before, and the
1386    /// call count assertion goes red.
1387    #[tokio::test]
1388    async fn an_over_budget_launch_env_is_refused_before_any_control_plane_call() {
1389        let (mut sandbox, recorder, _) = planted();
1390        answer_launch(&recorder);
1391
1392        let error = sandbox
1393            .run(
1394                RunRequest::new()
1395                    .with_image("arn:image")
1396                    .with_launch_env("AWS_SESSION_TOKEN", "t".repeat(4096)),
1397            )
1398            .await
1399            .expect_err("a credential-scale launch env does not fit the payload");
1400
1401        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1402        let message = error.to_string();
1403        assert!(message.contains("ceiling of 4096"), "{message}");
1404        assert!(message.contains("launch env contributed"), "{message}");
1405        assert_eq!(
1406            recorder.calls().len(),
1407            0,
1408            "the refusal has to be local: a launch that reached AWS costs a VM and a bill"
1409        );
1410        // And the sandbox is left usable rather than half-launched, so a caller can trim
1411        // the env and retry through the same handle.
1412        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1413        assert_eq!(sandbox.bootstrap_count(), 0);
1414        assert!(sandbox.microvm().is_none());
1415    }
1416
1417    /// **Issue #24's guards reach through `Sandbox`, which is the layer both bindings use.**
1418    ///
1419    /// `microvms-py`'s `run` and `microvms-js`'s `run` both build a [`RunRequest`] and hand it to
1420    /// [`Sandbox::run`], which fills a `RunMicrovmRequest` and calls
1421    /// [`crate::control::ControlPlane::run_microvm`]. So the guards there cover the bindings — but
1422    /// "cover" is a claim about a call chain, and a chain is what a refactor breaks. This asserts
1423    /// it at the layer the bindings actually enter, so a `Sandbox::run` that stopped delegating
1424    /// (or a binding-side default that bypassed the request type) fails a test rather than shipping
1425    /// an unguarded surface to Python and Node.
1426    ///
1427    /// `max_idle_sec` is the case that matters most here, because it is a plain `u32` keyword
1428    /// argument on both bindings with no type narrowing it: `sandbox.run(max_idle_sec=59)` is what
1429    /// a Python caller writes, and before this it reached the wire.
1430    ///
1431    /// **Guard proof.** Delete `require_idle_duration` from `run_microvm` and the idle row goes red
1432    /// on the call count; delete `require_valid_role_arn` and the role row does. Neither guard is in
1433    /// this file, which is the point — the test is about the chain.
1434    #[tokio::test]
1435    async fn the_bindings_layer_refuses_an_illegal_launch_with_zero_calls() {
1436        let cases: [(&str, RunRequest, &str); 3] = [
1437            (
1438                "max_idle_sec=59, which is what a Python keyword argument passes",
1439                RunRequest {
1440                    max_idle_sec: 59,
1441                    ..RunRequest::new().with_image("arn:image")
1442                },
1443                "maxIdleDurationSeconds",
1444            ),
1445            (
1446                "an execution role that is a bare name",
1447                RunRequest {
1448                    execution_role_arn: Some("execution-role".to_string()),
1449                    ..RunRequest::new().with_image("arn:image")
1450                },
1451                "role *name*",
1452            ),
1453            (
1454                "a pinned version with a trailing newline",
1455                RunRequest::new()
1456                    .with_image("arn:image")
1457                    .with_image_version("2.0\n"),
1458                "contains whitespace",
1459            ),
1460        ];
1461
1462        for (label, request, expected) in cases {
1463            let (mut sandbox, recorder, _) = planted();
1464            // The launch answered, so a missing guard is a *successful* launch — a real VM and a
1465            // real bill — rather than a different failure.
1466            answer_launch(&recorder);
1467
1468            // `expect_err` needs the Ok type to be `Debug`, and `run` answers `&mut Session`
1469            // (which is not) — so the discriminant is matched instead. That is also the more
1470            // honest assertion: it says the launch produced no session at all.
1471            let Err(error) = sandbox.run(request).await else {
1472                panic!("{label} must be refused");
1473            };
1474            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1475            assert!(
1476                error.to_string().contains(expected),
1477                "{label}: wanted {expected:?}, got {error}"
1478            );
1479            assert_eq!(
1480                recorder.calls().len(),
1481                0,
1482                "{label}: the refusal has to be local, because a launch that reached AWS costs a \
1483                 VM and a bill"
1484            );
1485            assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1486            assert!(sandbox.microvm().is_none());
1487        }
1488    }
1489
1490    /// A launch whose VM dies during startup leaves the token **not** installed, because
1491    /// the run hook is what delivers it and a dead VM ran no hook.
1492    ///
1493    /// The distinction this pins is the one the test above cannot: both a correct client and
1494    /// one that installs the token at the launch call reach RUNNING with the token set, and
1495    /// only this case separates them.
1496    #[tokio::test]
1497    async fn a_launch_that_dies_during_startup_installs_no_token() {
1498        let (mut sandbox, recorder, _) = planted();
1499        recorder
1500            .answer(
1501                "RunMicrovm",
1502                Answer::ok(fake::microvm_response("PENDING", None)),
1503            )
1504            .answer(
1505                "GetMicrovm",
1506                Answer::ok(fake::microvm_response(
1507                    "TERMINATED",
1508                    Some("run hook returned 500"),
1509                )),
1510            );
1511
1512        let error = sandbox
1513            .run(RunRequest::new().with_image("arn:image"))
1514            .await
1515            .expect_err("a VM that died during startup is not a launch");
1516        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1517        assert!(
1518            error.to_string().contains("run hook returned 500"),
1519            "{error}"
1520        );
1521
1522        assert!(
1523            !sandbox.token_installed(),
1524            "the run hook never answered, so nothing was installed"
1525        );
1526        assert_eq!(
1527            sandbox.bootstrap_count(),
1528            0,
1529            "a bootstrap counted here would make STATE-3's ceiling unreachable for a retry"
1530        );
1531        assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1532        assert!(
1533            sandbox.image_exists(),
1534            "STATE-1 still holds: the launch was accepted against a real image"
1535        );
1536    }
1537
1538    /// **STATE-3, the guard proof.** A second `run` on one sandbox is refused, and it is
1539    /// refused with **zero** further control-plane calls.
1540    ///
1541    /// The call count is the assertion that matters. A client that let the second launch
1542    /// through would create a second VM and silently address two guests through one handle,
1543    /// and it would also deliver a second run-hook payload to a daemon whose one-shot
1544    /// bootstrap answers 409 — so "an error came back" is not enough to tell the two apart.
1545    ///
1546    /// **Falsification** — delete the `bootstrap_count > 0` branch from `run` and this test
1547    /// is red on both the count and the call total. Verified; see the packet's guard proofs.
1548    #[tokio::test]
1549    async fn a_second_run_on_one_sandbox_is_refused_before_any_call() {
1550        let (mut sandbox, recorder, _) = launched().await;
1551        assert_eq!(sandbox.bootstrap_count(), 1);
1552        let before = recorder.calls().len();
1553
1554        let error = sandbox
1555            .run(RunRequest::new().with_image("arn:image"))
1556            .await
1557            .expect_err("the token is installed at most once per VM lifetime");
1558        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1559        assert!(error.to_string().contains("STATE-3"), "{error}");
1560        assert!(
1561            error.to_string().contains("needs a second Sandbox"),
1562            "the remedy has to be nameable: {error}"
1563        );
1564
1565        assert_eq!(
1566            sandbox.bootstrap_count(),
1567            1,
1568            "bootstrap_count <= 1 is the Z3-proved invariant"
1569        );
1570        assert_eq!(
1571            recorder.calls().len(),
1572            before,
1573            "the refusal must cost no control-plane call"
1574        );
1575        assert_eq!(recorder.call_count("RunMicrovm"), 1);
1576    }
1577
1578    /// **STATE-4 and STATE-6.** A suspend from RUNNING moves to SUSPENDING before the wait,
1579    /// and the platform reporting SUSPENDED is what moves it to SUSPENDED.
1580    #[tokio::test]
1581    async fn a_suspend_from_running_passes_through_suspending_to_suspended() {
1582        let (sandbox, recorder, _) = suspended().await;
1583        assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1584        assert!(
1585            sandbox.token_installed(),
1586            "a freeze keeps the guest's memory, so the token survives"
1587        );
1588        assert_eq!(sandbox.bootstrap_count(), 1, "no re-bootstrap on a freeze");
1589
1590        let calls = recorder.calls();
1591        let suspend = calls
1592            .iter()
1593            .find(|call| call.operation == "SuspendMicrovm")
1594            .expect("the suspend went out");
1595        assert_eq!(suspend.method, Method::Post);
1596        assert_eq!(suspend.path, "/2025-09-09/microvms/mvm-abc123/suspend");
1597    }
1598
1599    /// A suspend whose VM dies while suspending reports TERMINATED rather than raising, and
1600    /// records it — which is what stops a later resume.
1601    ///
1602    /// TERMINATED is *wanted* by the suspend wait for a reason: a VM that dies mid-suspend
1603    /// is a state to report, not an exception out of the middle of a teardown.
1604    #[tokio::test]
1605    async fn a_vm_that_dies_while_suspending_is_recorded_rather_than_raised() {
1606        let (mut sandbox, recorder, _) = launched().await;
1607        recorder
1608            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1609            .answer(
1610                "GetMicrovm",
1611                Answer::ok(fake::microvm_response("TERMINATED", Some("idle policy"))),
1612            );
1613
1614        sandbox
1615            .suspend()
1616            .await
1617            .expect("a death mid-suspend is a state, not an error");
1618        assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
1619        assert!(
1620            sandbox.was_terminated(),
1621            "STATE-11's precondition is recorded"
1622        );
1623    }
1624
1625    /// A suspend whose wire call fails leaves the sandbox in RUNNING, and a retry works.
1626    ///
1627    /// STATE-4's "accepted" is the wire call succeeding. A client that moved to SUSPENDING
1628    /// before the call would strand a failed call there — a state neither suspend nor
1629    /// resume accepts, so one throttled or dropped request would brick the handle.
1630    /// **Falsification** — move `self.lifecycle = Lifecycle::Suspending` back above the
1631    /// `control.suspend` call and the first assertion here reads SUSPENDING, and the retry
1632    /// is refused by the STATE-5 guard.
1633    #[tokio::test]
1634    async fn a_suspend_whose_call_fails_stays_running_and_can_be_retried() {
1635        let (mut sandbox, recorder, _) = planted();
1636        recorder
1637            .answer(
1638                "RunMicrovm",
1639                Answer::ok(fake::microvm_response("PENDING", None)),
1640            )
1641            .answer(
1642                "GetMicrovm",
1643                Answer::ok(fake::microvm_response("RUNNING", None)),
1644            )
1645            .answer(
1646                "GetMicrovm",
1647                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1648            )
1649            .answer(
1650                "CreateMicrovmAuthToken",
1651                Answer::ok(fake::auth_token_response("proxy-token")),
1652            )
1653            // A 409 rather than a 429 or a transport cut, because those two are retried
1654            // inside `send_with_retry` and this test wants the call to fail once, fast.
1655            .answer("SuspendMicrovm", Answer::failure(409, "ConflictException"))
1656            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1657
1658        sandbox
1659            .run(RunRequest::new().with_image("arn:image"))
1660            .await
1661            .expect("launches");
1662
1663        sandbox
1664            .suspend()
1665            .await
1666            .expect_err("the control plane refused the suspend");
1667        assert_eq!(
1668            sandbox.lifecycle(),
1669            Lifecycle::Running,
1670            "a refused suspend must not move the lifecycle: SUSPENDING accepts neither a \
1671             suspend nor a resume, so recording it here would brick the handle"
1672        );
1673
1674        sandbox.suspend().await.expect("the retry suspends");
1675        assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1676        assert_eq!(recorder.call_count("SuspendMicrovm"), 2);
1677    }
1678
1679    /// **STATE-5, the guard proof.** A suspend from anything but RUNNING is refused with
1680    /// **zero** control-plane calls.
1681    ///
1682    /// Every non-RUNNING state, so a state added to `Lifecycle` later is not silently
1683    /// exempt. The count is the load-bearing assertion: letting the call go and reading the
1684    /// service's answer also produces an error, and that error is about a non-running id
1685    /// rather than about which of two things the caller got wrong.
1686    ///
1687    /// **Falsification** — delete the `lifecycle != Running` branch from `suspend` and the
1688    /// SUSPENDED case emits a `SuspendMicrovm` call, turning the count assertion red.
1689    /// Verified; see the packet's guard proofs.
1690    #[tokio::test]
1691    async fn a_suspend_from_a_non_running_state_reaches_no_control_plane_call() {
1692        // From SUSPENDED — the caller who believes they resumed.
1693        let (mut sandbox, recorder, _) = suspended().await;
1694        let before = recorder.call_count("SuspendMicrovm");
1695
1696        let error = sandbox
1697            .suspend()
1698            .await
1699            .expect_err("a suspend is only issued from RUNNING");
1700        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1701        assert!(error.to_string().contains("STATE-5"), "{error}");
1702        assert!(error.to_string().contains("SUSPENDED"), "{error}");
1703        assert_eq!(
1704            recorder.call_count("SuspendMicrovm"),
1705            before,
1706            "the refusal must be local: no second suspend went to the wire"
1707        );
1708
1709        // And from PENDING, before anything launched at all.
1710        let (mut fresh, fresh_recorder, _) = planted();
1711        let error = fresh.suspend().await.expect_err("nothing to suspend");
1712        assert_eq!(error.kind(), ErrorKind::Precondition);
1713        assert_eq!(fresh_recorder.calls().len(), 0);
1714    }
1715
1716    /// **STATE-7 and STATE-8.** A resume from SUSPENDED returns to RUNNING, re-delivers
1717    /// **nothing**, and drops the cached proxy token.
1718    ///
1719    /// Three assertions, and each one is a different failure mode. The bootstrap count says
1720    /// no run-hook payload was re-delivered — which a daemon's one-shot bootstrap would
1721    /// answer 409 to, reading like a broken VM. The `RunMicrovm` count says no second launch
1722    /// happened. And `is_cached` says the token was invalidated, which is the only
1723    /// observable difference on a path where the endpoint URL does not change: a client that
1724    /// kept the token would produce identical requests until the pre-suspend token stopped
1725    /// validating, and that rejection reads exactly like a dead daemon.
1726    #[tokio::test]
1727    async fn a_resume_reuses_the_token_redelivers_nothing_and_drops_the_proxy_token() {
1728        let (mut sandbox, recorder, _) = suspended().await;
1729
1730        // The session mints once before the suspend, so there is a cached token to drop.
1731        let auth = Arc::clone(
1732            sandbox
1733                .session()
1734                .expect("launched")
1735                .proxy_auth()
1736                .expect("the launch wired a minter"),
1737        );
1738        auth.headers().await.expect("mints");
1739        assert!(
1740            auth.is_cached(),
1741            "there must be a token for the drop to matter"
1742        );
1743        assert_eq!(auth.mint_count(), 1);
1744
1745        recorder
1746            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1747            .answer(
1748                "GetMicrovm",
1749                Answer::ok(fake::microvm_response("RUNNING", None)),
1750            );
1751        let session = sandbox.resume().await.expect("resumes");
1752        assert_eq!(
1753            session.endpoint(),
1754            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1755        );
1756
1757        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1758        assert!(
1759            sandbox.token_installed(),
1760            "STATE-7: the installed token is reused"
1761        );
1762        assert_eq!(
1763            sandbox.bootstrap_count(),
1764            1,
1765            "STATE-7: a resume re-delivers no run-hook payload"
1766        );
1767        assert_eq!(
1768            recorder.call_count("RunMicrovm"),
1769            1,
1770            "a resume is not a second launch"
1771        );
1772        assert!(
1773            !auth.is_cached(),
1774            "STATE-8: the cached proxy token survived the resume"
1775        );
1776    }
1777
1778    /// **STATE-8's guard proof, on the mint count.** The request after a resume mints a
1779    /// fresh token rather than reusing the pre-suspend one.
1780    ///
1781    /// Separate from the test above because `is_cached` is a fact about the cache and this is
1782    /// a fact about the wire: a client that dropped the cache but kept emitting the old
1783    /// header would pass the first assertion and fail every request.
1784    ///
1785    /// **Falsification** — delete the `proxy.invalidate()` line from `Session::rebind` and
1786    /// the mint count stays at 1 here. Verified; see the packet's guard proofs.
1787    #[tokio::test]
1788    async fn the_request_after_a_resume_mints_a_fresh_proxy_token() {
1789        let (mut sandbox, recorder, _) = suspended().await;
1790        let auth = Arc::clone(
1791            sandbox
1792                .session()
1793                .expect("launched")
1794                .proxy_auth()
1795                .expect("wired"),
1796        );
1797        auth.headers().await.expect("mints");
1798        assert_eq!(auth.mint_count(), 1);
1799
1800        recorder
1801            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1802            .answer(
1803                "GetMicrovm",
1804                Answer::ok(fake::microvm_response("RUNNING", None)),
1805            );
1806        sandbox.resume().await.expect("resumes");
1807
1808        // No clock movement at all, so a re-mint here is caused by the invalidation rather
1809        // than by the refresh window rolling over.
1810        auth.headers().await.expect("re-mints");
1811        assert_eq!(
1812            auth.mint_count(),
1813            2,
1814            "STATE-8: the resume did not force a fresh mint"
1815        );
1816    }
1817
1818    /// **STATE-12, the guard proof.** A resume past the launch-time suspended window is
1819    /// refused locally, with **zero** resume calls, and the message names the window.
1820    ///
1821    /// Four assertions, and the packet asks for all four because each rules out a different
1822    /// wrong implementation. `ErrorKind::WindowClosed` rather than `Timeout` separates this
1823    /// from the client that waits. Zero `ResumeMicrovm` calls is what "before any wire call"
1824    /// means. The elapsed reading rules out an implementation that polled to the deadline
1825    /// first — the fake's clock advances on every `sleep`, so a poll loop here would show up
1826    /// as time passed. And the message naming both numbers plus `idlePolicy` is what sends a
1827    /// reader to the finding rather than looking for the flag that reopens the window.
1828    ///
1829    /// **Falsification** — delete the `require_open_suspended_window` call from `resume` and
1830    /// the fake answers TERMINATED, so this test fails with an `ErrorKind::LaunchDied` after
1831    /// a `ResumeMicrovm` call, naming neither the window nor the seconds elapsed. Verified;
1832    /// see the packet's guard proofs.
1833    #[tokio::test]
1834    async fn a_resume_past_the_suspended_window_is_refused_before_the_wire() {
1835        // A short window, so the arithmetic is legible: sixty seconds asked for at launch.
1836        let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1837        assert_eq!(sandbox.suspended_window(), Some(Duration::from_secs(60)));
1838
1839        // The window closes. This is the whole of what the clock injection buys.
1840        clock.advance(Duration::from_secs(61));
1841
1842        // What a service says about a VM the idlePolicy already terminated — which is what a
1843        // client without the local check would spend its poll timeout discovering.
1844        recorder
1845            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1846            .answer(
1847                "GetMicrovm",
1848                Answer::ok(fake::microvm_response(
1849                    "TERMINATED",
1850                    Some("suspended window elapsed"),
1851                )),
1852            );
1853        let elapsed_before = clock.now();
1854        let resumes_before = recorder.call_count("ResumeMicrovm");
1855
1856        let error = sandbox
1857            .resume()
1858            .await
1859            .expect_err("the window the launch set has closed");
1860
1861        assert_eq!(error.kind(), ErrorKind::WindowClosed);
1862        assert_eq!(error.code(), "ERR_WINDOW_CLOSED");
1863        let message = error.to_string();
1864        assert!(message.contains("61s"), "the elapsed time: {message}");
1865        assert!(
1866            message.contains("60s suspendedDurationSeconds"),
1867            "the window has to be named: {message}"
1868        );
1869        assert!(message.contains("idlePolicy"), "the finding: {message}");
1870        assert!(
1871            message.contains("GetMicrovm does not return it"),
1872            "why the client is the only party that can say this: {message}"
1873        );
1874        assert_eq!(
1875            recorder.call_count("ResumeMicrovm"),
1876            resumes_before,
1877            "the refusal must come before ResumeMicrovm"
1878        );
1879        assert_eq!(
1880            clock.now(),
1881            elapsed_before,
1882            "the refusal must be immediate rather than polled to the deadline"
1883        );
1884    }
1885
1886    /// A resume **inside** the window goes through, so the guard is a comparison rather than
1887    /// a blanket refusal of every resume.
1888    ///
1889    /// The boundary is inclusive: elapsed exactly equal to the window is still open, matching
1890    /// the Python's `elapsed <= window`.
1891    #[tokio::test]
1892    async fn a_resume_inside_the_window_is_allowed_and_the_boundary_is_inclusive() {
1893        let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1894
1895        // Exactly at the window.
1896        clock.advance(Duration::from_secs(60));
1897        recorder
1898            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1899            .answer(
1900                "GetMicrovm",
1901                Answer::ok(fake::microvm_response("RUNNING", None)),
1902            );
1903        sandbox
1904            .resume()
1905            .await
1906            .expect("60s == the 60s window is open");
1907        assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1908        assert_eq!(recorder.call_count("ResumeMicrovm"), 1);
1909    }
1910
1911    /// A successful resume clears the stamp, so the next cycle's window is measured from the
1912    /// next suspend rather than from the first one.
1913    ///
1914    /// The failure this rules out is subtle and would only show on a second cycle:
1915    /// accumulating every suspension's elapsed time into one total refuses a resume whose own
1916    /// window is wide open. The clock is advanced past the window *between* the two cycles,
1917    /// which is what makes the accumulating implementation fail here.
1918    #[tokio::test]
1919    async fn a_successful_resume_clears_the_stamp_so_a_second_cycle_measures_its_own_window() {
1920        let (mut sandbox, recorder, clock) = planted();
1921        // Every `GetMicrovm` answer for both cycles, in the order the waits consume them:
1922        // the launch's RUNNING, then SUSPENDED / RUNNING twice. Queued up front for the
1923        // reason `suspended_with_window` documents — a stale answer left at the head makes a
1924        // wait poll twice, and the fake's `sleep` moves the very clock this test is measuring.
1925        recorder
1926            .answer(
1927                "RunMicrovm",
1928                Answer::ok(fake::microvm_response("PENDING", None)),
1929            )
1930            .answer(
1931                "CreateMicrovmAuthToken",
1932                Answer::ok(fake::auth_token_response("proxy-token")),
1933            )
1934            .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1935            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1936            .answer(
1937                "GetMicrovm",
1938                Answer::ok(fake::microvm_response("RUNNING", None)),
1939            )
1940            .answer(
1941                "GetMicrovm",
1942                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1943            )
1944            .answer(
1945                "GetMicrovm",
1946                Answer::ok(fake::microvm_response("RUNNING", None)),
1947            )
1948            .answer(
1949                "GetMicrovm",
1950                Answer::ok(fake::microvm_response("SUSPENDED", None)),
1951            )
1952            .answer(
1953                "GetMicrovm",
1954                Answer::ok(fake::microvm_response("RUNNING", None)),
1955            );
1956        sandbox
1957            .run(
1958                RunRequest::new()
1959                    .with_image("arn:image")
1960                    .with_suspended_sec(60),
1961            )
1962            .await
1963            .expect("launches");
1964
1965        for cycle in 0..2 {
1966            sandbox
1967                .suspend()
1968                .await
1969                .unwrap_or_else(|error| panic!("cycle {cycle} suspend: {error}"));
1970            assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended, "cycle {cycle}");
1971
1972            // Forty-five seconds each time — inside the sixty-second window on its own, and
1973            // ninety in total. An implementation that never cleared the stamp measures the
1974            // total and refuses the second resume, which is the whole point of the loop.
1975            clock.advance(Duration::from_secs(45));
1976
1977            sandbox.resume().await.unwrap_or_else(|error| {
1978                panic!("cycle {cycle} resume, whose own window is wide open: {error}")
1979            });
1980            assert_eq!(sandbox.lifecycle(), Lifecycle::Running, "cycle {cycle}");
1981            assert_eq!(
1982                sandbox.bootstrap_count(),
1983                1,
1984                "cycle {cycle}: no re-bootstrap"
1985            );
1986        }
1987
1988        assert_eq!(
1989            clock.now(),
1990            Duration::from_secs(90),
1991            "ninety seconds of accumulated suspension against a sixty-second window: this is \
1992             the total an implementation that never cleared the stamp would have compared"
1993        );
1994        assert_eq!(
1995            recorder.call_count("ResumeMicrovm"),
1996            2,
1997            "both cycles resumed"
1998        );
1999    }
2000
2001    /// **STATE-11, the resume-after-terminate case the packet names.** A terminated VM never
2002    /// returns to RUNNING, and the recorder sees **zero** resume wire calls.
2003    ///
2004    /// The zero count is the assertion the packet asks for, and it is the right one: a client
2005    /// that called and read the answer would also fail, with whatever the service says about
2006    /// a terminated id — which is a different statement from "this VM is gone and even a
2007    /// successful call would hand you a different machine".
2008    #[tokio::test]
2009    async fn a_resume_after_terminate_records_zero_resume_calls() {
2010        let (mut sandbox, recorder, _) = launched().await;
2011        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2012        let report = sandbox.terminate(TeardownOpts::default()).await;
2013        assert!(report.terminate_accepted);
2014        assert!(sandbox.was_terminated());
2015
2016        let error = sandbox
2017            .resume()
2018            .await
2019            .expect_err("a terminated VM never returns to RUNNING");
2020        assert_eq!(error.kind(), ErrorKind::InvalidArg);
2021        assert!(error.to_string().contains("STATE-11"), "{error}");
2022        assert_eq!(
2023            recorder.call_count("ResumeMicrovm"),
2024            0,
2025            "no resume may reach the wire once the VM was terminated"
2026        );
2027        assert_ne!(
2028            sandbox.lifecycle(),
2029            Lifecycle::Running,
2030            "the lifecycle must not return to RUNNING"
2031        );
2032    }
2033
2034    /// **STATE-9 and STATE-10.** A terminate accepted is TERMINATING with the VM recorded
2035    /// terminated; the platform reporting termination complete is what marks TERMINATED.
2036    ///
2037    /// The two are separate states rather than one because the default teardown does not
2038    /// wait — so TERMINATING is a state a report really ends in, and calling it TERMINATED
2039    /// would claim an observation nobody made.
2040    #[tokio::test]
2041    async fn a_terminate_records_terminating_and_the_completion_report_marks_terminated() {
2042        let (mut sandbox, recorder, _) = launched().await;
2043        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2044
2045        let report = sandbox.terminate(TeardownOpts::default()).await;
2046        assert!(report.terminate_accepted, "STATE-9: the call was accepted");
2047        assert!(sandbox.was_terminated(), "STATE-9: recorded terminated");
2048        assert_eq!(
2049            report.lifecycle,
2050            Some(Lifecycle::Terminating),
2051            "the default teardown does not wait, so TERMINATED is not claimed"
2052        );
2053        assert!(!report.leaked(), "{report:?}");
2054
2055        // And with the wait asked for, STATE-10 is observed.
2056        let (mut sandbox, recorder, _) = launched().await;
2057        recorder
2058            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2059            .answer(
2060                "GetMicrovm",
2061                Answer::ok(fake::microvm_response("TERMINATED", None)),
2062            );
2063        let report = sandbox
2064            .terminate(TeardownOpts::default().waiting_for_terminated())
2065            .await;
2066        assert_eq!(report.lifecycle, Some(Lifecycle::Terminated));
2067        assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
2068    }
2069
2070    /// The teardown order is VM, then image, then the log group **last** — asserted on the
2071    /// recorder's ledger rather than on the report, because the order is a property of what
2072    /// was emitted.
2073    ///
2074    /// The log group is last because the service can recreate a group deleted before its
2075    /// image. This crate cannot delete it at all, so the assertion is that it is *named
2076    /// after* the image work rather than that a delete call went out — see
2077    /// `TeardownReport::undeleted`.
2078    #[tokio::test]
2079    async fn teardown_deletes_the_vm_then_the_image_and_names_the_log_group_last() {
2080        let (mut sandbox, recorder, _) = planted();
2081        recorder
2082            .answer(
2083                "CreateMicrovmImage",
2084                Answer::created(fake::create_image_response("agentd-conformance")),
2085            )
2086            .answer(
2087                "GetMicrovmImage",
2088                Answer::ok(fake::get_image_response("agentd-conformance", "CREATED")),
2089            );
2090        sandbox
2091            .build_image(CreateImageRequest::new(
2092                "agentd-conformance",
2093                b"binary".to_vec(),
2094                "s3://bucket/img.zip",
2095                "arn:aws:iam::123456789012:role/build",
2096            ))
2097            .await
2098            .expect("builds");
2099        answer_launch(&recorder);
2100        sandbox
2101            .run(RunRequest::new())
2102            .await
2103            .expect("launches from the built image");
2104
2105        recorder
2106            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2107            .answer(
2108                "ListMicrovmImageVersions",
2109                Answer::ok(fake::list_versions_response("1")),
2110            )
2111            .answer(
2112                "DeleteMicrovmImage",
2113                Answer::ok(fake::delete_image_response()),
2114            );
2115
2116        let report = sandbox
2117            .terminate(
2118                TeardownOpts::default()
2119                    .deleting_image()
2120                    .deleting_log_group(),
2121            )
2122            .await;
2123        assert!(report.terminate_accepted);
2124        assert_eq!(report.image_deleted, Some(true));
2125
2126        // The ledger, filtered to the three destructive operations.
2127        let order: Vec<&str> = recorder
2128            .operations()
2129            .into_iter()
2130            .filter(|operation| matches!(*operation, "TerminateMicrovm" | "DeleteMicrovmImage"))
2131            .collect();
2132        assert_eq!(
2133            order,
2134            ["TerminateMicrovm", "DeleteMicrovmImage"],
2135            "the VM goes before the image: a terminating VM holds a reference to it"
2136        );
2137
2138        // The log group is named, and named last.
2139        assert_eq!(
2140            report.undeleted,
2141            ["/aws/lambda-microvms/agentd-conformance"],
2142            "{report:?}"
2143        );
2144        assert!(report.leaked());
2145        assert!(
2146            report
2147                .failures
2148                .last()
2149                .expect("a failure")
2150                .contains("log group"),
2151            "the log group's failure has to be the last one recorded: {report:?}"
2152        );
2153        assert!(
2154            report
2155                .failures
2156                .last()
2157                .expect("a failure")
2158                .contains("terraform destroy"),
2159            "the reason it leaks has to be named: {report:?}"
2160        );
2161    }
2162
2163    /// An image delete that fails every attempt lands its identifier in the report rather
2164    /// than raising, so the CLI can emit what was left behind (CLI-6).
2165    ///
2166    /// The identifier rather than a flag is the whole point: a leak nobody can name is a leak
2167    /// nobody can clean up.
2168    #[tokio::test]
2169    async fn a_failing_image_delete_names_what_it_left_behind() {
2170        let (mut sandbox, recorder, _) = planted();
2171        recorder
2172            .answer(
2173                "CreateMicrovmImage",
2174                Answer::created(fake::create_image_response("img")),
2175            )
2176            .answer(
2177                "GetMicrovmImage",
2178                Answer::ok(fake::get_image_response("img", "CREATED")),
2179            );
2180        sandbox
2181            .build_image(CreateImageRequest::new(
2182                "img",
2183                b"binary".to_vec(),
2184                "s3://bucket/img.zip",
2185                "arn:aws:iam::123456789012:role/build",
2186            ))
2187            .await
2188            .expect("builds");
2189        answer_launch(&recorder);
2190        sandbox.run(RunRequest::new()).await.expect("launches");
2191
2192        recorder
2193            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2194            .answer(
2195                "ListMicrovmImageVersions",
2196                Answer::ok(fake::list_versions_response("1")),
2197            )
2198            .answer(
2199                "DeleteMicrovmImage",
2200                Answer::failure(409, "image is in CREATING"),
2201            );
2202
2203        let mut opts = TeardownOpts::default().deleting_image();
2204        opts.delete_attempts = 3;
2205        opts.delete_backoff = Duration::from_secs(1);
2206        let report = sandbox.terminate(opts).await;
2207
2208        assert_eq!(report.image_deleted, Some(false));
2209        assert_eq!(
2210            report.undeleted,
2211            ["arn:aws:lambda:us-east-1:123456789012:microvm-image:img"],
2212            "{report:?}"
2213        );
2214        assert!(
2215            sandbox.image_exists(),
2216            "the image really is still there, so the flag must say so"
2217        );
2218        assert_eq!(
2219            recorder.call_count("DeleteMicrovmImage"),
2220            3,
2221            "every attempt ran"
2222        );
2223    }
2224
2225    /// A terminate whose own call fails still records the VM as one this client asked to
2226    /// destroy, and names it as undeleted.
2227    ///
2228    /// Recording it is what stops a later resume from being offered against a VM the caller
2229    /// already tried to kill (STATE-11) — the alternative leaves the sandbox looking
2230    /// resumable, which is the worse of the two wrong answers.
2231    #[tokio::test]
2232    async fn a_terminate_whose_call_fails_still_records_the_intent_and_the_leak() {
2233        let (mut sandbox, recorder, _) = launched().await;
2234        recorder.answer(
2235            "TerminateMicrovm",
2236            Answer::failure(500, "InternalServerException"),
2237        );
2238
2239        let report = sandbox.terminate(TeardownOpts::default()).await;
2240        assert!(!report.terminate_accepted);
2241        assert_eq!(report.undeleted, ["mvm-abc123"], "{report:?}");
2242        assert!(sandbox.was_terminated());
2243        assert_eq!(report.lifecycle, Some(Lifecycle::Terminating));
2244        assert!(
2245            !report.failures.is_empty(),
2246            "the swallowed failure is recorded"
2247        );
2248    }
2249
2250    /// A teardown on a sandbox that never launched is a no-op report rather than a panic.
2251    ///
2252    /// The path a caller reaches by tearing down in a `finally` after a launch that failed
2253    /// before any VM existed, which is exactly when a teardown that assumed a VM would turn
2254    /// the real failure into a panic.
2255    #[tokio::test]
2256    async fn a_teardown_before_any_launch_is_an_empty_report() {
2257        let (mut sandbox, recorder, _) = planted();
2258        let report = sandbox.terminate(TeardownOpts::default()).await;
2259        assert!(!report.terminate_accepted);
2260        assert!(!report.leaked());
2261        assert_eq!(report.lifecycle, Some(Lifecycle::Pending));
2262        assert_eq!(recorder.calls().len(), 0);
2263    }
2264
2265    /// A run with no image at all is a precondition error before any call, rather than a
2266    /// launch of an empty identifier.
2267    #[tokio::test]
2268    async fn a_run_with_no_image_is_refused_before_any_call() {
2269        let (mut sandbox, recorder, _) = planted();
2270        let error = sandbox
2271            .run(RunRequest::new())
2272            .await
2273            .expect_err("there is nothing to launch");
2274        assert_eq!(error.kind(), ErrorKind::Precondition);
2275        assert!(error.to_string().contains("build_image first"), "{error}");
2276        assert_eq!(recorder.calls().len(), 0);
2277    }
2278
2279    /// The lifecycle's six states are the symspec's `vm_state` domain, spelled as the service
2280    /// spells them.
2281    ///
2282    /// Asserted rather than assumed because the spelling reaches an error message a reader
2283    /// compares against a `GetMicrovm` response: a lifecycle rendering `Suspended` beside a
2284    /// service saying `SUSPENDED` reads like two different facts.
2285    #[test]
2286    fn the_lifecycle_states_are_the_state_models_domain_in_the_services_spelling() {
2287        let all = [
2288            Lifecycle::Pending,
2289            Lifecycle::Running,
2290            Lifecycle::Suspending,
2291            Lifecycle::Suspended,
2292            Lifecycle::Terminating,
2293            Lifecycle::Terminated,
2294        ];
2295        assert_eq!(
2296            all.map(Lifecycle::as_str),
2297            [
2298                "PENDING",
2299                "RUNNING",
2300                "SUSPENDING",
2301                "SUSPENDED",
2302                "TERMINATING",
2303                "TERMINATED"
2304            ]
2305        );
2306        for state in all {
2307            assert_eq!(state.to_string(), state.as_str());
2308        }
2309        // The four live states are the ones a `Drop` warns about, and TERMINATING is not one
2310        // of them: the platform accepted the terminate, so nobody needs telling.
2311        assert_eq!(
2312            all.iter().filter(|state| state.is_live()).count(),
2313            4,
2314            "a state added to the enum defaults to not-live, which would silence the warning"
2315        );
2316        assert!(!Lifecycle::Terminating.is_live());
2317        assert!(!Lifecycle::Terminated.is_live());
2318    }
2319
2320    /// An agent token is 64 hex characters and never repeats.
2321    ///
2322    /// The distinctness matters because the token is the only thing separating one VM's
2323    /// control API from another's: two VMs sharing a token means either one's caller can
2324    /// drive the other's guest.
2325    #[test]
2326    fn a_minted_agent_token_is_sixty_four_hex_characters_and_never_repeats() {
2327        let minted: std::collections::HashSet<String> =
2328            (0..200).map(|_| mint_agent_token()).collect();
2329        assert_eq!(minted.len(), 200, "a repeated token is a shared guest");
2330        for token in &minted {
2331            assert_eq!(token.len(), 64, "{token}");
2332            assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}");
2333            assert_ne!(
2334                token,
2335                &"0".repeat(64),
2336                "an all-zero draw is a read that did nothing"
2337            );
2338        }
2339    }
2340
2341    /// A sandbox's `Debug` does not print the agent token.
2342    #[tokio::test]
2343    async fn a_sandbox_debug_does_not_print_the_agent_token() {
2344        let (mut sandbox, recorder, _) = planted();
2345        answer_launch(&recorder);
2346        sandbox
2347            .run(
2348                RunRequest::new()
2349                    .with_image("arn:image")
2350                    .with_agent_token("super-secret-agent-token"),
2351            )
2352            .await
2353            .expect("launches");
2354
2355        let rendered = format!("{sandbox:?}");
2356        assert!(rendered.contains("mvm-abc123"), "{rendered}");
2357        assert!(rendered.contains("Running"), "{rendered}");
2358        assert!(
2359            !rendered.contains("super-secret"),
2360            "the agent token reached a Debug string: {rendered}"
2361        );
2362    }
2363
2364    /// A torn-down sandbox does not warn on drop, and an abandoned one is the case the
2365    /// warning exists for.
2366    ///
2367    /// Asserted on the flag rather than on stderr — capturing another thread's stderr from a
2368    /// test is not something this crate can do without a dependency — so what is pinned is
2369    /// the condition the warning branches on. Both halves, so the branch cannot be
2370    /// vacuously true.
2371    #[tokio::test]
2372    async fn a_torn_down_sandbox_is_distinguishable_from_an_abandoned_one() {
2373        let (mut sandbox, recorder, _) = launched().await;
2374        assert!(
2375            sandbox.lifecycle().is_live() && !sandbox.torn_down,
2376            "an abandoned live VM is what the drop warning is for"
2377        );
2378
2379        recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2380        sandbox.terminate(TeardownOpts::default()).await;
2381        assert!(
2382            sandbox.torn_down,
2383            "terminate must mark the sandbox torn down"
2384        );
2385        assert!(
2386            !sandbox.lifecycle().is_live(),
2387            "and the lifecycle must no longer read as live"
2388        );
2389    }
2390}