Skip to main content

arcbox_computer_runtime/sandbox/
lifecycle.rs

1use super::reconcile::JournaledLease;
2use super::record::{ComputerProvisionOutcome, ComputerTransition, ProvisionIntent};
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 ComputerManager {
28    /// Replay a durable Create outcome without resolving its template again.
29    pub async fn replay_computer_create(
30        &self,
31        id: &str,
32        request_key: &str,
33    ) -> Result<Option<(ComputerId, 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_computer(&self, spec: ComputerSpec) -> Result<(ComputerId, String)> {
41        self.create_computer_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_computer_keyed(
47        &self,
48        spec: ComputerSpec,
49        request_key: &str,
50    ) -> Result<(ComputerId, String)> {
51        self.create_computer_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: &ComputerId) {
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_computer_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_computer_inner(
95        &self,
96        mut spec: ComputerSpec,
97        request_key: &str,
98        warm_policy: WarmPolicy,
99    ) -> Result<(ComputerId, 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_computer_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 @ ComputerError::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 @ ComputerError::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 =
305                    record
306                        .provision_outcome
307                        .ok_or_else(|| ComputerError::WrongState {
308                            id: id.clone(),
309                            expected: "a persisted create outcome".into(),
310                            actual: "none".into(),
311                        })?;
312                return Ok((id, outcome.ip_address));
313            }
314            ProvisionIntent::Blocked(_) => return Err(ComputerError::AlreadyExists(id)),
315        };
316        let generation = record.generation;
317        let deadlines = Deadlines {
318            // Computed by the durable record so a same-key create retry keeps
319            // the original cap instead of re-deriving it from a later "now".
320            ttl: record.ttl_deadline,
321            idle_timeout_seconds: record.effective_spec.idle_timeout_seconds,
322            on_idle: record.effective_spec.on_idle,
323        };
324        let spec = record.effective_spec;
325        let arc = Arc::clone(reservation.runtime());
326        {
327            let mut creating = arc.lock().unwrap();
328            creating.record_generation = Some(generation);
329            creating.labels.clone_from(&spec.labels);
330            creating.spec.clone_from(&spec);
331            creating.ttl_deadline = deadlines.ttl;
332        }
333        // No lock is held across the awaits below: nothing can tear this
334        // computer down while it is being built, because its actor is the
335        // only thing that could and it has not been asked to do anything yet.
336
337        // Reserve the IP without touching the host, durably journal it, then
338        // materialize the TAP. No external resource exists before its cleanup
339        // metadata does.
340        // Tracked apart from the NIC on purpose: the lease exists from
341        // `reserve` on, and the rollback below must hand back one whose TAP
342        // was never built — a journal write between the two can fail.
343        let mut lease: Option<NetworkLease> = None;
344        let mut nic: Option<NicSpec> = None;
345        // Whether this boot bakes the fixed invariant identity into the
346        // guest's command line (see do_boot); false when the caller brought
347        // their own `ip=`. Independent of the mode the host side is attached
348        // in, which is `Invariant` either way.
349        let invariant_identity = !spec.boot_args.contains("ip=");
350        let setup = async {
351            if spec.network.mode != "none" {
352                lease = Some(
353                    self.services
354                        .network
355                        .reserve(&VmId::new(&id)?, super::computer_network_policy())
356                        .await?,
357                );
358            }
359            let ip_address = lease
360                .as_ref()
361                .map(|lease| lease.ip.to_string())
362                .unwrap_or_default();
363
364            super::reconcile::create_runtime_dir(&vm_dir)?;
365            let cleanup_record = super::reconcile::ComputerStateRecord::new(
366                &id,
367                None,
368                lease
369                    .as_ref()
370                    .map(|lease| JournaledLease::cold_boot(lease, invariant_identity)),
371                None,
372                &self.config,
373                None,
374            )?;
375            super::reconcile::write_state_record(&vm_dir, &cleanup_record)?;
376            if let Some(lease) = &lease {
377                nic = Some(
378                    self.services
379                        .network
380                        .activate(lease, AttachMode::Invariant)
381                        .await?,
382                );
383            }
384
385            Ok::<_, ComputerError>(ip_address)
386        }
387        .await;
388
389        let ip_address = match setup {
390            Ok(result) => result,
391            Err(error) => {
392                // The reply frame carries the only other copy of this error;
393                // log it here so a create that dies between the network
394                // activation and the Starting journal commit is attributable
395                // from the guest log alone (CORE-82).
396                warn!(sandbox_id = %id, error = %error, "sandbox create setup failed; rolling back");
397                let mut rollback_errors = Vec::new();
398                let mut network_cleanup_failed = false;
399                if let Some(reserved) = lease.clone()
400                    && let Err(release_error) = self.services.network.release(reserved).await
401                {
402                    network_cleanup_failed = true;
403                    rollback_errors.push(format!("network: {release_error}"));
404                }
405                if !network_cleanup_failed
406                    && let Err(remove_error) = std::fs::remove_dir_all(&vm_dir)
407                    && remove_error.kind() != std::io::ErrorKind::NotFound
408                {
409                    rollback_errors.push(format!("directory {}: {remove_error}", vm_dir.display()));
410                }
411                if !rollback_errors.is_empty() {
412                    {
413                        let mut creating = arc.lock().unwrap();
414                        creating.network = lease;
415                        creating.state = ComputerState::Failed;
416                        creating.error = Some(error.to_string());
417                    }
418                    let record_error = self
419                        .records
420                        .transition(
421                            &id,
422                            generation,
423                            ComputerTransition::Failed(error.to_string()),
424                        )
425                        .err()
426                        .map(|record_error| format!("record: {record_error}"));
427                    if let Some(record_error) = record_error {
428                        rollback_errors.push(record_error);
429                    }
430                    // Kept, in the phase the record was just left in: the
431                    // computer is inspectable and removable, and its actor is
432                    // what a later Remove reaches to release whatever the
433                    // rollback could not.
434                    let (services, timers_enabled) = self.spawn_context();
435                    reservation.spawn(super::ActorSpawn {
436                        services,
437                        timers_enabled,
438                        generation: Some(generation),
439                        deadlines,
440                        launch: Launch::Reinstated,
441                        seeded: Seeded::Recovered(PersistPhase::Failed),
442                    });
443                    let rollback = rollback_errors.join("; ");
444                    error!(
445                        sandbox_id = %id,
446                        error = %error,
447                        rollback = %rollback,
448                        "sandbox create rollback incomplete; instance left Failed"
449                    );
450                    return Err(ComputerError::Other(format!(
451                        "{error}; sandbox rollback is incomplete: {rollback}"
452                    )));
453                }
454                let abort = self.records.abort_provision(&id, generation)?;
455                if let Some(durability_error) = abort.durability_error {
456                    return Err(ComputerError::Unavailable(format!(
457                        "{error}; create rollback is visible, but durability is unconfirmed: {durability_error}"
458                    )));
459                }
460                return Err(error);
461            }
462        };
463
464        // Populate the claimed computer.
465        // What the guest is told over its NIC is the network's answer for
466        // the mode it was activated in, not a constant of any one adapter.
467        let attachment = lease.clone().zip(nic).map(|(lease, nic)| {
468            let identity = self
469                .services
470                .network
471                .identity(&lease, AttachMode::Invariant);
472            NetworkAttachment {
473                lease,
474                nic,
475                identity,
476                invariant_identity,
477            }
478        });
479        {
480            let mut creating = arc.lock().unwrap();
481            creating.network = lease;
482            // The boot bakes the invariant `ip=` identity unless the caller
483            // supplied an explicit ip= (see do_boot); record which one this
484            // guest runs so checkpoints carry the right restore contract.
485            creating.net_invariant = creating.network.is_some() && invariant_identity;
486        }
487
488        // The actor takes it from here: the `Starting` write, the CREATED
489        // event, the TTL arm and the boot task are all effects of the
490        // provision, in that order.
491        let (services, timers_enabled) = self.spawn_context();
492        let warm = warm_publish.is_some();
493        let mailbox = reservation.spawn(super::ActorSpawn {
494            services,
495            timers_enabled,
496            generation: Some(generation),
497            deadlines,
498            launch: Launch::Boot(Box::new(BootLaunch {
499                attachment,
500                warm_publish,
501            })),
502            seeded: Seeded::Fresh,
503        });
504        let outcome = ComputerProvisionOutcome {
505            ip_address: ip_address.clone(),
506        };
507        // `AckUnconfirmed` says the `Starting` write is visible but not
508        // proven durable — the boot is under way and the caller decides. Any
509        // other error means the write was refused, so nothing was recorded
510        // and no boot started; the computer failed itself, releasing what
511        // this create had allocated, and keeps its `Failed` record rather
512        // than freeing the id — the disposition a rollback that cannot
513        // finish leaves behind.
514        mailbox
515            .ask(&id, |reply| Command::Provision {
516                provision: Provision::Boot { warm },
517                outcome,
518                reply,
519            })
520            .await?;
521        info!(sandbox_id = %id, "sandbox create requested (async boot started)");
522        Ok((id, ip_address))
523    }
524
525    /// Stop a sandbox gracefully.
526    ///
527    /// Waits up to `timeout_seconds` (default 30 s) for an active workload
528    /// to exit, asks the guest to shut down (Ctrl+Alt+Del reboots the guest,
529    /// which exits the VMM), and the driver SIGKILLs the VMM only if it
530    /// outlives the remaining budget. All runtime resources (TAP + IP,
531    /// dm-snapshot CoW, jailer chroot) are released on `Stopped`; only the
532    /// inspectable record and the log directory survive until `Remove`.
533    pub async fn stop_computer(&self, id: &ComputerId, timeout_seconds: u32) -> Result<()> {
534        self.await_reconcile().await?;
535        let budget = Duration::from_secs(u64::from(if timeout_seconds > 0 {
536            timeout_seconds
537        } else {
538            30
539        }));
540        self.mailbox(id)?
541            .ask(id, |reply| Command::Stop { budget, reply })
542            .await
543    }
544
545    /// Give up ownership of every live VM without stopping it, so this
546    /// process can exit and the next one adopt them.
547    ///
548    /// The composer calls this on graceful shutdown. Without it a clean exit
549    /// unwinds into the driver handle's `Drop`, which kills — so the startup
550    /// sweep's adoption would only ever fire after a crash, never after the
551    /// binary upgrade or redeploy it mainly exists for.
552    ///
553    /// Best-effort per sandbox: one VM that refuses to be handed over must
554    /// not cost the others theirs, so failures are collected and reported
555    /// together. A driver without `Detach` runs its VMs in-process and has
556    /// nothing to hand over — the same reasoning the sweep applies to
557    /// `Adopt`. The `PreparedVm` a booted sandbox also holds needs no such
558    /// step: it kills on drop only while it is still unconsumed.
559    ///
560    /// Each handover goes through its computer's actor (CORE-145). Detach is a
561    /// terminal transition, and the actor is what serializes those: taking the
562    /// handles straight out of the map let a handover run concurrently with the
563    /// `Stop` of the same computer — the very stop whose 30s budget is why a
564    /// composer's drain deadline expires and this gets called at all. Either
565    /// order damaged the guest: a stop reaching a detached handle waits out a
566    /// stood-down reaper and then SIGKILLs the VM it was just told had been
567    /// handed over, and its release hands TAP, the CoW device and the jail back
568    /// while the successor is adopting them.
569    pub async fn detach_all(&self) -> Result<()> {
570        // The sweep adopts and kills VMs by the same deterministic names, so
571        // racing it is the same class of bug; `stop_computer` and
572        // `remove_computer` wait it out for exactly this reason. Neither its
573        // failure nor its slowness is propagated the way theirs is — see
574        // `SWEEP_WAIT_BUDGET`.
575        match tokio::time::timeout(SWEEP_WAIT_BUDGET, self.await_reconcile()).await {
576            Ok(Ok(())) => {}
577            Ok(Err(error)) => warn!(%error, "the startup sweep failed; handing over anyway"),
578            Err(_) => warn!(
579                seconds = SWEEP_WAIT_BUDGET.as_secs(),
580                "the startup sweep is still running; handing over anyway"
581            ),
582        }
583        // The incarnation rides along for the one answer the mailbox cannot
584        // tell apart on its own; see the `NotFound` arms below.
585        let computers: Vec<(ComputerId, Mailbox, Uuid)> = self
586            .computers
587            .read()
588            .unwrap()
589            .iter()
590            .map(|(id, computer)| (id.clone(), computer.mailbox.clone(), computer.incarnation))
591            .collect();
592
593        // Concurrently, because the actor is already the serializer: per
594        // computer these asks are ordered by its mailbox, and between
595        // computers there is nothing to order. Sequentially, one actor that
596        // cannot reach its command loop — a teardown waiting out an in-flight
597        // boot's resource handoff parks the actor for up to two
598        // `HANDOFF_TIMEOUT`s, and a create holds its mailbox unserved until it
599        // spawns — would spend the composer's remaining deadline on behalf of
600        // every computer behind it, and those guests die unasked.
601        let mut asks = tokio::task::JoinSet::new();
602        for (id, mailbox, incarnation) in computers {
603            asks.spawn(async move {
604                let answer = mailbox.ask(&id, |reply| Command::Detach { reply }).await;
605                (id, incarnation, answer)
606            });
607        }
608
609        let mut asked = 0_usize;
610        let mut failures = Vec::new();
611        while let Some(joined) = asks.join_next().await {
612            asked += 1;
613            let (id, incarnation, answer) = match joined {
614                Ok(answered) => answered,
615                // The ask itself reports everything the actor can do to it, so
616                // a join error is this runtime coming down mid-pass — there is
617                // no computer id left to attribute it to.
618                Err(error) => {
619                    failures.push(format!("a handover could not be asked for: {error}"));
620                    continue;
621                }
622            };
623            match answer {
624                Ok(()) => {}
625                // The mailbox closed, or the reply was dropped. Both arrive as
626                // `NotFound`, and whether either cost a guest is answered by
627                // the registry rather than by the error: every clean ending
628                // unregisters — the actor's own loop tail, the record it
629                // forgets, and the reservation of a create that unwound before
630                // spawning one — so an incarnation still registered is one
631                // whose actor died without finishing. Its VM is live,
632                // unhanded, and dies with this process, which is the one thing
633                // this pass exists to report.
634                Err(ComputerError::NotFound(_))
635                    if !super::still_registered(&self.computers, &id, incarnation) => {}
636                Err(ComputerError::NotFound(_)) => failures.push(format!(
637                    "{id}: its actor stopped without answering, so the vm was not handed over"
638                )),
639                Err(error) => failures.push(format!("{id}: {error}")),
640            }
641        }
642        // Deliberately a count of what was *asked*, not of guests saved: an
643        // `Ok` also comes back from a paused, stopped or still-provisioning
644        // computer that had no VM to give up. Only `detach_vm` knows a handle
645        // really changed hands, and that is where each one is logged.
646        info!(
647            computers = asked,
648            failures = failures.len(),
649            "asked every computer to hand its vm to the next process"
650        );
651        if failures.is_empty() {
652            return Ok(());
653        }
654        Err(ComputerError::Unavailable(format!(
655            "some sandboxes could not be handed over and will be killed on exit: {}",
656            failures.join("; ")
657        )))
658    }
659
660    /// Forcibly destroy a sandbox and release all resources immediately.
661    pub async fn remove_computer(&self, id: &ComputerId, force: bool) -> Result<()> {
662        self.await_reconcile().await?;
663        let mailbox = match self.mailbox(id) {
664            Ok(mailbox) => mailbox,
665            // No computer under this id. It may still own a durable record —
666            // an intent nobody acknowledged, or a tombstone a previous
667            // removal did not finish — and the claim is what serializes this
668            // against a create of the same id arriving now.
669            Err(ComputerError::NotFound(_)) => {
670                let vm_dir = PathBuf::from(&self.config.firecracker.data_dir)
671                    .join("sandboxes")
672                    .join(id);
673                match super::reserve_actor(
674                    &self.computers,
675                    id,
676                    ComputerRuntime::new(
677                        id.clone(),
678                        ComputerSpec {
679                            id: Some(id.clone()),
680                            ..Default::default()
681                        },
682                        None,
683                        vm_dir,
684                    ),
685                ) {
686                    Ok(_reservation) => {
687                        let commit = self.records.cancel_pending_or_missing(id)?;
688                        if let Some(error) = commit.durability_error {
689                            return Err(ComputerError::Unavailable(format!(
690                                "sandbox {id} removal is visible, but durability is unconfirmed: {error}"
691                            )));
692                        }
693                        info!(sandbox_id = %id, "sandbox already removed");
694                        return Ok(());
695                    }
696                    // A create won the race for the id; remove what it made.
697                    Err(ComputerError::AlreadyExists(_)) => self.mailbox(id)?,
698                    Err(error) => return Err(error),
699                }
700            }
701            Err(error) => return Err(error),
702        };
703        mailbox
704            .ask(id, |reply| Command::Remove { force, reply })
705            .await?;
706        info!(sandbox_id = %id, "sandbox removed");
707        Ok(())
708    }
709
710    /// Files a checkpoint occupies on disk, for storage accounting.
711    ///
712    /// Empty when the id is unknown — a paused sandbox whose checkpoint went
713    /// missing reports the disk overlay alone rather than failing the read.
714    fn checkpoint_paths(&self, snapshot_id: &str) -> Vec<PathBuf> {
715        self.snapshots
716            .find_by_id(snapshot_id)
717            .map(|meta| {
718                std::iter::once(meta.vmstate_path)
719                    .chain(meta.mem_path)
720                    .collect()
721            })
722            .unwrap_or_default()
723    }
724
725    /// Return the current state and metadata of a sandbox.
726    pub fn inspect_computer(&self, id: &ComputerId) -> Result<ComputerInfo> {
727        let snapshot = self.snapshot(id)?;
728        // Size the retained artifacts after the read: the sizing stats files
729        // and scans the catalog, and the snapshot is a borrow of a `watch`
730        // the actor writes.
731        let artifacts = super::storage::retained_artifacts(&self.config, id, &snapshot);
732        let mut info = snapshot_to_info(id, &snapshot);
733        info.storage_bytes = artifacts.storage_bytes(|id| self.checkpoint_paths(id));
734        Ok(info)
735    }
736
737    /// List sandboxes, optionally filtered by state string and/or labels.
738    pub fn list_computers(
739        &self,
740        state_filter: Option<&str>,
741        label_filter: &HashMap<String, String>,
742    ) -> Result<Vec<ComputerSummary>> {
743        self.check_reconcile()?;
744        // Snapshot every computer's read view under the map read guard. No
745        // per-computer lock is taken at all, so the "never hold the map lock
746        // while holding an instance lock" discipline this fold used to need
747        // has nothing left to violate.
748        let computers: Vec<(ComputerId, ComputerSnapshot)> = self
749            .computers
750            .read()
751            .unwrap()
752            .iter()
753            .map(|(id, computer)| (id.clone(), computer.snapshot.borrow().clone()))
754            .collect();
755        // The second pass sizes every row — a couple of `stat` calls each,
756        // which is what keeps List and Inspect agreeing in every state — and
757        // pays one catalog listing for the whole response instead of one per
758        // paused sandbox (only paused rows carry a checkpoint to resolve).
759        let mut summaries: Vec<(ComputerSummary, super::storage::RetainedArtifacts)> = computers
760            .iter()
761            .filter_map(|(id, snapshot)| {
762                if let Some(sf) = state_filter
763                    && !sf.is_empty()
764                    && snapshot.state.to_string() != sf
765                {
766                    return None;
767                }
768                // Label filter: all supplied key-value pairs must match.
769                for (k, v) in label_filter {
770                    if snapshot.labels.get(k).map(String::as_str) != Some(v.as_str()) {
771                        return None;
772                    }
773                }
774                let summary = ComputerSummary {
775                    id: id.clone(),
776                    state: snapshot.state,
777                    labels: snapshot.labels.clone(),
778                    ip_address: snapshot
779                        .lease
780                        .as_ref()
781                        .map(|lease| lease.ip.to_string())
782                        .unwrap_or_default(),
783                    created_at: snapshot.created_at,
784                    paused_at: snapshot.paused_at,
785                    storage_bytes: 0,
786                };
787                let artifacts = super::storage::retained_artifacts(&self.config, id, snapshot);
788                Some((summary, artifacts))
789            })
790            .collect();
791
792        let needs_catalog = summaries
793            .iter()
794            .any(|(_, artifacts)| artifacts.has_checkpoint());
795        let catalog = if needs_catalog {
796            self.snapshots.list_all().unwrap_or_default()
797        } else {
798            Vec::new()
799        };
800        for (summary, artifacts) in &mut summaries {
801            summary.storage_bytes = artifacts.storage_bytes(|id| {
802                catalog
803                    .iter()
804                    .find(|info| info.id == id)
805                    .map(snapshot_files)
806                    .unwrap_or_default()
807            });
808        }
809        Ok(summaries.into_iter().map(|(summary, _)| summary).collect())
810    }
811
812    /// Subscribe to sandbox lifecycle events.
813    ///
814    /// **Delivery is best-effort — treat the stream as a latency
815    /// optimization over polling, never as an authoritative log.** Events
816    /// published before this call are gone (with no subscriber at all they
817    /// are discarded outright), and a subscriber that falls more than the
818    /// channel capacity behind gets
819    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
820    /// with the overwritten events lost.
821    ///
822    /// What makes the loss *detectable* is [`ComputerEvent::sequence`]
823    /// (CORE-147): 1-based, global across all sandboxes of this manager,
824    /// and contiguous in the order received — so a subscriber that sees
825    /// `sequence` jump by more than one has missed events (including any
826    /// history from before it subscribed) and should fall back to
827    /// [`Self::inspect_computer`] / [`Self::list_computers`] to re-derive
828    /// state instead of carrying what it has. That test is conclusive
829    /// precisely because this subscription is unfiltered; a downstream
830    /// view that filters it (the wire API's per-sandbox subscription)
831    /// sees legitimate gaps and must not read them as loss. The counter is not
832    /// persisted: a new manager numbers from 1 again, and events emitted
833    /// while no manager ran were never numbered at all — a consumer that
834    /// outlives the manager must reconcile on reconnect regardless of
835    /// sequence.
836    pub fn subscribe_events(&self) -> broadcast::Receiver<ComputerEvent> {
837        self.events.subscribe()
838    }
839
840    /// Verify the computer is `Ready` and return the agent inside it.
841    ///
842    /// A paused computer answers [`ComputerError::Paused`], not `WrongState`, so
843    /// the daemon can resume it transparently and retry (CORE-21).
844    pub(super) fn require_ready_agent(&self, id: &ComputerId) -> Result<Arc<dyn GuestAgent>> {
845        self.agent_in(id, &[ComputerState::Ready], "Ready")
846    }
847
848    /// Verify the computer is alive (Ready or Running) and return the agent
849    /// inside it. Unlike [`Self::require_ready_agent`], an in-flight workload
850    /// does not block the operation — file I/O works alongside Run/Exec.
851    /// Paused states map to [`ComputerError::Paused`] for the daemon's transparent
852    /// resume, as above.
853    pub(super) fn require_alive_agent(&self, id: &ComputerId) -> Result<Arc<dyn GuestAgent>> {
854        self.agent_in(
855            id,
856            &[ComputerState::Ready, ComputerState::Running],
857            "Ready or Running",
858        )
859    }
860
861    /// The agent reaching this computer's guest, if it is in one of `allowed`.
862    ///
863    /// The whole data plane's entry point, and the reason the agent rides the
864    /// read snapshot (§B.6 of the R3 plan): one borrow of a `watch`, no
865    /// mailbox round-trip and no lock a lifecycle transition also needs, so
866    /// an exec costs a clone of an `Arc` rather than a scheduling hop.
867    fn agent_in(
868        &self,
869        id: &ComputerId,
870        allowed: &[ComputerState],
871        expected: &str,
872    ) -> Result<Arc<dyn GuestAgent>> {
873        let snapshot = self.snapshot(id)?;
874        if !allowed.contains(&snapshot.state) {
875            return Err(match snapshot.state {
876                ComputerState::Pausing | ComputerState::Paused => ComputerError::Paused(id.clone()),
877                state => ComputerError::WrongState {
878                    id: id.clone(),
879                    expected: expected.to_owned(),
880                    actual: state.to_string(),
881                },
882            });
883        }
884        snapshot
885            .agent
886            .ok_or_else(|| ComputerError::Vsock(format!("sandbox {id} has no running vm to reach")))
887    }
888}
889
890/// A computer's read snapshot as `Inspect` reports it.
891fn snapshot_to_info(id: &ComputerId, snapshot: &ComputerSnapshot) -> ComputerInfo {
892    ComputerInfo {
893        id: id.clone(),
894        state: snapshot.state,
895        labels: snapshot.labels.clone(),
896        vcpus: snapshot.vcpus,
897        memory_mib: snapshot.memory_mib,
898        network: snapshot.lease.as_ref().map(|lease| ComputerNetworkInfo {
899            ip_address: lease.ip.to_string(),
900            gateway: lease.gateway.to_string(),
901        }),
902        created_at: snapshot.created_at,
903        ready_at: snapshot.ready_at,
904        last_exited_at: snapshot.last_exited_at,
905        last_exit_status: snapshot.last_exit_status,
906        error: snapshot.error.clone(),
907        paused_at: snapshot.paused_at,
908        // Filled by the caller in every state (it owns the paths).
909        storage_bytes: 0,
910        ttl_deadline: snapshot.deadlines.ttl,
911        idle_timeout_seconds: snapshot.deadlines.idle_timeout_seconds,
912        on_idle: snapshot.deadlines.on_idle,
913    }
914}
915
916/// Files a listed checkpoint occupies on disk, for storage accounting.
917///
918/// The listing counterpart of [`ComputerManager::checkpoint_paths`]: it reads
919/// an already-loaded [`SnapshotInfo`] so a multi-sandbox response pays one
920/// catalog scan rather than one per paused sandbox.
921fn snapshot_files(info: &crate::snapshot::SnapshotInfo) -> Vec<PathBuf> {
922    std::iter::once(info.vmstate_path.clone())
923        .chain(info.mem_path.clone())
924        .collect()
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use crate::testkit::agent::FakeAgentFactory;
931
932    /// A factory whose guests announce nothing needs no `vsock_listen` from
933    /// the driver: what the readiness gate costs is the agent port's answer.
934    #[test]
935    fn a_probing_agent_does_not_require_a_listening_driver() {
936        let dir = tempfile::tempdir().unwrap();
937        let mut config = RuntimeConfig::default();
938        config.firecracker.data_dir = dir.path().to_string_lossy().into_owned();
939        let driver = arcbox_vm_driver::testkit::FakeDriver::builder()
940            .capabilities(arcbox_vm_driver::DriverCapabilities {
941                vsock_listen: false,
942                ..arcbox_vm_driver::testkit::FakeDriver::new().capabilities()
943            })
944            .build();
945        ComputerManager::new(
946            config.clone(),
947            NodeEnvironment {
948                driver: Arc::new(driver),
949                network: Arc::new(
950                    arcbox_vm_driver::testkit::FakeNetwork::with_startup_cleanup("test-boot"),
951                ),
952                agent: Arc::new(FakeAgentFactory::new()),
953                ..crate::testkit::fake_environment(&config).unwrap()
954            },
955        )
956        .expect("a probing agent asks the driver for no listener");
957    }
958}