Skip to main content

arcbox_computer_runtime/sandbox/
lifecycle.rs

1use super::reconcile::JournaledLease;
2use super::record::{ProvisionIntent, SandboxProvisionOutcome, SandboxTransition};
3use super::*;
4use crate::lifecycle::actor::ComputerSnapshot;
5use crate::sandbox::record::PersistPhase;
6
7/// Whether a create may restore from warm sources and publish into the
8/// warm-cache LRU. [`Disabled`](Self::Disabled) exists for the prewarm
9/// builder (CORE-107), whose boot must be a plain cold boot.
10pub(super) enum WarmPolicy {
11    Auto,
12    Disabled,
13}
14
15/// How long a handover pass waits for the startup sweep before going ahead
16/// without it.
17///
18/// The sweep bounds only its adoptions (`reconcile::ADOPT_TIMEOUT`); the kill
19/// and the CoW teardown it may run instead are unbounded. Every other verb
20/// waits for it indefinitely because a slow sweep only delays them, whereas
21/// this is the process's last chance to save the guests it holds — a pass that
22/// waits out an unbounded sweep loses all of them, including the computers the
23/// sweep has nothing to do with. Ten seconds mirrors the sweep's own
24/// per-computer bound: past that it is not about to finish.
25const SWEEP_WAIT_BUDGET: Duration = Duration::from_secs(10);
26
27impl SandboxManager {
28    /// Replay a durable Create outcome without resolving its template again.
29    pub async fn replay_sandbox_create(
30        &self,
31        id: &str,
32        request_key: &str,
33    ) -> Result<Option<(SandboxId, String)>> {
34        self.await_reconcile().await?;
35        self.records
36            .replay_provision(id, request_key)
37            .map(|outcome| outcome.map(|outcome| (id.to_owned(), outcome.ip_address)))
38    }
39
40    pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result<(SandboxId, String)> {
41        self.create_sandbox_keyed(spec, &Uuid::new_v4().to_string())
42            .await
43    }
44
45    /// Create a sandbox with a stable key for durable request replay.
46    pub async fn create_sandbox_keyed(
47        &self,
48        spec: SandboxSpec,
49        request_key: &str,
50    ) -> Result<(SandboxId, String)> {
51        self.create_sandbox_inner(spec, request_key, WarmPolicy::Auto)
52            .await
53    }
54
55    /// Bounded wait for the host to finalize a just-removed same-id
56    /// sandbox's network cleanup, so a cold-boot fallback can re-reserve.
57    ///
58    /// A failed warm restore unwinds with an internal force remove, which
59    /// quarantines the id's network until the host completes the cleanup
60    /// ticket — delivered over the always-open `WatchSandboxCleanup` stream
61    /// (terminal events prompt it; a 1 s rescan backstops), so this
62    /// normally resolves within a second or two. On expiry the fallback
63    /// proceeds anyway and `reserve` surfaces the honest UNAVAILABLE.
64    async fn await_network_release(&self, id: &SandboxId) {
65        let deadline = std::time::Instant::now() + Duration::from_secs(10);
66        loop {
67            match self.reconcile_network().pending_cleanups().await {
68                Ok(pending) if !pending.iter().any(|(vm, _)| vm.as_str() == id) => return,
69                Ok(_) => {}
70                Err(error) => {
71                    // The ledger is what the wait is about, so an unreadable
72                    // one is not something to spin on: fall through and let
73                    // `reserve` report it.
74                    warn!(sandbox_id = %id, %error, "network cleanup ledger is unreadable");
75                    return;
76                }
77            }
78            if std::time::Instant::now() > deadline {
79                warn!(
80                    sandbox_id = %id,
81                    "network cleanup still awaiting host finalization; cold boot will report it"
82                );
83                return;
84            }
85            tokio::time::sleep(Duration::from_millis(100)).await;
86        }
87    }
88
89    /// [`Self::create_sandbox_keyed`] with explicit warm behaviour: the
90    /// prewarm builder (CORE-107) passes [`WarmPolicy::Disabled`] so its own
91    /// boot neither restores from a warm source nor publishes into the
92    /// warm-cache LRU (which would burn one of the `MAX_WARM_KEYS` slots on
93    /// a duplicate full-memory snapshot).
94    pub(super) async fn create_sandbox_inner(
95        &self,
96        mut spec: SandboxSpec,
97        request_key: &str,
98        warm_policy: WarmPolicy,
99    ) -> Result<(SandboxId, String)> {
100        // Do not allocate any per-id resources until the startup orphan sweep
101        // has run — otherwise a re-created same-id sandbox races it.
102        self.await_reconcile().await?;
103
104        // Whether the caller pinned its own boot recipe. Captured before the
105        // defaults fill — a custom kernel or cmdline is never warm-created.
106        let caller_supplied_boot = !spec.kernel.is_empty() || !spec.boot_args.is_empty();
107
108        // Apply daemon defaults for fields not supplied by the caller.
109        let defaults = &self.config.defaults;
110        if spec.kernel.is_empty() {
111            spec.kernel.clone_from(&defaults.kernel);
112        }
113        if spec.rootfs.is_empty() {
114            spec.rootfs.clone_from(&defaults.rootfs);
115        }
116        if spec.boot_args.is_empty() {
117            spec.boot_args.clone_from(&defaults.boot_args);
118        }
119        if spec.vcpus == 0 {
120            spec.vcpus = defaults.vcpus as u32;
121        }
122        if spec.memory_mib == 0 {
123            spec.memory_mib = defaults.memory_mib;
124        }
125        if spec.network.mode.is_empty() {
126            spec.network.mode = "tap".into();
127        }
128
129        let id = spec
130            .id
131            .clone()
132            .filter(|s| !s.is_empty())
133            .unwrap_or_else(|| Uuid::new_v4().to_string());
134
135        // Hold a caller-supplied id to what the driver can actually run it
136        // as — its VM identity and its socket-path budget. Auto-generated
137        // UUIDs pass unchanged.
138        super::validate_new_sandbox_id(&id, &*self.services.driver, &self.config)?;
139        spec.id = Some(id.clone());
140
141        // Template warm-restore (CORE-107): a catalog template carrying a
142        // pre-warmed snapshot restores it directly — the sub-second start the
143        // template was built for. Same restore path as the warm cache below
144        // (`WarmCreate` origin delivers the CREATED→READY event contract and
145        // runs the effective spec's cmd after Ready; the checkpoint was taken
146        // idle and cmd-less, so defaults-vs-checkpoint divergence resolves to
147        // the spec). A geometry mismatch — the caller overrode `limits` —
148        // makes the snapshot unusable and cold-boots from the template
149        // rootfs, correct per the wholesale-replace contract. Unlike the
150        // warm cache, a kernel bump does NOT invalidate a template snapshot:
151        // a published template pins its artifacts, and the restore resurrects
152        // the kernel it was built with.
153        if matches!(warm_policy, WarmPolicy::Auto)
154            && let Some(warm) = spec.template_warm.clone()
155            && super::templates::template_restore_eligible(
156                &self.config,
157                &spec,
158                caller_supplied_boot,
159                &warm,
160            )
161        {
162            info!(
163                sandbox_id = %id,
164                snapshot_id = %warm.snapshot_id,
165                "template create: restoring the template's pre-warmed snapshot"
166            );
167            let mut warm_spec = spec.clone();
168            warm_spec.id = Some(id.clone());
169            match self
170                .restore_from_snapshot(
171                    super::checkpoint::RestoreRequest {
172                        snapshot_id: warm.snapshot_id.clone(),
173                        network_override: true,
174                        spec: warm_spec,
175                        origin: RestoreOrigin::WarmCreate,
176                    },
177                    request_key,
178                )
179                .await
180            {
181                Ok(result) => return Ok(result),
182                // Post-commit ambiguity: the sandbox is running under this
183                // id — cold-booting would recreate a live sandbox.
184                Err(error @ VmmError::AckUnconfirmed { .. }) => return Err(error),
185                Err(error) => {
186                    warn!(
187                        sandbox_id = %id,
188                        snapshot_id = %warm.snapshot_id,
189                        %error,
190                        "template warm restore failed; cold-booting from the template rootfs"
191                    );
192                    self.await_network_release(&id).await;
193                }
194            }
195        }
196
197        // Warm create (CORE-77): a Create whose boot shape matches a cached
198        // template snapshot restores instead of cold-booting. Decided before
199        // any resource allocation — the restore path owns its own id
200        // reservation, durable intent, and fresh network identity
201        // (`network_override`, free with net-invariant snapshots). On a miss
202        // the boot task publishes the snapshot once the guest is Ready.
203        let mut warm_publish = None;
204        if matches!(warm_policy, WarmPolicy::Auto)
205            && super::warm::warm_eligible(&self.config, &spec, caller_supplied_boot)
206        {
207            match super::warm::derive_warm_key(&spec) {
208                Ok(key) => match super::warm::find_warm_snapshot(&self.snapshots, &key) {
209                    Ok(Some(snapshot_id)) => {
210                        self.warm.touch(&key);
211                        info!(
212                            sandbox_id = %id,
213                            snapshot_id,
214                            "warm create: restoring cached template snapshot"
215                        );
216                        // The cache entry can die between this lookup and the
217                        // restore: a concurrent publish of another key evicts
218                        // by LRU and deletes the snapshot. That is a cache
219                        // miss arriving late, not a create failure — fall
220                        // through to the cold boot (which republishes) rather
221                        // than surfacing a snapshot-not-found to the caller.
222                        //
223                        // The failure class decides the fallback, carried
224                        // in the error itself: `AckUnconfirmed` is the one
225                        // post-commit failure — the sandbox is running and
226                        // READY under this id — so cold-booting there would
227                        // recreate a live (or, if the client already acted
228                        // on READY and deleted it, just-deleted) sandbox.
229                        // Every other failure unwound its claim pre-commit
230                        // and cold-boots safely. The restore is pinned to
231                        // this create's id so both outcomes concern the id
232                        // the caller asked for.
233                        let mut warm_spec = spec.clone();
234                        warm_spec.id = Some(id.clone());
235                        match self
236                            .restore_from_snapshot(
237                                super::checkpoint::RestoreRequest {
238                                    snapshot_id: snapshot_id.clone(),
239                                    network_override: true,
240                                    spec: warm_spec,
241                                    origin: RestoreOrigin::WarmCreate,
242                                },
243                                request_key,
244                            )
245                            .await
246                        {
247                            Ok(result) => return Ok(result),
248                            Err(error @ VmmError::AckUnconfirmed { .. }) => {
249                                return Err(error);
250                            }
251                            Err(error) => {
252                                warn!(
253                                    sandbox_id = %id,
254                                    snapshot_id,
255                                    %error,
256                                    "warm restore failed; cold-booting instead"
257                                );
258                                self.await_network_release(&id).await;
259                                warm_publish = Some(super::warm::WarmPublishTicket {
260                                    key,
261                                    cache: Arc::clone(&self.warm),
262                                    snapshots: Arc::clone(&self.snapshots),
263                                    pool: Arc::clone(&self.pool),
264                                });
265                            }
266                        }
267                    }
268                    Ok(None) => {
269                        warm_publish = Some(super::warm::WarmPublishTicket {
270                            key,
271                            cache: Arc::clone(&self.warm),
272                            snapshots: Arc::clone(&self.snapshots),
273                            pool: Arc::clone(&self.pool),
274                        });
275                    }
276                    Err(error) => {
277                        // The publish path would scan the same catalog, so a
278                        // failed lookup cold-boots without cache interaction.
279                        warn!(sandbox_id = %id, %error, "warm snapshot lookup failed; cold-booting");
280                    }
281                },
282                Err(error) => {
283                    debug!(sandbox_id = %id, %error, "rootfs fingerprint failed; skipping warm create");
284                }
285            }
286        }
287
288        let vm_dir = PathBuf::from(&self.config.firecracker.data_dir)
289            .join("sandboxes")
290            .join(&id);
291
292        // Create and Restore claim the in-memory namespace in the same order,
293        // before either consults durable ownership.
294        let reservation = super::reserve_actor(
295            &self.computers,
296            &id,
297            ComputerRuntime::new(id.clone(), spec.clone(), None, vm_dir.clone()),
298        )?;
299
300        // This durable boundary still precedes every external side effect.
301        let record = match self.records.provision_intent(&id, request_key, spec)? {
302            ProvisionIntent::Created(record) | ProvisionIntent::Resume(record) => record,
303            ProvisionIntent::Replay(record) => {
304                let outcome = record
305                    .provision_outcome
306                    .ok_or_else(|| VmmError::WrongState {
307                        id: id.clone(),
308                        expected: "a persisted create outcome".into(),
309                        actual: "none".into(),
310                    })?;
311                return Ok((id, outcome.ip_address));
312            }
313            ProvisionIntent::Blocked(_) => return Err(VmmError::AlreadyExists(id)),
314        };
315        let generation = record.generation;
316        let deadlines = Deadlines {
317            // Computed by the durable record so a same-key create retry keeps
318            // the original cap instead of re-deriving it from a later "now".
319            ttl: record.ttl_deadline,
320            idle_timeout_seconds: record.effective_spec.idle_timeout_seconds,
321            on_idle: record.effective_spec.on_idle,
322        };
323        let spec = record.effective_spec;
324        let arc = Arc::clone(reservation.runtime());
325        {
326            let mut creating = arc.lock().unwrap();
327            creating.record_generation = Some(generation);
328            creating.labels.clone_from(&spec.labels);
329            creating.spec.clone_from(&spec);
330            creating.ttl_deadline = deadlines.ttl;
331        }
332        // No lock is held across the awaits below: nothing can tear this
333        // computer down while it is being built, because its actor is the
334        // only thing that could and it has not been asked to do anything yet.
335
336        // Reserve the IP without touching the host, durably journal it, then
337        // materialize the TAP. No external resource exists before its cleanup
338        // metadata does.
339        // Tracked apart from the NIC on purpose: the lease exists from
340        // `reserve` on, and the rollback below must hand back one whose TAP
341        // was never built — a journal write between the two can fail.
342        let mut lease: Option<NetworkLease> = None;
343        let mut nic: Option<NicSpec> = None;
344        // Whether this boot bakes the fixed invariant identity into the
345        // guest's command line (see do_boot); false when the caller brought
346        // their own `ip=`. Independent of the mode the host side is attached
347        // in, which is `Invariant` either way.
348        let invariant_identity = !spec.boot_args.contains("ip=");
349        let setup = async {
350            if spec.network.mode != "none" {
351                lease = Some(
352                    self.services
353                        .network
354                        .reserve(&VmId::new(&id)?, super::sandbox_network_policy())
355                        .await?,
356                );
357            }
358            let ip_address = lease
359                .as_ref()
360                .map(|lease| lease.ip.to_string())
361                .unwrap_or_default();
362
363            super::reconcile::create_runtime_dir(&vm_dir)?;
364            let cleanup_record = super::reconcile::SandboxStateRecord::new(
365                &id,
366                None,
367                lease
368                    .as_ref()
369                    .map(|lease| JournaledLease::cold_boot(lease, invariant_identity)),
370                None,
371                &self.config,
372                None,
373            )?;
374            super::reconcile::write_state_record(&vm_dir, &cleanup_record)?;
375            if let Some(lease) = &lease {
376                nic = Some(
377                    self.services
378                        .network
379                        .activate(lease, AttachMode::Invariant)
380                        .await?,
381                );
382            }
383
384            Ok::<_, VmmError>(ip_address)
385        }
386        .await;
387
388        let ip_address = match setup {
389            Ok(result) => result,
390            Err(error) => {
391                // The reply frame carries the only other copy of this error;
392                // log it here so a create that dies between the network
393                // activation and the Starting journal commit is attributable
394                // from the guest log alone (CORE-82).
395                warn!(sandbox_id = %id, error = %error, "sandbox create setup failed; rolling back");
396                let mut rollback_errors = Vec::new();
397                let mut network_cleanup_failed = false;
398                if let Some(reserved) = lease.clone()
399                    && let Err(release_error) = self.services.network.release(reserved).await
400                {
401                    network_cleanup_failed = true;
402                    rollback_errors.push(format!("network: {release_error}"));
403                }
404                if !network_cleanup_failed
405                    && let Err(remove_error) = std::fs::remove_dir_all(&vm_dir)
406                    && remove_error.kind() != std::io::ErrorKind::NotFound
407                {
408                    rollback_errors.push(format!("directory {}: {remove_error}", vm_dir.display()));
409                }
410                if !rollback_errors.is_empty() {
411                    {
412                        let mut creating = arc.lock().unwrap();
413                        creating.network = lease;
414                        creating.state = SandboxState::Failed;
415                        creating.error = Some(error.to_string());
416                    }
417                    let record_error = self
418                        .records
419                        .transition(
420                            &id,
421                            generation,
422                            SandboxTransition::Failed(error.to_string()),
423                        )
424                        .err()
425                        .map(|record_error| format!("record: {record_error}"));
426                    if let Some(record_error) = record_error {
427                        rollback_errors.push(record_error);
428                    }
429                    // Kept, in the phase the record was just left in: the
430                    // computer is inspectable and removable, and its actor is
431                    // what a later Remove reaches to release whatever the
432                    // rollback could not.
433                    let (services, timers_enabled) = self.spawn_context();
434                    reservation.spawn(super::ActorSpawn {
435                        services,
436                        timers_enabled,
437                        generation: Some(generation),
438                        deadlines,
439                        launch: Launch::Reinstated,
440                        seeded: Seeded::Recovered(PersistPhase::Failed),
441                    });
442                    let rollback = rollback_errors.join("; ");
443                    error!(
444                        sandbox_id = %id,
445                        error = %error,
446                        rollback = %rollback,
447                        "sandbox create rollback incomplete; instance left Failed"
448                    );
449                    return Err(VmmError::Other(format!(
450                        "{error}; sandbox rollback is incomplete: {rollback}"
451                    )));
452                }
453                let abort = self.records.abort_provision(&id, generation)?;
454                if let Some(durability_error) = abort.durability_error {
455                    return Err(VmmError::Unavailable(format!(
456                        "{error}; create rollback is visible, but durability is unconfirmed: {durability_error}"
457                    )));
458                }
459                return Err(error);
460            }
461        };
462
463        // Populate the claimed computer.
464        // What the guest is told over its NIC is the network's answer for
465        // the mode it was activated in, not a constant of any one adapter.
466        let attachment = lease.clone().zip(nic).map(|(lease, nic)| {
467            let identity = self
468                .services
469                .network
470                .identity(&lease, AttachMode::Invariant);
471            NetworkAttachment {
472                lease,
473                nic,
474                identity,
475                invariant_identity,
476            }
477        });
478        {
479            let mut creating = arc.lock().unwrap();
480            creating.network = lease;
481            // The boot bakes the invariant `ip=` identity unless the caller
482            // supplied an explicit ip= (see do_boot); record which one this
483            // guest runs so checkpoints carry the right restore contract.
484            creating.net_invariant = creating.network.is_some() && invariant_identity;
485        }
486
487        // The actor takes it from here: the `Starting` write, the CREATED
488        // event, the TTL arm and the boot task are all effects of the
489        // provision, in that order.
490        let (services, timers_enabled) = self.spawn_context();
491        let warm = warm_publish.is_some();
492        let mailbox = reservation.spawn(super::ActorSpawn {
493            services,
494            timers_enabled,
495            generation: Some(generation),
496            deadlines,
497            launch: Launch::Boot(Box::new(BootLaunch {
498                attachment,
499                warm_publish,
500            })),
501            seeded: Seeded::Fresh,
502        });
503        let outcome = SandboxProvisionOutcome {
504            ip_address: ip_address.clone(),
505        };
506        // `AckUnconfirmed` says the `Starting` write is visible but not
507        // proven durable — the boot is under way and the caller decides. Any
508        // other error means the write was refused, so nothing was recorded
509        // and no boot started; the computer failed itself, releasing what
510        // this create had allocated, and keeps its `Failed` record rather
511        // than freeing the id — the disposition a rollback that cannot
512        // finish leaves behind.
513        mailbox
514            .ask(&id, |reply| Command::Provision {
515                provision: Provision::Boot { warm },
516                outcome,
517                reply,
518            })
519            .await?;
520        info!(sandbox_id = %id, "sandbox create requested (async boot started)");
521        Ok((id, ip_address))
522    }
523
524    /// Stop a sandbox gracefully.
525    ///
526    /// Waits up to `timeout_seconds` (default 30 s) for an active workload
527    /// to exit, asks the guest to shut down (Ctrl+Alt+Del reboots the guest,
528    /// which exits the VMM), and the driver SIGKILLs the VMM only if it
529    /// outlives the remaining budget. All runtime resources (TAP + IP,
530    /// dm-snapshot CoW, jailer chroot) are released on `Stopped`; only the
531    /// inspectable record and the log directory survive until `Remove`.
532    pub async fn stop_sandbox(&self, id: &SandboxId, timeout_seconds: u32) -> Result<()> {
533        self.await_reconcile().await?;
534        let budget = Duration::from_secs(u64::from(if timeout_seconds > 0 {
535            timeout_seconds
536        } else {
537            30
538        }));
539        self.mailbox(id)?
540            .ask(id, |reply| Command::Stop { budget, reply })
541            .await
542    }
543
544    /// Give up ownership of every live VM without stopping it, so this
545    /// process can exit and the next one adopt them.
546    ///
547    /// The composer calls this on graceful shutdown. Without it a clean exit
548    /// unwinds into the driver handle's `Drop`, which kills — so the startup
549    /// sweep's adoption would only ever fire after a crash, never after the
550    /// binary upgrade or redeploy it mainly exists for.
551    ///
552    /// Best-effort per sandbox: one VM that refuses to be handed over must
553    /// not cost the others theirs, so failures are collected and reported
554    /// together. A driver without `Detach` runs its VMs in-process and has
555    /// nothing to hand over — the same reasoning the sweep applies to
556    /// `Adopt`. The `PreparedVm` a booted sandbox also holds needs no such
557    /// step: it kills on drop only while it is still unconsumed.
558    ///
559    /// Each handover goes through its computer's actor (CORE-145). Detach is a
560    /// terminal transition, and the actor is what serializes those: taking the
561    /// handles straight out of the map let a handover run concurrently with the
562    /// `Stop` of the same computer — the very stop whose 30s budget is why a
563    /// composer's drain deadline expires and this gets called at all. Either
564    /// order damaged the guest: a stop reaching a detached handle waits out a
565    /// stood-down reaper and then SIGKILLs the VM it was just told had been
566    /// handed over, and its release hands TAP, the CoW device and the jail back
567    /// while the successor is adopting them.
568    pub async fn detach_all(&self) -> Result<()> {
569        // The sweep adopts and kills VMs by the same deterministic names, so
570        // racing it is the same class of bug; `stop_sandbox` and
571        // `remove_sandbox` wait it out for exactly this reason. Neither its
572        // failure nor its slowness is propagated the way theirs is — see
573        // `SWEEP_WAIT_BUDGET`.
574        match tokio::time::timeout(SWEEP_WAIT_BUDGET, self.await_reconcile()).await {
575            Ok(Ok(())) => {}
576            Ok(Err(error)) => warn!(%error, "the startup sweep failed; handing over anyway"),
577            Err(_) => warn!(
578                seconds = SWEEP_WAIT_BUDGET.as_secs(),
579                "the startup sweep is still running; handing over anyway"
580            ),
581        }
582        // The incarnation rides along for the one answer the mailbox cannot
583        // tell apart on its own; see the `NotFound` arms below.
584        let computers: Vec<(SandboxId, Mailbox, Uuid)> = self
585            .computers
586            .read()
587            .unwrap()
588            .iter()
589            .map(|(id, computer)| (id.clone(), computer.mailbox.clone(), computer.incarnation))
590            .collect();
591
592        // Concurrently, because the actor is already the serializer: per
593        // computer these asks are ordered by its mailbox, and between
594        // computers there is nothing to order. Sequentially, one actor that
595        // cannot reach its command loop — a teardown waiting out an in-flight
596        // boot's resource handoff parks the actor for up to two
597        // `HANDOFF_TIMEOUT`s, and a create holds its mailbox unserved until it
598        // spawns — would spend the composer's remaining deadline on behalf of
599        // every computer behind it, and those guests die unasked.
600        let mut asks = tokio::task::JoinSet::new();
601        for (id, mailbox, incarnation) in computers {
602            asks.spawn(async move {
603                let answer = mailbox.ask(&id, |reply| Command::Detach { reply }).await;
604                (id, incarnation, answer)
605            });
606        }
607
608        let mut asked = 0_usize;
609        let mut failures = Vec::new();
610        while let Some(joined) = asks.join_next().await {
611            asked += 1;
612            let (id, incarnation, answer) = match joined {
613                Ok(answered) => answered,
614                // The ask itself reports everything the actor can do to it, so
615                // a join error is this runtime coming down mid-pass — there is
616                // no computer id left to attribute it to.
617                Err(error) => {
618                    failures.push(format!("a handover could not be asked for: {error}"));
619                    continue;
620                }
621            };
622            match answer {
623                Ok(()) => {}
624                // The mailbox closed, or the reply was dropped. Both arrive as
625                // `NotFound`, and whether either cost a guest is answered by
626                // the registry rather than by the error: every clean ending
627                // unregisters — the actor's own loop tail, the record it
628                // forgets, and the reservation of a create that unwound before
629                // spawning one — so an incarnation still registered is one
630                // whose actor died without finishing. Its VM is live,
631                // unhanded, and dies with this process, which is the one thing
632                // this pass exists to report.
633                Err(VmmError::NotFound(_))
634                    if !super::still_registered(&self.computers, &id, incarnation) => {}
635                Err(VmmError::NotFound(_)) => failures.push(format!(
636                    "{id}: its actor stopped without answering, so the vm was not handed over"
637                )),
638                Err(error) => failures.push(format!("{id}: {error}")),
639            }
640        }
641        // Deliberately a count of what was *asked*, not of guests saved: an
642        // `Ok` also comes back from a paused, stopped or still-provisioning
643        // computer that had no VM to give up. Only `detach_vm` knows a handle
644        // really changed hands, and that is where each one is logged.
645        info!(
646            computers = asked,
647            failures = failures.len(),
648            "asked every computer to hand its vm to the next process"
649        );
650        if failures.is_empty() {
651            return Ok(());
652        }
653        Err(VmmError::Unavailable(format!(
654            "some sandboxes could not be handed over and will be killed on exit: {}",
655            failures.join("; ")
656        )))
657    }
658
659    /// Forcibly destroy a sandbox and release all resources immediately.
660    pub async fn remove_sandbox(&self, id: &SandboxId, force: bool) -> Result<()> {
661        self.await_reconcile().await?;
662        let mailbox = match self.mailbox(id) {
663            Ok(mailbox) => mailbox,
664            // No computer under this id. It may still own a durable record —
665            // an intent nobody acknowledged, or a tombstone a previous
666            // removal did not finish — and the claim is what serializes this
667            // against a create of the same id arriving now.
668            Err(VmmError::NotFound(_)) => {
669                let vm_dir = PathBuf::from(&self.config.firecracker.data_dir)
670                    .join("sandboxes")
671                    .join(id);
672                match super::reserve_actor(
673                    &self.computers,
674                    id,
675                    ComputerRuntime::new(
676                        id.clone(),
677                        SandboxSpec {
678                            id: Some(id.clone()),
679                            ..Default::default()
680                        },
681                        None,
682                        vm_dir,
683                    ),
684                ) {
685                    Ok(_reservation) => {
686                        let commit = self.records.cancel_pending_or_missing(id)?;
687                        if let Some(error) = commit.durability_error {
688                            return Err(VmmError::Unavailable(format!(
689                                "sandbox {id} removal is visible, but durability is unconfirmed: {error}"
690                            )));
691                        }
692                        info!(sandbox_id = %id, "sandbox already removed");
693                        return Ok(());
694                    }
695                    // A create won the race for the id; remove what it made.
696                    Err(VmmError::AlreadyExists(_)) => self.mailbox(id)?,
697                    Err(error) => return Err(error),
698                }
699            }
700            Err(error) => return Err(error),
701        };
702        mailbox
703            .ask(id, |reply| Command::Remove { force, reply })
704            .await?;
705        info!(sandbox_id = %id, "sandbox removed");
706        Ok(())
707    }
708
709    /// Files a checkpoint occupies on disk, for storage accounting.
710    ///
711    /// Empty when the id is unknown — a paused sandbox whose checkpoint went
712    /// missing reports the disk overlay alone rather than failing the read.
713    fn checkpoint_paths(&self, snapshot_id: &str) -> Vec<PathBuf> {
714        self.snapshots
715            .find_by_id(snapshot_id)
716            .map(|meta| {
717                std::iter::once(meta.vmstate_path)
718                    .chain(meta.mem_path)
719                    .collect()
720            })
721            .unwrap_or_default()
722    }
723
724    /// Return the current state and metadata of a sandbox.
725    pub fn inspect_sandbox(&self, id: &SandboxId) -> Result<SandboxInfo> {
726        let snapshot = self.snapshot(id)?;
727        // Size the retained artifacts after the read: the sizing stats files
728        // and scans the catalog, and the snapshot is a borrow of a `watch`
729        // the actor writes.
730        let artifacts = super::storage::retained_artifacts(&self.config, id, &snapshot);
731        let mut info = snapshot_to_info(id, &snapshot);
732        info.storage_bytes = artifacts.storage_bytes(|id| self.checkpoint_paths(id));
733        Ok(info)
734    }
735
736    /// List sandboxes, optionally filtered by state string and/or labels.
737    pub fn list_sandboxes(
738        &self,
739        state_filter: Option<&str>,
740        label_filter: &HashMap<String, String>,
741    ) -> Result<Vec<SandboxSummary>> {
742        self.check_reconcile()?;
743        // Snapshot every computer's read view under the map read guard. No
744        // per-computer lock is taken at all, so the "never hold the map lock
745        // while holding an instance lock" discipline this fold used to need
746        // has nothing left to violate.
747        let computers: Vec<(SandboxId, ComputerSnapshot)> = self
748            .computers
749            .read()
750            .unwrap()
751            .iter()
752            .map(|(id, computer)| (id.clone(), computer.snapshot.borrow().clone()))
753            .collect();
754        // The second pass sizes every row — a couple of `stat` calls each,
755        // which is what keeps List and Inspect agreeing in every state — and
756        // pays one catalog listing for the whole response instead of one per
757        // paused sandbox (only paused rows carry a checkpoint to resolve).
758        let mut summaries: Vec<(SandboxSummary, super::storage::RetainedArtifacts)> = computers
759            .iter()
760            .filter_map(|(id, snapshot)| {
761                if let Some(sf) = state_filter
762                    && !sf.is_empty()
763                    && snapshot.state.to_string() != sf
764                {
765                    return None;
766                }
767                // Label filter: all supplied key-value pairs must match.
768                for (k, v) in label_filter {
769                    if snapshot.labels.get(k).map(String::as_str) != Some(v.as_str()) {
770                        return None;
771                    }
772                }
773                let summary = SandboxSummary {
774                    id: id.clone(),
775                    state: snapshot.state,
776                    labels: snapshot.labels.clone(),
777                    ip_address: snapshot
778                        .lease
779                        .as_ref()
780                        .map(|lease| lease.ip.to_string())
781                        .unwrap_or_default(),
782                    created_at: snapshot.created_at,
783                    paused_at: snapshot.paused_at,
784                    storage_bytes: 0,
785                };
786                let artifacts = super::storage::retained_artifacts(&self.config, id, snapshot);
787                Some((summary, artifacts))
788            })
789            .collect();
790
791        let needs_catalog = summaries
792            .iter()
793            .any(|(_, artifacts)| artifacts.has_checkpoint());
794        let catalog = if needs_catalog {
795            self.snapshots.list_all().unwrap_or_default()
796        } else {
797            Vec::new()
798        };
799        for (summary, artifacts) in &mut summaries {
800            summary.storage_bytes = artifacts.storage_bytes(|id| {
801                catalog
802                    .iter()
803                    .find(|info| info.id == id)
804                    .map(snapshot_files)
805                    .unwrap_or_default()
806            });
807        }
808        Ok(summaries.into_iter().map(|(summary, _)| summary).collect())
809    }
810
811    /// Subscribe to sandbox lifecycle events.
812    ///
813    /// **Delivery is best-effort — treat the stream as a latency
814    /// optimization over polling, never as an authoritative log.** Events
815    /// published before this call are gone (with no subscriber at all they
816    /// are discarded outright), and a subscriber that falls more than the
817    /// channel capacity behind gets
818    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
819    /// with the overwritten events lost.
820    ///
821    /// What makes the loss *detectable* is [`SandboxEvent::sequence`]
822    /// (CORE-147): 1-based, global across all sandboxes of this manager,
823    /// and contiguous in the order received — so a subscriber that sees
824    /// `sequence` jump by more than one has missed events (including any
825    /// history from before it subscribed) and should fall back to
826    /// [`Self::inspect_sandbox`] / [`Self::list_sandboxes`] to re-derive
827    /// state instead of carrying what it has. That test is conclusive
828    /// precisely because this subscription is unfiltered; a downstream
829    /// view that filters it (the wire API's per-sandbox subscription)
830    /// sees legitimate gaps and must not read them as loss. The counter is not
831    /// persisted: a new manager numbers from 1 again, and events emitted
832    /// while no manager ran were never numbered at all — a consumer that
833    /// outlives the manager must reconcile on reconnect regardless of
834    /// sequence.
835    pub fn subscribe_events(&self) -> broadcast::Receiver<SandboxEvent> {
836        self.events.subscribe()
837    }
838
839    /// Verify the computer is `Ready` and return the agent inside it.
840    ///
841    /// A paused computer answers [`VmmError::Paused`], not `WrongState`, so
842    /// the daemon can resume it transparently and retry (CORE-21).
843    pub(super) fn require_ready_agent(&self, id: &SandboxId) -> Result<Arc<dyn GuestAgent>> {
844        self.agent_in(id, &[SandboxState::Ready], "Ready")
845    }
846
847    /// Verify the computer is alive (Ready or Running) and return the agent
848    /// inside it. Unlike [`Self::require_ready_agent`], an in-flight workload
849    /// does not block the operation — file I/O works alongside Run/Exec.
850    /// Paused states map to [`VmmError::Paused`] for the daemon's transparent
851    /// resume, as above.
852    pub(super) fn require_alive_agent(&self, id: &SandboxId) -> Result<Arc<dyn GuestAgent>> {
853        self.agent_in(
854            id,
855            &[SandboxState::Ready, SandboxState::Running],
856            "Ready or Running",
857        )
858    }
859
860    /// The agent reaching this computer's guest, if it is in one of `allowed`.
861    ///
862    /// The whole data plane's entry point, and the reason the agent rides the
863    /// read snapshot (§B.6 of the R3 plan): one borrow of a `watch`, no
864    /// mailbox round-trip and no lock a lifecycle transition also needs, so
865    /// an exec costs a clone of an `Arc` rather than a scheduling hop.
866    fn agent_in(
867        &self,
868        id: &SandboxId,
869        allowed: &[SandboxState],
870        expected: &str,
871    ) -> Result<Arc<dyn GuestAgent>> {
872        let snapshot = self.snapshot(id)?;
873        if !allowed.contains(&snapshot.state) {
874            return Err(match snapshot.state {
875                SandboxState::Pausing | SandboxState::Paused => VmmError::Paused(id.clone()),
876                state => VmmError::WrongState {
877                    id: id.clone(),
878                    expected: expected.to_owned(),
879                    actual: state.to_string(),
880                },
881            });
882        }
883        snapshot
884            .agent
885            .ok_or_else(|| VmmError::Vsock(format!("sandbox {id} has no running vm to reach")))
886    }
887}
888
889/// A computer's read snapshot as `Inspect` reports it.
890fn snapshot_to_info(id: &SandboxId, snapshot: &ComputerSnapshot) -> SandboxInfo {
891    SandboxInfo {
892        id: id.clone(),
893        state: snapshot.state,
894        labels: snapshot.labels.clone(),
895        vcpus: snapshot.vcpus,
896        memory_mib: snapshot.memory_mib,
897        network: snapshot.lease.as_ref().map(|lease| SandboxNetworkInfo {
898            ip_address: lease.ip.to_string(),
899            gateway: lease.gateway.to_string(),
900        }),
901        created_at: snapshot.created_at,
902        ready_at: snapshot.ready_at,
903        last_exited_at: snapshot.last_exited_at,
904        last_exit_status: snapshot.last_exit_status,
905        error: snapshot.error.clone(),
906        paused_at: snapshot.paused_at,
907        // Filled by the caller in every state (it owns the paths).
908        storage_bytes: 0,
909        ttl_deadline: snapshot.deadlines.ttl,
910        idle_timeout_seconds: snapshot.deadlines.idle_timeout_seconds,
911        on_idle: snapshot.deadlines.on_idle,
912    }
913}
914
915/// Files a listed checkpoint occupies on disk, for storage accounting.
916///
917/// The listing counterpart of [`SandboxManager::checkpoint_paths`]: it reads
918/// an already-loaded [`SnapshotInfo`] so a multi-sandbox response pays one
919/// catalog scan rather than one per paused sandbox.
920fn snapshot_files(info: &crate::snapshot::SnapshotInfo) -> Vec<PathBuf> {
921    std::iter::once(info.vmstate_path.clone())
922        .chain(info.mem_path.clone())
923        .collect()
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use crate::testkit::agent::FakeAgentFactory;
930
931    /// A factory whose guests announce nothing needs no `vsock_listen` from
932    /// the driver: what the readiness gate costs is the agent port's answer.
933    #[test]
934    fn a_probing_agent_does_not_require_a_listening_driver() {
935        let dir = tempfile::tempdir().unwrap();
936        let mut config = RuntimeConfig::default();
937        config.firecracker.data_dir = dir.path().to_string_lossy().into_owned();
938        let driver = arcbox_vm_driver::testkit::FakeDriver::builder()
939            .capabilities(arcbox_vm_driver::DriverCapabilities {
940                vsock_listen: false,
941                ..arcbox_vm_driver::testkit::FakeDriver::new().capabilities()
942            })
943            .build();
944        SandboxManager::new(
945            config.clone(),
946            NodeEnvironment {
947                driver: Arc::new(driver),
948                network: Arc::new(
949                    arcbox_vm_driver::testkit::FakeNetwork::with_startup_cleanup("test-boot"),
950                ),
951                agent: Arc::new(FakeAgentFactory::new()),
952                ..crate::testkit::fake_environment(&config).unwrap()
953            },
954        )
955        .expect("a probing agent asks the driver for no listener");
956    }
957}