arcbox_computer_runtime/sandbox.rs
1//! `SandboxManager` — orchestrates sandbox microVM lifecycle.
2//!
3//! A sandbox is a short-lived, strongly-isolated microVM decoupled from its
4//! workload: when the initial `cmd` process exits the sandbox transitions back
5//! to `Ready` rather than stopping, and continues accepting `Run` calls until
6//! an explicit `Stop`/`Remove` or TTL expiry.
7//!
8//! `create_sandbox` returns immediately with state `"starting"`. The VM boots
9//! in a background task which broadcasts a `"ready"` event on success.
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, RwLock};
14use std::time::Duration;
15
16use arcbox_vm_driver::net::{
17 AttachMode, GuestNetwork, HostIngress, NetworkIdentity, NetworkLease, NetworkMode,
18 NetworkPolicy, NetworkReconcile,
19};
20use arcbox_vm_driver::{
21 CheckpointFormat, CheckpointImage, CheckpointKind, DiskSource, IsolationSpec, NicSpec, Prepare,
22 PreparedVm, Staging, VmDriver, VmHandle, VmId,
23};
24use chrono::{DateTime, Utc};
25use tokio::sync::broadcast;
26use tracing::{debug, error, info, warn};
27use uuid::Uuid;
28
29use crate::agent::{ExecInputMsg, ExitStatus, OutputChunk, PortWait, StartCommand};
30use crate::agent::{GuestAgent, Readiness};
31use crate::config::RuntimeConfig;
32use crate::environment::NodeEnvironment;
33use crate::error::{Result, VmmError};
34use crate::lifecycle::actor::{
35 Command, ComputerActor, ComputerSeed, ComputerSnapshot, Deadlines, Mailbox, Seeded,
36};
37use crate::lifecycle::event::{Provision, RestoreOrigin};
38use crate::lifecycle::flows::{BootLaunch, ComputerFlows, ComputerServices, Launch, RestoreLaunch};
39use crate::lifecycle::runtime::{ComputerRuntime, Runtime};
40use crate::sandbox::reconcile::JournaledVmm;
41use crate::snapshot::{SnapshotCatalog, SnapshotMeta};
42use crate::snapshot_cow::{CowHandle, CowManager};
43use crate::template_catalog::TemplateCatalog;
44
45pub(crate) mod boot;
46mod checkpoint;
47pub(crate) mod cleanup;
48pub(crate) mod events;
49mod execution;
50mod files;
51mod lifecycle;
52pub(crate) mod pause;
53pub(crate) mod policy;
54pub(crate) mod pool;
55pub(crate) mod reconcile;
56pub(crate) mod record;
57pub(crate) mod spec;
58mod storage;
59mod templates;
60mod timers;
61pub(crate) mod types;
62pub(crate) mod warm;
63pub(crate) mod workload;
64
65pub use execution::{
66 ExecutionChannel, ExecutionOutput, ExecutionSnapshot, ExecutionSpec, StdinState,
67};
68pub use pause::reason as pause_reason;
69pub(crate) use spec::ROOTFS_DISK_ID;
70pub(crate) use types::NetworkAttachment;
71pub use types::{
72 CheckpointInfo, CheckpointSummary, IdleAction, LifecycleUpdate, RestoreSandboxSpec,
73 SandboxEvent, SandboxId, SandboxInfo, SandboxMountSpec, SandboxNetworkInfo, SandboxNetworkSpec,
74 SandboxSpec, SandboxState, SandboxSummary, TemplateWarmRef,
75};
76
77const EVENT_CHANNEL_CAPACITY: usize = 256;
78type ReconcileResult = std::result::Result<(), Arc<str>>;
79
80/// One computer as the registry holds it: its mailbox, its read snapshot, and
81/// the identity that tells this incarnation from a same-id replacement.
82///
83/// What replaced `Arc<Mutex<ComputerRuntime>>`. Every verb is a send on the
84/// mailbox and every read is a borrow of the snapshot, so neither the map
85/// lock nor a per-computer mutex is ever held across an await — the
86/// discipline `list_sandboxes` used to have to state.
87#[derive(Clone)]
88pub(crate) struct ComputerRef {
89 mailbox: Mailbox,
90 snapshot: tokio::sync::watch::Receiver<ComputerSnapshot>,
91 incarnation: Uuid,
92}
93
94/// The live computers, by id.
95pub(crate) type Computers = Arc<RwLock<HashMap<SandboxId, ComputerRef>>>;
96
97/// Manages the full lifecycle of multiple sandbox microVMs.
98pub struct SandboxManager {
99 computers: Computers,
100 records: Arc<record::SandboxRecordStore>,
101 /// What every computer's flows are built from — the driver, the guest
102 /// network, the agent factory, the record store, the catalogs and the
103 /// pool. One `Arc`, cloned into each actor.
104 services: Arc<ComputerServices>,
105 snapshots: Arc<SnapshotCatalog>,
106 /// Template catalog (CORE-107); see `templates.rs` for the manager surface.
107 templates: Arc<TemplateCatalog>,
108 config: Arc<RuntimeConfig>,
109 events: Arc<events::EventBus>,
110 cow_manager: Arc<CowManager>,
111 /// Pre-warmed restore slots (CORE-78); see `pool.rs`.
112 pool: Arc<pool::SlotPool>,
113 /// Warm template snapshot bookkeeping (CORE-77); see `warm.rs`.
114 warm: Arc<warm::WarmCache>,
115 /// Addressable executions (CORE-55); see `execution.rs`.
116 executions: Arc<execution::ExecutionRegistry>,
117 /// Publishes the startup reconciliation result. Lifecycle and read APIs
118 /// gate on it so callers never observe partial recovered state.
119 reconcile_done: tokio::sync::watch::Receiver<Option<ReconcileResult>>,
120 /// Whether the deadline timers may fire. `false` until
121 /// [`Self::into_shared`], which is the inertness contract a plain
122 /// [`Self::new`] manager has: unit tests of unrelated surfaces rely on it.
123 /// The actors hold receivers, so the flip reaches every one of them —
124 /// including the ones the startup sweep seeded before it was flipped.
125 timers_enabled: tokio::sync::watch::Sender<bool>,
126}
127
128impl SandboxManager {
129 /// Create a new manager from the given configuration, over the
130 /// environment-specific components the composer supplies.
131 ///
132 /// Fails with [`VmmError::Config`] when the environment's driver lacks a
133 /// capability every sandbox needs — `Prepare` (the flows spawn the VMM
134 /// ahead of the guest), `Staging` (every flow brings its computer's
135 /// files into the area the VMM can reach), `Vsock` (the guest agent is
136 /// reached over it), or whatever the agent factory's readiness gate
137 /// needs (`VsockListen` for the guest's dial-out) — or when its guest
138 /// network offers no `NetworkReconcile` (the cleanup-token protocol is
139 /// how a host releases the addresses a previous process held), so an
140 /// environment missing one is refused here instead of at the first boot
141 /// or the first cleanup ticket.
142 pub fn new(config: RuntimeConfig, environment: NodeEnvironment) -> Result<Self> {
143 let NodeEnvironment {
144 driver,
145 network,
146 agent,
147 cow_manager,
148 } = environment;
149 let capabilities = driver.capabilities();
150 for (missing, capability, need) in [
151 (
152 driver.prepare().is_none(),
153 "prepare",
154 "the boot and pool flows spawn the VMM ahead of the guest",
155 ),
156 (
157 !capabilities.staging,
158 "staging",
159 "every flow brings the files its computer boots from into the area the VMM \
160 can reach, and pause takes the disk back out of it",
161 ),
162 // The last hard-wired transport assumption in this layer: every
163 // agent implementation the crate ships reaches its guest through
164 // the VM handle. A composition root that knows its Computers are
165 // not dialable at all is what will retire it.
166 (
167 !capabilities.vsock,
168 "vsock",
169 "the guest agent is reached over vsock",
170 ),
171 // What the readiness gate needs is the agent port's answer, not
172 // this layer's: only a dial-out has to be listened for before
173 // the guest starts.
174 (
175 matches!(agent.readiness(), Readiness::DialOut { .. })
176 && !capabilities.vsock_listen,
177 "vsock_listen",
178 "the readiness gate is the guest's vsock dial-out",
179 ),
180 ] {
181 if missing {
182 return Err(VmmError::Config(format!(
183 "VM driver `{}` has no {capability} capability; {need}",
184 driver.name()
185 )));
186 }
187 }
188 let records = Arc::new(record::SandboxRecordStore::new(Path::new(
189 &config.firecracker.data_dir,
190 ))?);
191 drop(records.load_all()?);
192 if network.reconcile().is_none() {
193 return Err(VmmError::Config(
194 "guest network has no reconcile capability; the quarantine ledger is how a host \
195 releases the addresses a previous process held"
196 .into(),
197 ));
198 }
199 let snapshots = Arc::new(SnapshotCatalog::new(&config.firecracker.data_dir));
200 let templates = Arc::new(TemplateCatalog::new(&config.firecracker.data_dir));
201 let events = Arc::new(events::EventBus::new(EVENT_CHANNEL_CAPACITY));
202 let config = Arc::new(config);
203
204 // Sweep leftovers of a previous agent process (crash / respawn):
205 // orphaned VMM processes, TAPs, dm devices, chroots. Create and
206 // restore wait for this to finish (await_reconcile) so a re-created
207 // same-id sandbox can't have its deterministically-named resources torn
208 // down mid-flight. Only meaningful inside a tokio runtime; sync
209 // constructions (unit tests) have no previous instance to reconcile.
210 let (reconcile_tx, reconcile_done) = tokio::sync::watch::channel(None);
211 let executions = Arc::new(execution::ExecutionRegistry::default());
212 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
213 let pool = Arc::new(pool::SlotPool::default());
214 let (timers_enabled, timers_gate) = tokio::sync::watch::channel(false);
215 let services = Arc::new(ComputerServices {
216 driver,
217 network,
218 agents: agent,
219 config: Arc::clone(&config),
220 cow_manager: Arc::clone(&cow_manager),
221 records: Arc::clone(&records),
222 snapshots: Arc::clone(&snapshots),
223 events: Arc::clone(&events),
224 pool: Arc::clone(&pool),
225 });
226 if tokio::runtime::Handle::try_current().is_ok() {
227 let config = Arc::clone(&config);
228 let cow_manager = Arc::clone(&cow_manager);
229 let snapshots = Arc::clone(&snapshots);
230 let records = Arc::clone(&records);
231 let computers = Arc::clone(&computers);
232 let services = Arc::clone(&services);
233 let network = Arc::clone(&services.network);
234 let driver = Arc::clone(&services.driver);
235 tokio::spawn(async move {
236 let result = async {
237 let mut swept = reconcile::sweep_orphans(
238 &config,
239 driver.as_ref(),
240 &*network,
241 &cow_manager,
242 &snapshots,
243 &records,
244 )
245 .await?;
246 let mut runtime = swept.take_runtime();
247 let mut inactive = Vec::new();
248 let normalized = reconcile::normalize_durable_records(
249 &records,
250 Path::new(&config.firecracker.data_dir),
251 Some(&mut runtime),
252 &mut inactive,
253 );
254 // A reclaimed sandbox is in exactly one of these two by
255 // now — never claimed, or built into an instance — and
256 // dropping either would kill its guest while leaving its
257 // lease and template refcount held. Release both before
258 // reporting the refusal.
259 reconcile::release_unclaimed(&mut runtime, &*network, &cow_manager).await;
260 if let Err(error) = normalized {
261 reconcile::release_instances(&mut inactive, &*network, &cow_manager).await;
262 return Err(error);
263 }
264 // Publish before anything else can fail. The reclaimed
265 // sandboxes' only handles live in these computers, so a
266 // later error — a runtime directory that will not delete,
267 // say — would otherwise un-reclaim every guest by
268 // dropping them, over something none of them caused.
269 reconcile::seed_computers(inactive, &computers, &services, &timers_gate);
270 reconcile::finalize_sweep(swept).await?;
271 reconcile_capability(&*network).replay_complete();
272 Ok::<_, VmmError>(())
273 }
274 .await
275 .map_err(|error| Arc::<str>::from(error.to_string()));
276 let _ = reconcile_tx.send(Some(result));
277 });
278 // Executions die with their sandbox; purge on terminal events so
279 // every teardown path (stop / remove / TTL / boot failure) is
280 // covered without threading the registry through each of them.
281 execution::spawn_teardown_purge(Arc::clone(&executions), events.subscribe());
282 } else {
283 let mut inactive = Vec::new();
284 reconcile::normalize_durable_records(
285 &records,
286 Path::new(&config.firecracker.data_dir),
287 None,
288 &mut inactive,
289 )?;
290 reconcile_capability(&*services.network).replay_complete();
291 reconcile::seed_computers(inactive, &computers, &services, &timers_enabled.subscribe());
292 let _ = reconcile_tx.send(Some(Ok(())));
293 }
294
295 Ok(Self {
296 computers,
297 records,
298 services,
299 snapshots,
300 templates,
301 config,
302 events,
303 cow_manager,
304 pool,
305 warm: Arc::new(warm::WarmCache::default()),
306 executions,
307 reconcile_done,
308 timers_enabled,
309 })
310 }
311
312 /// Wrap the manager in an `Arc` and let its computers' deadline timers
313 /// fire.
314 ///
315 /// Production embedders (the guest agent's `SandboxService`) must use
316 /// this; a plain [`Self::new`] manager never fires idle timers (unit
317 /// tests exercising unrelated surfaces rely on that inertness).
318 ///
319 /// One `watch` flip, which every actor is holding a receiver for — the
320 /// weak self-handle a detached expiry task needed is gone with the
321 /// detached task.
322 #[must_use]
323 pub fn into_shared(self) -> Arc<Self> {
324 // `send_replace`, not `send`: a manager with no computers yet has no
325 // receivers, and `send` would refuse — leaving every actor spawned
326 // afterwards reading a gate that was never opened.
327 self.timers_enabled.send_replace(true);
328 Arc::new(self)
329 }
330
331 /// What every actor this manager spawns is built from.
332 pub(super) fn spawn_context(
333 &self,
334 ) -> (Arc<ComputerServices>, tokio::sync::watch::Receiver<bool>) {
335 (Arc::clone(&self.services), self.timers_enabled.subscribe())
336 }
337
338 /// This computer's registry entry.
339 pub(super) fn computer(&self, id: &SandboxId) -> Result<ComputerRef> {
340 self.check_reconcile()?;
341 self.computers
342 .read()
343 .unwrap()
344 .get(id)
345 .cloned()
346 .ok_or_else(|| VmmError::NotFound(id.clone()))
347 }
348
349 /// This computer's mailbox.
350 pub(super) fn mailbox(&self, id: &SandboxId) -> Result<Mailbox> {
351 Ok(self.computer(id)?.mailbox)
352 }
353
354 /// What a read of this computer sees, without touching its mailbox.
355 pub(super) fn snapshot(&self, id: &SandboxId) -> Result<ComputerSnapshot> {
356 Ok(self.computer(id)?.snapshot.borrow().clone())
357 }
358
359 /// Wait until the startup orphan sweep has completed.
360 ///
361 /// The sweep tears down resources by deterministic per-id names
362 /// (`arcbox-snap-{id}`, the id's TAP, `arcbox-cow-{id}`) and adjusts the
363 /// shared template refcount. A create/restore that runs concurrently with
364 /// it — e.g. a client re-creating the same id right after an agent restart
365 /// — would have its live device destroyed or a live template detached.
366 /// Gating create/restore on the sweep removes that class entirely; it runs
367 /// once, so after completion this returns immediately.
368 pub(super) async fn await_reconcile(&self) -> Result<()> {
369 let mut rx = self.reconcile_done.clone();
370 let result = rx
371 .wait_for(Option::is_some)
372 .await
373 .map_err(|error| VmmError::Other(format!("sandbox reconciliation stopped: {error}")))?
374 .clone()
375 .expect("wait_for returned only after reconciliation completed");
376 result.map_err(|error| {
377 VmmError::Other(format!(
378 "sandbox durable-state reconciliation failed: {error}"
379 ))
380 })
381 }
382
383 fn check_reconcile(&self) -> Result<()> {
384 let result = self.reconcile_done.borrow().clone();
385 match result {
386 None => Err(VmmError::Other(
387 "sandbox durable-state reconciliation is still running".into(),
388 )),
389 Some(Ok(())) => Ok(()),
390 Some(Err(error)) => Err(VmmError::Other(format!(
391 "sandbox durable-state reconciliation failed: {error}"
392 ))),
393 }
394 }
395
396 /// Return sandbox IDs whose inactive network allocation still awaits host
397 /// forwarding cleanup.
398 pub async fn pending_network_cleanups(&self) -> Result<Vec<(String, String)>> {
399 self.await_reconcile().await?;
400 Ok(self
401 .reconcile_network()
402 .pending_cleanups()
403 .await
404 .map_err(VmmError::from)?
405 .into_iter()
406 .map(|(vm, token)| (vm.as_str().to_owned(), token))
407 .collect())
408 }
409
410 /// Validate one exact pending host-cleanup ticket.
411 ///
412 /// Returns the lease that generation held: the host's forwarding rules
413 /// for it are keyed by its address, and after an agent restart the
414 /// quarantine ledger is the only place that address survives.
415 pub async fn validate_network_cleanup(&self, id: &str, token: &str) -> Result<NetworkLease> {
416 self.await_reconcile().await?;
417 Ok(self
418 .reconcile_network()
419 .validate_cleanup(&VmId::new(id)?, token)
420 .await?)
421 }
422
423 /// Recycle one exact inactive sandbox generation after forwarding cleanup.
424 pub async fn finalize_network_cleanup(&self, id: &str, token: &str) -> Result<()> {
425 self.await_reconcile().await?;
426 Ok(self
427 .reconcile_network()
428 .finalize_cleanup(&VmId::new(id)?, token)
429 .await?)
430 }
431
432 /// Wait until the current agent generation's startup cleanup is complete.
433 pub async fn wait_startup_cleanup_complete(&self) {
434 self.reconcile_network()
435 .wait_startup_cleanup_complete()
436 .await;
437 }
438
439 /// Opaque ticket for the host cleanup pass required after agent startup.
440 pub async fn startup_cleanup_token(&self) -> Result<Option<String>> {
441 self.await_reconcile().await?;
442 Ok(self.reconcile_network().startup_cleanup_token().await)
443 }
444
445 /// Validate the current process-generation startup cleanup ticket.
446 pub async fn validate_startup_cleanup(&self, token: &str) -> Result<()> {
447 self.await_reconcile().await?;
448 Ok(self
449 .reconcile_network()
450 .validate_startup_cleanup(token)
451 .await?)
452 }
453
454 /// Release the startup gate once host listeners and legacy DNAT are gone.
455 pub async fn finalize_startup_cleanup(&self, token: &str) -> Result<()> {
456 self.await_reconcile().await?;
457 Ok(self
458 .reconcile_network()
459 .finalize_startup_cleanup(token)
460 .await?)
461 }
462
463 /// The guest network's cleanup protocol.
464 pub(super) fn reconcile_network(&self) -> &dyn NetworkReconcile {
465 reconcile_capability(&*self.services.network)
466 }
467
468 /// Return the active generation's network identity: the external pool IP
469 /// (what DNS, expose, and the API report), its opaque cleanup token, and
470 /// how expose DNAT must target the sandbox (CORE-81/CORE-83).
471 ///
472 /// No startup-cleanup gate here, unlike every method above. An identity
473 /// needs a live lease; a lease comes only from `reserve`, which the
474 /// guest network refuses while the same startup barrier is closed; and
475 /// both callers in the guest agent await `wait_startup_cleanup_complete`
476 /// before asking. The gate this used to take could therefore only ever
477 /// have fired for a sandbox that has no lease to report anyway, and it
478 /// has no non-blocking form on the port.
479 pub fn sandbox_network_identity(&self, id: &str) -> Result<SandboxNetworkIdentity> {
480 let snapshot = self.snapshot(&id.to_owned())?;
481 let lease = snapshot
482 .lease
483 .as_ref()
484 .ok_or_else(|| VmmError::WrongState {
485 id: id.to_owned(),
486 expected: "sandbox with an active network allocation".into(),
487 actual: snapshot.state.to_string(),
488 })?;
489 Ok(SandboxNetworkIdentity {
490 ip: lease.ipv4()?,
491 cleanup_token: lease.cleanup_token.clone(),
492 expose: self.services.network.host_ingress(lease)?,
493 })
494 }
495}
496
497/// What the sandbox stack needs from a [`NetworkLease`] beyond its fields.
498///
499/// The port's lease carries an [`IpAddr`](std::net::IpAddr) because a guest
500/// network need not be IPv4 (the platform's node dataplane is IPv6-only),
501/// while everything downstream of *this* manager — the pool address it
502/// reports, the netmask a legacy guest is reconfigured with, the crash
503/// journal's on-disk shape — is v4. The narrowing happens here, once, and
504/// says so when a lease cannot answer.
505pub(super) trait LeaseExt {
506 /// The lease's address.
507 fn ipv4(&self) -> Result<std::net::Ipv4Addr>;
508 /// The gateway the guest routes through.
509 fn gateway_ipv4(&self) -> Result<std::net::Ipv4Addr>;
510}
511
512impl LeaseExt for NetworkLease {
513 fn ipv4(&self) -> Result<std::net::Ipv4Addr> {
514 ipv4(self.ip)
515 }
516
517 fn gateway_ipv4(&self) -> Result<std::net::Ipv4Addr> {
518 ipv4(self.gateway)
519 }
520}
521
522/// One address of a lease or identity, narrowed. See [`LeaseExt`].
523pub(super) fn ipv4(address: std::net::IpAddr) -> Result<std::net::Ipv4Addr> {
524 match address {
525 std::net::IpAddr::V4(v4) => Ok(v4),
526 std::net::IpAddr::V6(v6) => Err(VmmError::Network(format!(
527 "the guest network offered {v6}; this manager's sandboxes are IPv4-only"
528 ))),
529 }
530}
531
532/// The subnet mask `prefix_len` describes, clamped at /32 so an
533/// out-of-range prefix cannot overflow the shift.
534pub(super) fn netmask(prefix_len: u8) -> std::net::Ipv4Addr {
535 let prefix = prefix_len.min(32);
536 if prefix == 0 {
537 std::net::Ipv4Addr::UNSPECIFIED
538 } else {
539 std::net::Ipv4Addr::from(!0u32 << (32 - prefix))
540 }
541}
542
543/// The connectivity every sandbox gets: egress through the host's address,
544/// which is what the System VM's netfilter provides for the pool.
545pub(super) const fn sandbox_network_policy() -> NetworkPolicy {
546 NetworkPolicy {
547 mode: NetworkMode::Nat,
548 }
549}
550
551/// The mode a guest's interface is activated in, which is also the mode
552/// [`GuestNetwork::identity`] must be read under: a fresh boot and every
553/// invariant-snapshot restore take the fixed identity, and only checkpoints
554/// taken before it existed carry the pool address on the interface itself.
555pub(super) const fn attach_mode(net_invariant: bool) -> AttachMode {
556 if net_invariant {
557 AttachMode::Invariant
558 } else {
559 AttachMode::LegacySnapshot
560 }
561}
562
563/// A sandbox's network identity as seen by forwarding and DNS consumers.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct SandboxNetworkIdentity {
566 /// External pool IP — the address the rest of the system keeps using.
567 pub ip: std::net::Ipv4Addr,
568 /// Opaque generation token carried through host cleanup finalization.
569 pub cleanup_token: String,
570 /// How expose DNAT must target this sandbox, decided by the datapath
571 /// actually applied to its TAP (CORE-81/CORE-83).
572 ///
573 /// The guest network's own answer, passed through unchanged: rendering
574 /// it as forwarding rules is the composing host's job, and only that
575 /// host knows which network it built.
576 pub expose: HostIngress,
577}
578
579/// The guest network's cleanup protocol, which [`SandboxManager::new`]
580/// requires — the quarantine ledger gates every address the pool hands
581/// out, and the startup sweep gates the pool itself.
582pub(super) fn reconcile_capability(network: &dyn GuestNetwork) -> &dyn NetworkReconcile {
583 network
584 .reconcile()
585 .expect("SandboxManager::new requires the guest network's reconcile capability")
586}
587
588/// The driver's `Prepare` capability, which [`SandboxManager::new`]
589/// requires — the boot, pool, and restore flows all spawn the VMM before
590/// there is a guest to run on it.
591pub(crate) fn prepare_capability(driver: &dyn VmDriver) -> &dyn Prepare {
592 driver
593 .prepare()
594 .expect("SandboxManager::new requires the driver's Prepare capability")
595}
596
597/// A grip's `Staging` capability, which [`SandboxManager::new`] requires —
598/// every flow that puts a guest on a VMM first brings that guest's files
599/// into the area the VMM can reach, and pause takes its disk back out of
600/// it.
601///
602/// Takes the accessor's answer rather than the grip it came from: both
603/// grips on a VM have one and they name the same area, so a computer
604/// booted here asks its [`PreparedVm`] and one this process adopted asks
605/// its [`VmHandle`](arcbox_vm_driver::VmHandle).
606pub(crate) fn staging_capability(staging: Option<&dyn Staging>) -> &dyn Staging {
607 staging.expect("SandboxManager::new requires the driver's Staging capability")
608}
609
610/// The VMM's pid as the crash journal records it: what a restart sweep
611/// kills before tearing the sandbox's other resources down.
612pub(crate) fn journaled_pid(prepared: &dyn PreparedVm) -> Option<i32> {
613 prepared
614 .record()
615 .process
616 .and_then(|process| i32::try_from(process.pid).ok())
617}
618
619/// The VMM as the crash journal records it — its API socket and its jail —
620/// which is what lets the next process adopt this VM instead of killing it.
621///
622/// The driver knows both because it spawned the VMM that way. Nothing else
623/// can work either out afterwards: see
624/// [`SandboxStateRecord::api_socket`](crate::sandbox::reconcile::SandboxStateRecord::api_socket)
625/// and [`SandboxStateRecord::jail`](crate::sandbox::reconcile::SandboxStateRecord::jail).
626pub(crate) fn journaled_vmm(prepared: &dyn PreparedVm) -> Option<JournaledVmm> {
627 prepared.record().process.map(|process| JournaledVmm {
628 api_socket: process.api_socket,
629 jail: process.jail.map(Into::into),
630 })
631}
632
633/// The isolation every sandbox VMM runs under: the jailer's, when one is
634/// configured; none otherwise (direct mode).
635pub(crate) fn isolation_spec(config: &RuntimeConfig) -> Result<IsolationSpec> {
636 config
637 .firecracker
638 .jailer
639 .as_ref()
640 .map_or(Ok(IsolationSpec::None), IsolationSpec::try_from)
641}
642
643/// Where a paused computer's retained disk overlay lives: a function of the
644/// data dir and the id, which is why the flows that keep, rename, look for
645/// and delete it can all say it the same way.
646pub(crate) fn preserved_cow_file(config: &RuntimeConfig, id: &str) -> PathBuf {
647 PathBuf::from(&config.firecracker.data_dir)
648 .join("cow")
649 .join(format!("arcbox-cow-{id}.img"))
650}
651
652/// A checkpoint as the catalog holds it, which is what
653/// [`Staging::stage_checkpoint`] takes and what the driver reads back.
654///
655/// The catalog writes one directory per checkpoint and puts `vmstate` and
656/// `mem` in it, so the image is that directory — read off the meta's own
657/// `vmstate_path` rather than recomputed from the catalog's layout, which
658/// this layer does not own. Its name is what the driver stages the
659/// checkpoint under, and a restore reproduces that name from the same
660/// image, so the two cannot drift. The format is the one the catalog
661/// recorded at capture; legacy entries default to the Firecracker format.
662pub(crate) fn catalogued_checkpoint(meta: &SnapshotMeta) -> Result<CheckpointImage> {
663 let dir = meta.vmstate_path.parent().ok_or_else(|| {
664 VmmError::Snapshot(format!(
665 "checkpoint {} records a vmstate at {}, which is in no directory",
666 meta.id,
667 meta.vmstate_path.display()
668 ))
669 })?;
670 Ok(CheckpointImage {
671 dir: dir.to_path_buf(),
672 format: CheckpointFormat::new(&meta.format),
673 kind: CheckpointKind::Full,
674 })
675}
676
677/// Validate a caller-supplied sandbox or snapshot id.
678///
679/// Ids become filesystem path components and dm/TAP name fragments, so they are
680/// restricted to `[A-Za-z0-9_-]`. This rejects path traversal (`/`, `\`, `..`),
681/// NUL and whitespace. The narrower rule a VMM imposes on the id it runs under
682/// is *not* checked here — that is [`VmId`]'s, applied by
683/// [`validate_new_sandbox_id`] where an id becomes a VM identity.
684///
685/// Deliberately NO length cap here: this also runs against persisted
686/// records, and what one legacy over-long id would cost differs by call
687/// site. On the sweep path it costs that record its reconciliation — the
688/// journal is skipped rather than acted on, and every resource it names
689/// is held (`reconcile::sweep_orphans`). On a record *load* it costs
690/// every record theirs: [`SandboxRecordStore::load_all`] validates each
691/// id and propagates, so one rejection aborts the whole startup read,
692/// which is the stronger reason the cap is absent. It also runs against
693/// snapshot / execution ids that never become a VM identity at all. Both
694/// limits the driver imposes are enforced only where a sandbox id enters
695/// the system: [`validate_new_sandbox_id`].
696///
697/// The gap between this alphabet and [`VmId`]'s is not academic: `_` is
698/// legal here and is not a `VmId`, so every record a pre-#680 process
699/// wrote for an `inst_…` sandbox is one this process cannot name — which
700/// is exactly what the sweep and the quarantine ledger read back.
701pub(super) fn validate_id(kind: &str, id: &str) -> Result<()> {
702 if id.is_empty() {
703 return Err(VmmError::Config(format!("{kind} must not be empty")));
704 }
705 if !id
706 .bytes()
707 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
708 {
709 return Err(VmmError::Config(format!(
710 "invalid {kind} {id:?}: only ASCII letters, digits, '-' and '_' are allowed"
711 )));
712 }
713 Ok(())
714}
715
716/// Validate a sandbox id at request ingress (create / restore), where it
717/// becomes the identity the VM runs under.
718///
719/// Both halves belong to the driver, and neither is restated here. The
720/// alphabet and the 64-byte ceiling are [`VmId`]'s: an id a VMM will not
721/// take is refused by construction, and Firecracker — which `panic!`s on
722/// its own `--id` in *both* jailed and direct mode — is what that rule is
723/// cut to. The length budget is [`VmDriver::id_budget`], measured on the
724/// layout the configured driver will actually lay down, so a longer chroot
725/// base or binary name tightens it rather than silently reintroducing the
726/// failure it exists to prevent.
727///
728/// Refusing here is the whole point: past this call the id is validated
729/// again inside the async boot task, where the refusal arrives with
730/// nothing tying it back to the request that caused it — an
731/// `InvalidInstanceId` under the jailer, an abort under bare Firecracker,
732/// and an opaque "timed out waiting for socket" when it is the length
733/// (the driver's readiness probe is a `connect()`, which ENAMETOOLONGs on
734/// every attempt while the VMM is up and bound inside its chroot; caught
735/// by the CORE-107 prewarm e2e, whose 51-char builder id overflowed the
736/// stock budget).
737pub(super) fn validate_new_sandbox_id(
738 id: &str,
739 driver: &dyn VmDriver,
740 config: &RuntimeConfig,
741) -> Result<()> {
742 VmId::new(id)?;
743 if let Some(budget) = driver.id_budget(&isolation_spec(config)?)
744 && id.len() > budget
745 {
746 return Err(VmmError::Config(format!(
747 "invalid sandbox id {id:?}: at most {budget} characters under this \
748 driver's layout (its sockets must fit the AF_UNIX path limit)"
749 )));
750 }
751 Ok(())
752}
753
754/// Atomically claim `id` in the registry and stand up its actor.
755///
756/// Create and restore both derive per-computer resources deterministically
757/// from the id (CoW file `arcbox-cow-{id}`, dm device `arcbox-snap-{id}`, TAP
758/// name). A check-then-insert with those resources set up in between is a
759/// TOCTOU: two concurrent restores to the same id both pass the check, then
760/// the second `create_sparse_file` truncates the file the first's loop device
761/// is backing, corrupting the winner's rootfs. Claiming the id up front
762/// (check + insert under one write lock) makes the loser fail fast with
763/// `AlreadyExists` before it touches any shared resource.
764///
765/// The actor is **not** spawned here. Its seed needs the durable generation
766/// and the deadlines the record computes, and both are resolved after the
767/// claim; the mailbox and the read snapshot exist from the claim on, so a
768/// caller that finds the entry finds a usable computer either way. The claim
769/// is released on drop unless [`ActorReservation::spawn`] is called, so every
770/// error path unwinds it.
771pub(crate) fn reserve_actor(
772 computers: &Computers,
773 id: &SandboxId,
774 runtime: ComputerRuntime,
775) -> Result<ActorReservation> {
776 let mut map = computers.write().unwrap();
777 if map.contains_key(id) {
778 return Err(VmmError::AlreadyExists(id.clone()));
779 }
780 let incarnation = Uuid::new_v4();
781 let (mailbox_tx, commands) = tokio::sync::mpsc::unbounded_channel();
782 let runtime = Arc::new(Mutex::new(runtime));
783 let (snapshot_tx, snapshot) = tokio::sync::watch::channel(ComputerSnapshot::project(
784 &runtime.lock().unwrap(),
785 SandboxState::Starting,
786 Deadlines::default(),
787 ));
788 map.insert(
789 id.clone(),
790 ComputerRef {
791 mailbox: Mailbox::new(mailbox_tx.clone()),
792 snapshot,
793 incarnation,
794 },
795 );
796 drop(map);
797 Ok(ActorReservation {
798 computers: Arc::clone(computers),
799 id: id.clone(),
800 incarnation,
801 runtime,
802 mailbox: Mailbox::new(mailbox_tx),
803 commands: Some(commands),
804 snapshot_tx: Some(snapshot_tx),
805 })
806}
807
808/// A claimed id, its runtime state, and the channels its actor will run on.
809///
810/// Dropping it without [`Self::spawn`] releases the claim; the mailbox's last
811/// sender goes with it, so an actor already running would stop too.
812pub(crate) struct ActorReservation {
813 computers: Computers,
814 id: SandboxId,
815 incarnation: Uuid,
816 runtime: Runtime,
817 mailbox: Mailbox,
818 commands: Option<tokio::sync::mpsc::UnboundedReceiver<Command>>,
819 snapshot_tx: Option<tokio::sync::watch::Sender<ComputerSnapshot>>,
820}
821
822impl ActorReservation {
823 /// The claimed computer's runtime state, for the caller to populate
824 /// before its actor starts.
825 pub(crate) fn runtime(&self) -> &Runtime {
826 &self.runtime
827 }
828
829 /// Start the actor and keep the claim.
830 pub(crate) fn spawn(mut self, seed: ActorSpawn) -> Mailbox {
831 let ActorSpawn {
832 services,
833 timers_enabled,
834 generation,
835 deadlines,
836 launch,
837 seeded,
838 } = seed;
839 let mailbox = self.mailbox.clone();
840 let flows = Arc::new(ComputerFlows::new(
841 self.id.clone(),
842 Arc::clone(&self.runtime),
843 Arc::clone(&services),
844 &mailbox,
845 launch,
846 ));
847 let unregister = {
848 let computers = Arc::clone(&self.computers);
849 let id = self.id.clone();
850 let incarnation = self.incarnation;
851 Arc::new(move || forget_computer(&computers, &id, incarnation))
852 };
853 let vm_dir = self.runtime.lock().unwrap().vm_dir.clone();
854 let actor = ComputerActor::new(
855 ComputerSeed {
856 id: self.id.clone(),
857 runtime: Arc::clone(&self.runtime),
858 unregister,
859 generation,
860 vm_dir,
861 records: Arc::clone(&services.records),
862 events: Arc::clone(&services.events),
863 tasks: flows,
864 deadlines,
865 timers_enabled,
866 seeded,
867 },
868 self.commands.take().expect("spawned once"),
869 self.snapshot_tx.take().expect("spawned once"),
870 );
871 // Constructors are synchronous and may run outside a runtime (unit
872 // tests of unrelated surfaces); such a manager has no actors to run
873 // and never provisions one.
874 if tokio::runtime::Handle::try_current().is_ok() {
875 tokio::spawn(actor.run());
876 }
877 mailbox
878 }
879}
880
881impl Drop for ActorReservation {
882 fn drop(&mut self) {
883 if self.commands.is_some() {
884 forget_computer(&self.computers, &self.id, self.incarnation);
885 }
886 }
887}
888
889/// Everything an actor needs that the reservation does not already hold.
890///
891/// Assembled from the manager's own pieces rather than from `&SandboxManager`
892/// so the startup sweep can seed actors too: it runs in a task spawned from
893/// the constructor, before the manager it belongs to exists.
894pub(crate) struct ActorSpawn {
895 pub(crate) services: Arc<ComputerServices>,
896 pub(crate) timers_enabled: tokio::sync::watch::Receiver<bool>,
897 pub(crate) generation: Option<Uuid>,
898 pub(crate) deadlines: Deadlines,
899 pub(crate) launch: Launch,
900 pub(crate) seeded: Seeded,
901}
902
903/// Drop `id`'s entry when it is still `incarnation`'s.
904///
905/// The identity check replaces the `Arc::ptr_eq` guard the instance map
906/// needed: a computer removed and re-created under the same id (deterministic
907/// caller-supplied ids make this common) installs a fresh entry, and the
908/// departing actor must not evict it.
909fn forget_computer(computers: &Computers, id: &SandboxId, incarnation: Uuid) {
910 let mut map = computers.write().unwrap();
911 if map
912 .get(id)
913 .is_some_and(|current| current.incarnation == incarnation)
914 {
915 map.remove(id);
916 }
917}
918
919/// Whether `id`'s entry is still this exact incarnation's — the inverse of
920/// [`forget_computer`], and the question an unanswerable mailbox raises.
921///
922/// Every ending unregisters: the actor's own loop tail whatever stopped it, the
923/// record it forgets on a removal, and [`ActorReservation::drop`] for a create
924/// that unwound before spawning one. A re-created id installs a fresh
925/// incarnation over the departed one, so this is `false` there too. What is
926/// left is an incarnation *still here* whose mailbox no longer answers: an
927/// actor whose task died without finishing, and the one case where a
928/// computer's resources outlive the thing that was managing them.
929///
930/// Reads nothing the actor owns, so it stays answerable for a computer whose
931/// runtime mutex a panic has poisoned.
932fn still_registered(computers: &Computers, id: &SandboxId, incarnation: Uuid) -> bool {
933 computers
934 .read()
935 .unwrap()
936 .get(id)
937 .is_some_and(|current| current.incarnation == incarnation)
938}
939
940#[cfg(test)]
941mod tests {
942 use arcbox_vm_driver::testkit::FakeDriver;
943
944 use super::*;
945
946 fn placeholder(id: &str) -> ComputerRuntime {
947 ComputerRuntime::new(
948 id.to_owned(),
949 SandboxSpec::default(),
950 None,
951 PathBuf::from("/tmp/x"),
952 )
953 }
954
955 /// The manager refuses, at construction, a driver that cannot spawn the
956 /// VMM ahead of a boot, dial the guest, or take its readiness dial-out:
957 /// every sandbox flow needs all three, so a driver without one would
958 /// fail at the first create instead.
959 #[test]
960 fn construction_requires_the_drivers_prepare_and_vsock_capabilities() {
961 use arcbox_vm_driver::testkit::FakeDriver;
962 use arcbox_vm_driver::{DriverCapabilities, VmDriver as _};
963
964 let dir = tempfile::tempdir().unwrap();
965 let mut config = RuntimeConfig::default();
966 config.firecracker.data_dir = dir.path().to_string_lossy().into_owned();
967 // The vm-proto factory, not the agent fake: only a dial-out
968 // readiness makes the driver's `vsock_listen` a requirement, which
969 // is one of the three refusals below.
970 let environment = |driver: FakeDriver| NodeEnvironment {
971 driver: Arc::new(driver),
972 agent: Arc::new(crate::agent::VmProtoAgentFactory::default()),
973 ..crate::testkit::fake_environment(&config).unwrap()
974 };
975 let all = FakeDriver::new().capabilities();
976
977 for (name, capabilities) in [
978 (
979 "prepare",
980 DriverCapabilities {
981 prepare: false,
982 ..all.clone()
983 },
984 ),
985 (
986 "vsock",
987 DriverCapabilities {
988 vsock: false,
989 ..all.clone()
990 },
991 ),
992 (
993 "vsock_listen",
994 DriverCapabilities {
995 vsock_listen: false,
996 ..all
997 },
998 ),
999 ] {
1000 let driver = FakeDriver::builder().capabilities(capabilities).build();
1001 let error = SandboxManager::new(config.clone(), environment(driver))
1002 .err()
1003 .unwrap_or_else(|| panic!("a driver without {name} is refused"));
1004 assert!(matches!(error, VmmError::Config(_)), "{error}");
1005 assert!(error.to_string().contains(name), "{error}");
1006 }
1007
1008 let manager = SandboxManager::new(config.clone(), environment(FakeDriver::new()))
1009 .expect("a driver with every needed capability is accepted");
1010 assert_eq!(manager.services.driver.name(), "fake");
1011 }
1012
1013 #[test]
1014 fn a_claimed_id_rejects_a_concurrent_duplicate() {
1015 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1016 let first = reserve_actor(&computers, &"dup".to_owned(), placeholder("dup")).unwrap();
1017 // A second claim of the same id must fail while the first is live —
1018 // before either touches the per-id resources they both derive.
1019 assert!(matches!(
1020 reserve_actor(&computers, &"dup".to_owned(), placeholder("dup")),
1021 Err(VmmError::AlreadyExists(_))
1022 ));
1023 drop(first);
1024 assert!(!computers.read().unwrap().contains_key("dup"));
1025 }
1026
1027 /// A jailed config, which is what makes a driver answer with a budget
1028 /// at all — the layout behind that answer is the driver's business.
1029 fn jailed_config() -> RuntimeConfig {
1030 let mut config = RuntimeConfig::default();
1031 config.firecracker.jailer = Some(crate::config::JailerConfig {
1032 uid: 0,
1033 gid: 0,
1034 chroot_base_dir: Some("/srv/jailer".into()),
1035 netns: None,
1036 new_pid_ns: false,
1037 cgroup_version: None,
1038 parent_cgroup: None,
1039 });
1040 config
1041 }
1042
1043 /// The id shape the platform node actually mints, in the rendering a
1044 /// driver can run: the control plane's `inst_<uuid v7>` with the `_`
1045 /// the VMM refuses turned into a `-`.
1046 const CONTROL_PLANE_ID: &str = "inst-019e409e-7546-7a3e-8b2c-1f2e3d4c5b6a";
1047
1048 #[test]
1049 fn validate_id_accepts_safe_ids_and_rejects_traversal() {
1050 for ok in ["sandbox1", "a-b_c", "0f3e9d16-1234", "A_B-9"] {
1051 assert!(validate_id("id", ok).is_ok(), "{ok} should be valid");
1052 }
1053 // The generic validator stays uncapped and keeps the underscore: it
1054 // also runs against persisted records and against snapshot /
1055 // execution ids that never become a VM identity, where a legacy id
1056 // must not become fatal.
1057 assert!(validate_id("id", &"a".repeat(60)).is_ok());
1058 assert!(validate_id("id", "inst_019e409e-7546").is_ok());
1059 for bad in ["", "..", ".", "a/b", "a\\b", "a b", "a.b", "a\0b", "../etc"] {
1060 assert!(
1061 validate_id("id", bad).is_err(),
1062 "{bad:?} should be rejected"
1063 );
1064 }
1065 }
1066
1067 /// Ingress holds a new id to whatever budget the *configured* driver
1068 /// reports for the isolation it will run under — this layer keeps no
1069 /// copy of a number. Anything past it must fail fast here instead of
1070 /// surfacing as a socket connect timeout.
1071 ///
1072 /// Whether a given layout leaves room for the ids an ArcBox node mints
1073 /// is the adapter's own question, answered against the deployed
1074 /// constants in `arcbox-fc-driver`
1075 /// (`the_deployed_jail_layout_admits_the_ids_a_node_mints`).
1076 #[test]
1077 fn a_new_sandbox_id_is_held_to_the_drivers_own_budget() {
1078 let mut config = jailed_config();
1079 let budget = CONTROL_PLANE_ID.len();
1080 let driver = FakeDriver::builder().jailed_id_budget(budget).build();
1081 assert!(validate_new_sandbox_id(CONTROL_PLANE_ID, &driver, &config).is_ok());
1082 assert!(validate_new_sandbox_id(&"a".repeat(budget), &driver, &config).is_ok());
1083 assert!(validate_new_sandbox_id(&"a".repeat(budget + 1), &driver, &config).is_err());
1084
1085 // A tighter budget refuses what the looser one took, rather than
1086 // silently reintroducing the connect timeout.
1087 let tighter = FakeDriver::builder().jailed_id_budget(budget - 1).build();
1088 assert!(validate_new_sandbox_id(CONTROL_PLANE_ID, &tighter, &config).is_err());
1089
1090 // A driver that reports no budget for this isolation bounds
1091 // nothing: only `VmId`'s own 64-byte ceiling is left.
1092 config.firecracker.jailer = None;
1093 assert!(validate_new_sandbox_id(&"a".repeat(budget + 1), &tighter, &config).is_ok());
1094 }
1095
1096 /// CORE-140. Firecracker validates the `--id` it is handed and refuses it
1097 /// by panicking — under the jailer *and* in direct mode, where the same
1098 /// flag is passed straight to the VMM. So the refusal cannot be gated on
1099 /// a jailer being configured: it belongs to the id itself.
1100 #[test]
1101 fn an_underscored_id_is_refused_at_ingress_in_every_spawn_mode() {
1102 let mut config = jailed_config();
1103 let driver = FakeDriver::builder()
1104 .jailed_id_budget(CONTROL_PLANE_ID.len())
1105 .build();
1106 for _ in 0..2 {
1107 let error =
1108 validate_new_sandbox_id(&CONTROL_PLANE_ID.replacen('-', "_", 1), &driver, &config)
1109 .expect_err("firecracker would refuse to run under this id");
1110 assert!(
1111 matches!(&error, VmmError::Config(message) if message.contains('_')),
1112 "the error should name the offending character, got {error}"
1113 );
1114 assert!(validate_new_sandbox_id(CONTROL_PLANE_ID, &driver, &config).is_ok());
1115 // Direct mode passes the same `--id`, so it is refused there too.
1116 config.firecracker.jailer = None;
1117 }
1118 }
1119
1120 #[test]
1121 fn a_dropped_claim_frees_the_id() {
1122 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1123 {
1124 let _claim = reserve_actor(&computers, &"tmp".to_owned(), placeholder("tmp")).unwrap();
1125 assert!(computers.read().unwrap().contains_key("tmp"));
1126 // never spawned → dropped here
1127 }
1128 assert!(!computers.read().unwrap().contains_key("tmp"));
1129 // The id is free to claim again after an unwound restore.
1130 assert!(reserve_actor(&computers, &"tmp".to_owned(), placeholder("tmp")).is_ok());
1131 }
1132
1133 /// A departing actor unregisters *its own* entry and no other.
1134 ///
1135 /// The `Arc::ptr_eq` guard the instance map needed, as an incarnation:
1136 /// a computer removed and re-created under the same id (deterministic
1137 /// caller-supplied ids make this common) installs a fresh entry, and the
1138 /// one on its way out must not evict it.
1139 #[test]
1140 fn a_departing_actor_does_not_evict_its_replacement() {
1141 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1142 let departing = reserve_actor(&computers, &"same".to_owned(), placeholder("same")).unwrap();
1143 let incarnation = departing.incarnation;
1144 std::mem::forget(departing);
1145
1146 assert!(matches!(
1147 reserve_actor(&computers, &"same".to_owned(), placeholder("same")),
1148 Err(VmmError::AlreadyExists(_))
1149 ));
1150 forget_computer(&computers, &"same".to_owned(), incarnation);
1151 let replacement = reserve_actor(&computers, &"same".to_owned(), placeholder("same"))
1152 .expect("the id is free once the departing actor let it go");
1153
1154 forget_computer(&computers, &"same".to_owned(), incarnation);
1155 assert!(
1156 computers.read().unwrap().contains_key("same"),
1157 "the stale incarnation must not evict the replacement"
1158 );
1159 drop(replacement);
1160 assert!(!computers.read().unwrap().contains_key("same"));
1161 }
1162
1163 /// Registration is per incarnation, which is what lets a handover pass
1164 /// tell an actor that finished from one that died.
1165 ///
1166 /// `detach_all` reads this after an unanswerable mailbox: `false` means
1167 /// the actor ended and unregistered (or its id was re-created since), so
1168 /// nothing was lost, while `true` means its task went away without
1169 /// finishing and its guest is still out there.
1170 #[test]
1171 fn registration_is_per_incarnation() {
1172 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1173 let id = "box".to_owned();
1174 let departing = reserve_actor(&computers, &id, placeholder("box")).unwrap();
1175 let incarnation = departing.incarnation;
1176 assert!(still_registered(&computers, &id, incarnation));
1177
1178 // A different incarnation of a live id is not this one.
1179 assert!(!still_registered(&computers, &id, Uuid::new_v4()));
1180
1181 // An actor still holding its claim while unable to answer: the case
1182 // worth reporting.
1183 std::mem::forget(departing);
1184 assert!(still_registered(&computers, &id, incarnation));
1185
1186 // And once it has let go — its own exit, or a replacement taking the
1187 // id — that incarnation is no longer registered.
1188 forget_computer(&computers, &id, incarnation);
1189 assert!(!still_registered(&computers, &id, incarnation));
1190 let replacement = reserve_actor(&computers, &id, placeholder("box")).unwrap();
1191 assert!(!still_registered(&computers, &id, incarnation));
1192 assert!(still_registered(&computers, &id, replacement.incarnation));
1193 }
1194}