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}