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}