arcbox_computer_runtime/sandbox.rs
1//! `ComputerManager` — 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_computer` 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::{ComputerError, Result};
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, ComputerEvent, ComputerId, ComputerInfo, ComputerMountSpec,
73 ComputerNetworkInfo, ComputerNetworkSpec, ComputerSpec, ComputerState, ComputerSummary,
74 IdleAction, LifecycleUpdate, RestoreComputerSpec, 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_computers` 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<ComputerId, ComputerRef>>>;
96
97/// Manages the full lifecycle of multiple sandbox microVMs.
98pub struct ComputerManager {
99 computers: Computers,
100 records: Arc<record::ComputerRecordStore>,
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 ComputerManager {
129 /// Create a new manager from the given configuration, over the
130 /// environment-specific components the composer supplies.
131 ///
132 /// Fails with [`ComputerError::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(ComputerError::Config(format!(
183 "VM driver `{}` has no {capability} capability; {need}",
184 driver.name()
185 )));
186 }
187 }
188 let records = Arc::new(record::ComputerRecordStore::new(Path::new(
189 &config.firecracker.data_dir,
190 ))?);
191 drop(records.load_all()?);
192 if network.reconcile().is_none() {
193 return Err(ComputerError::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::<_, ComputerError>(())
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: &ComputerId) -> Result<ComputerRef> {
340 self.check_reconcile()?;
341 self.computers
342 .read()
343 .unwrap()
344 .get(id)
345 .cloned()
346 .ok_or_else(|| ComputerError::NotFound(id.clone()))
347 }
348
349 /// This computer's mailbox.
350 pub(super) fn mailbox(&self, id: &ComputerId) -> 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: &ComputerId) -> 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| {
374 ComputerError::Other(format!("sandbox reconciliation stopped: {error}"))
375 })?
376 .clone()
377 .expect("wait_for returned only after reconciliation completed");
378 result.map_err(|error| {
379 ComputerError::Other(format!(
380 "sandbox durable-state reconciliation failed: {error}"
381 ))
382 })
383 }
384
385 fn check_reconcile(&self) -> Result<()> {
386 let result = self.reconcile_done.borrow().clone();
387 match result {
388 None => Err(ComputerError::Other(
389 "sandbox durable-state reconciliation is still running".into(),
390 )),
391 Some(Ok(())) => Ok(()),
392 Some(Err(error)) => Err(ComputerError::Other(format!(
393 "sandbox durable-state reconciliation failed: {error}"
394 ))),
395 }
396 }
397
398 /// Return sandbox IDs whose inactive network allocation still awaits host
399 /// forwarding cleanup.
400 pub async fn pending_network_cleanups(&self) -> Result<Vec<(String, String)>> {
401 self.await_reconcile().await?;
402 Ok(self
403 .reconcile_network()
404 .pending_cleanups()
405 .await
406 .map_err(ComputerError::from)?
407 .into_iter()
408 .map(|(vm, token)| (vm.as_str().to_owned(), token))
409 .collect())
410 }
411
412 /// Validate one exact pending host-cleanup ticket.
413 ///
414 /// Returns the lease that generation held: the host's forwarding rules
415 /// for it are keyed by its address, and after an agent restart the
416 /// quarantine ledger is the only place that address survives.
417 pub async fn validate_network_cleanup(&self, id: &str, token: &str) -> Result<NetworkLease> {
418 self.await_reconcile().await?;
419 Ok(self
420 .reconcile_network()
421 .validate_cleanup(&VmId::new(id)?, token)
422 .await?)
423 }
424
425 /// Recycle one exact inactive sandbox generation after forwarding cleanup.
426 pub async fn finalize_network_cleanup(&self, id: &str, token: &str) -> Result<()> {
427 self.await_reconcile().await?;
428 Ok(self
429 .reconcile_network()
430 .finalize_cleanup(&VmId::new(id)?, token)
431 .await?)
432 }
433
434 /// Wait until the current agent generation's startup cleanup is complete.
435 pub async fn wait_startup_cleanup_complete(&self) {
436 self.reconcile_network()
437 .wait_startup_cleanup_complete()
438 .await;
439 }
440
441 /// Opaque ticket for the host cleanup pass required after agent startup.
442 pub async fn startup_cleanup_token(&self) -> Result<Option<String>> {
443 self.await_reconcile().await?;
444 Ok(self.reconcile_network().startup_cleanup_token().await)
445 }
446
447 /// Validate the current process-generation startup cleanup ticket.
448 pub async fn validate_startup_cleanup(&self, token: &str) -> Result<()> {
449 self.await_reconcile().await?;
450 Ok(self
451 .reconcile_network()
452 .validate_startup_cleanup(token)
453 .await?)
454 }
455
456 /// Release the startup gate once host listeners and legacy DNAT are gone.
457 pub async fn finalize_startup_cleanup(&self, token: &str) -> Result<()> {
458 self.await_reconcile().await?;
459 Ok(self
460 .reconcile_network()
461 .finalize_startup_cleanup(token)
462 .await?)
463 }
464
465 /// The guest network's cleanup protocol.
466 pub(super) fn reconcile_network(&self) -> &dyn NetworkReconcile {
467 reconcile_capability(&*self.services.network)
468 }
469
470 /// Return the active generation's network identity: the external pool IP
471 /// (what DNS, expose, and the API report), its opaque cleanup token, and
472 /// how expose DNAT must target the sandbox (CORE-81/CORE-83).
473 ///
474 /// No startup-cleanup gate here, unlike every method above. An identity
475 /// needs a live lease; a lease comes only from `reserve`, which the
476 /// guest network refuses while the same startup barrier is closed; and
477 /// both callers in the guest agent await `wait_startup_cleanup_complete`
478 /// before asking. The gate this used to take could therefore only ever
479 /// have fired for a sandbox that has no lease to report anyway, and it
480 /// has no non-blocking form on the port.
481 pub fn computer_network_identity(&self, id: &str) -> Result<ComputerNetworkIdentity> {
482 let snapshot = self.snapshot(&id.to_owned())?;
483 let lease = snapshot
484 .lease
485 .as_ref()
486 .ok_or_else(|| ComputerError::WrongState {
487 id: id.to_owned(),
488 expected: "sandbox with an active network allocation".into(),
489 actual: snapshot.state.to_string(),
490 })?;
491 Ok(ComputerNetworkIdentity {
492 ip: lease.ipv4()?,
493 cleanup_token: lease.cleanup_token.clone(),
494 expose: self.services.network.host_ingress(lease)?,
495 })
496 }
497}
498
499/// What the sandbox stack needs from a [`NetworkLease`] beyond its fields.
500///
501/// The port's lease carries an [`IpAddr`](std::net::IpAddr) because a guest
502/// network need not be IPv4 (the platform's node dataplane is IPv6-only),
503/// while everything downstream of *this* manager — the pool address it
504/// reports, the netmask a legacy guest is reconfigured with, the crash
505/// journal's on-disk shape — is v4. The narrowing happens here, once, and
506/// says so when a lease cannot answer.
507pub(super) trait LeaseExt {
508 /// The lease's address.
509 fn ipv4(&self) -> Result<std::net::Ipv4Addr>;
510 /// The gateway the guest routes through.
511 fn gateway_ipv4(&self) -> Result<std::net::Ipv4Addr>;
512}
513
514impl LeaseExt for NetworkLease {
515 fn ipv4(&self) -> Result<std::net::Ipv4Addr> {
516 ipv4(self.ip)
517 }
518
519 fn gateway_ipv4(&self) -> Result<std::net::Ipv4Addr> {
520 ipv4(self.gateway)
521 }
522}
523
524/// One address of a lease or identity, narrowed. See [`LeaseExt`].
525pub(super) fn ipv4(address: std::net::IpAddr) -> Result<std::net::Ipv4Addr> {
526 match address {
527 std::net::IpAddr::V4(v4) => Ok(v4),
528 std::net::IpAddr::V6(v6) => Err(ComputerError::Network(format!(
529 "the guest network offered {v6}; this manager's sandboxes are IPv4-only"
530 ))),
531 }
532}
533
534/// The subnet mask `prefix_len` describes, clamped at /32 so an
535/// out-of-range prefix cannot overflow the shift.
536pub(super) fn netmask(prefix_len: u8) -> std::net::Ipv4Addr {
537 let prefix = prefix_len.min(32);
538 if prefix == 0 {
539 std::net::Ipv4Addr::UNSPECIFIED
540 } else {
541 std::net::Ipv4Addr::from(!0u32 << (32 - prefix))
542 }
543}
544
545/// The connectivity every sandbox gets: egress through the host's address,
546/// which is what the System VM's netfilter provides for the pool.
547pub(super) const fn computer_network_policy() -> NetworkPolicy {
548 NetworkPolicy {
549 mode: NetworkMode::Nat,
550 }
551}
552
553/// The mode a guest's interface is activated in, which is also the mode
554/// [`GuestNetwork::identity`] must be read under: a fresh boot and every
555/// invariant-snapshot restore take the fixed identity, and only checkpoints
556/// taken before it existed carry the pool address on the interface itself.
557pub(super) const fn attach_mode(net_invariant: bool) -> AttachMode {
558 if net_invariant {
559 AttachMode::Invariant
560 } else {
561 AttachMode::LegacySnapshot
562 }
563}
564
565/// A sandbox's network identity as seen by forwarding and DNS consumers.
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct ComputerNetworkIdentity {
568 /// External pool IP — the address the rest of the system keeps using.
569 pub ip: std::net::Ipv4Addr,
570 /// Opaque generation token carried through host cleanup finalization.
571 pub cleanup_token: String,
572 /// How expose DNAT must target this sandbox, decided by the datapath
573 /// actually applied to its TAP (CORE-81/CORE-83).
574 ///
575 /// The guest network's own answer, passed through unchanged: rendering
576 /// it as forwarding rules is the composing host's job, and only that
577 /// host knows which network it built.
578 pub expose: HostIngress,
579}
580
581/// The guest network's cleanup protocol, which [`ComputerManager::new`]
582/// requires — the quarantine ledger gates every address the pool hands
583/// out, and the startup sweep gates the pool itself.
584pub(super) fn reconcile_capability(network: &dyn GuestNetwork) -> &dyn NetworkReconcile {
585 network
586 .reconcile()
587 .expect("SandboxManager::new requires the guest network's reconcile capability")
588}
589
590/// The driver's `Prepare` capability, which [`ComputerManager::new`]
591/// requires — the boot, pool, and restore flows all spawn the VMM before
592/// there is a guest to run on it.
593pub(crate) fn prepare_capability(driver: &dyn VmDriver) -> &dyn Prepare {
594 driver
595 .prepare()
596 .expect("SandboxManager::new requires the driver's Prepare capability")
597}
598
599/// A grip's `Staging` capability, which [`ComputerManager::new`] requires —
600/// every flow that puts a guest on a VMM first brings that guest's files
601/// into the area the VMM can reach, and pause takes its disk back out of
602/// it.
603///
604/// Takes the accessor's answer rather than the grip it came from: both
605/// grips on a VM have one and they name the same area, so a computer
606/// booted here asks its [`PreparedVm`] and one this process adopted asks
607/// its [`VmHandle`](arcbox_vm_driver::VmHandle).
608pub(crate) fn staging_capability(staging: Option<&dyn Staging>) -> &dyn Staging {
609 staging.expect("SandboxManager::new requires the driver's Staging capability")
610}
611
612/// The VMM's pid as the crash journal records it: what a restart sweep
613/// kills before tearing the sandbox's other resources down.
614pub(crate) fn journaled_pid(prepared: &dyn PreparedVm) -> Option<i32> {
615 prepared
616 .record()
617 .process
618 .and_then(|process| i32::try_from(process.pid).ok())
619}
620
621/// The VMM as the crash journal records it — its API socket and its jail —
622/// which is what lets the next process adopt this VM instead of killing it.
623///
624/// The driver knows both because it spawned the VMM that way. Nothing else
625/// can work either out afterwards: see
626/// [`ComputerStateRecord::api_socket`](crate::sandbox::reconcile::ComputerStateRecord::api_socket)
627/// and [`ComputerStateRecord::jail`](crate::sandbox::reconcile::ComputerStateRecord::jail).
628pub(crate) fn journaled_vmm(prepared: &dyn PreparedVm) -> Option<JournaledVmm> {
629 prepared.record().process.map(|process| JournaledVmm {
630 api_socket: process.api_socket,
631 jail: process.jail.map(Into::into),
632 })
633}
634
635/// The isolation every sandbox VMM runs under: the jailer's, when one is
636/// configured; none otherwise (direct mode).
637pub(crate) fn isolation_spec(config: &RuntimeConfig) -> Result<IsolationSpec> {
638 config
639 .firecracker
640 .jailer
641 .as_ref()
642 .map_or(Ok(IsolationSpec::None), IsolationSpec::try_from)
643}
644
645/// Where a paused computer's retained disk overlay lives: a function of the
646/// data dir and the id, which is why the flows that keep, rename, look for
647/// and delete it can all say it the same way.
648pub(crate) fn preserved_cow_file(config: &RuntimeConfig, id: &str) -> PathBuf {
649 PathBuf::from(&config.firecracker.data_dir)
650 .join("cow")
651 .join(format!("arcbox-cow-{id}.img"))
652}
653
654/// A checkpoint as the catalog holds it, which is what
655/// [`Staging::stage_checkpoint`] takes and what the driver reads back.
656///
657/// The catalog writes one directory per checkpoint and puts `vmstate` and
658/// `mem` in it, so the image is that directory — read off the meta's own
659/// `vmstate_path` rather than recomputed from the catalog's layout, which
660/// this layer does not own. Its name is what the driver stages the
661/// checkpoint under, and a restore reproduces that name from the same
662/// image, so the two cannot drift. The format is the one the catalog
663/// recorded at capture; legacy entries default to the Firecracker format.
664pub(crate) fn catalogued_checkpoint(meta: &SnapshotMeta) -> Result<CheckpointImage> {
665 let dir = meta.vmstate_path.parent().ok_or_else(|| {
666 ComputerError::Snapshot(format!(
667 "checkpoint {} records a vmstate at {}, which is in no directory",
668 meta.id,
669 meta.vmstate_path.display()
670 ))
671 })?;
672 Ok(CheckpointImage {
673 dir: dir.to_path_buf(),
674 format: CheckpointFormat::new(&meta.format),
675 kind: CheckpointKind::Full,
676 })
677}
678
679/// Validate a caller-supplied sandbox or snapshot id.
680///
681/// Ids become filesystem path components and dm/TAP name fragments, so they are
682/// restricted to `[A-Za-z0-9_-]`. This rejects path traversal (`/`, `\`, `..`),
683/// NUL and whitespace. The narrower rule a VMM imposes on the id it runs under
684/// is *not* checked here — that is [`VmId`]'s, applied by
685/// [`validate_new_computer_id`] where an id becomes a VM identity.
686///
687/// Deliberately NO length cap here: this also runs against persisted
688/// records, and what one legacy over-long id would cost differs by call
689/// site. On the sweep path it costs that record its reconciliation — the
690/// journal is skipped rather than acted on, and every resource it names
691/// is held (`reconcile::sweep_orphans`). On a record *load* it costs
692/// every record theirs: [`ComputerRecordStore::load_all`] validates each
693/// id and propagates, so one rejection aborts the whole startup read,
694/// which is the stronger reason the cap is absent. It also runs against
695/// snapshot / execution ids that never become a VM identity at all. Both
696/// limits the driver imposes are enforced only where a sandbox id enters
697/// the system: [`validate_new_computer_id`].
698///
699/// The gap between this alphabet and [`VmId`]'s is not academic: `_` is
700/// legal here and is not a `VmId`, so every record a pre-#680 process
701/// wrote for an `inst_…` sandbox is one this process cannot name — which
702/// is exactly what the sweep and the quarantine ledger read back.
703pub(super) fn validate_id(kind: &str, id: &str) -> Result<()> {
704 if id.is_empty() {
705 return Err(ComputerError::Config(format!("{kind} must not be empty")));
706 }
707 if !id
708 .bytes()
709 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
710 {
711 return Err(ComputerError::Config(format!(
712 "invalid {kind} {id:?}: only ASCII letters, digits, '-' and '_' are allowed"
713 )));
714 }
715 Ok(())
716}
717
718/// Validate a sandbox id at request ingress (create / restore), where it
719/// becomes the identity the VM runs under.
720///
721/// Both halves belong to the driver, and neither is restated here. The
722/// alphabet and the 64-byte ceiling are [`VmId`]'s: an id a VMM will not
723/// take is refused by construction, and Firecracker — which `panic!`s on
724/// its own `--id` in *both* jailed and direct mode — is what that rule is
725/// cut to. The length budget is [`VmDriver::id_budget`], measured on the
726/// layout the configured driver will actually lay down, so a longer chroot
727/// base or binary name tightens it rather than silently reintroducing the
728/// failure it exists to prevent.
729///
730/// Refusing here is the whole point: past this call the id is validated
731/// again inside the async boot task, where the refusal arrives with
732/// nothing tying it back to the request that caused it — an
733/// `InvalidInstanceId` under the jailer, an abort under bare Firecracker,
734/// and an opaque "timed out waiting for socket" when it is the length
735/// (the driver's readiness probe is a `connect()`, which ENAMETOOLONGs on
736/// every attempt while the VMM is up and bound inside its chroot; caught
737/// by the CORE-107 prewarm e2e, whose 51-char builder id overflowed the
738/// stock budget).
739pub(super) fn validate_new_computer_id(
740 id: &str,
741 driver: &dyn VmDriver,
742 config: &RuntimeConfig,
743) -> Result<()> {
744 VmId::new(id)?;
745 if let Some(budget) = driver.id_budget(&isolation_spec(config)?)
746 && id.len() > budget
747 {
748 return Err(ComputerError::Config(format!(
749 "invalid sandbox id {id:?}: at most {budget} characters under this \
750 driver's layout (its sockets must fit the AF_UNIX path limit)"
751 )));
752 }
753 Ok(())
754}
755
756/// Atomically claim `id` in the registry and stand up its actor.
757///
758/// Create and restore both derive per-computer resources deterministically
759/// from the id (CoW file `arcbox-cow-{id}`, dm device `arcbox-snap-{id}`, TAP
760/// name). A check-then-insert with those resources set up in between is a
761/// TOCTOU: two concurrent restores to the same id both pass the check, then
762/// the second `create_sparse_file` truncates the file the first's loop device
763/// is backing, corrupting the winner's rootfs. Claiming the id up front
764/// (check + insert under one write lock) makes the loser fail fast with
765/// `AlreadyExists` before it touches any shared resource.
766///
767/// The actor is **not** spawned here. Its seed needs the durable generation
768/// and the deadlines the record computes, and both are resolved after the
769/// claim; the mailbox and the read snapshot exist from the claim on, so a
770/// caller that finds the entry finds a usable computer either way. The claim
771/// is released on drop unless [`ActorReservation::spawn`] is called, so every
772/// error path unwinds it.
773pub(crate) fn reserve_actor(
774 computers: &Computers,
775 id: &ComputerId,
776 runtime: ComputerRuntime,
777) -> Result<ActorReservation> {
778 let mut map = computers.write().unwrap();
779 if map.contains_key(id) {
780 return Err(ComputerError::AlreadyExists(id.clone()));
781 }
782 let incarnation = Uuid::new_v4();
783 let (mailbox_tx, commands) = tokio::sync::mpsc::unbounded_channel();
784 let runtime = Arc::new(Mutex::new(runtime));
785 let (snapshot_tx, snapshot) = tokio::sync::watch::channel(ComputerSnapshot::project(
786 &runtime.lock().unwrap(),
787 ComputerState::Starting,
788 Deadlines::default(),
789 ));
790 map.insert(
791 id.clone(),
792 ComputerRef {
793 mailbox: Mailbox::new(mailbox_tx.clone()),
794 snapshot,
795 incarnation,
796 },
797 );
798 drop(map);
799 Ok(ActorReservation {
800 computers: Arc::clone(computers),
801 id: id.clone(),
802 incarnation,
803 runtime,
804 mailbox: Mailbox::new(mailbox_tx),
805 commands: Some(commands),
806 snapshot_tx: Some(snapshot_tx),
807 })
808}
809
810/// A claimed id, its runtime state, and the channels its actor will run on.
811///
812/// Dropping it without [`Self::spawn`] releases the claim; the mailbox's last
813/// sender goes with it, so an actor already running would stop too.
814pub(crate) struct ActorReservation {
815 computers: Computers,
816 id: ComputerId,
817 incarnation: Uuid,
818 runtime: Runtime,
819 mailbox: Mailbox,
820 commands: Option<tokio::sync::mpsc::UnboundedReceiver<Command>>,
821 snapshot_tx: Option<tokio::sync::watch::Sender<ComputerSnapshot>>,
822}
823
824impl ActorReservation {
825 /// The claimed computer's runtime state, for the caller to populate
826 /// before its actor starts.
827 pub(crate) fn runtime(&self) -> &Runtime {
828 &self.runtime
829 }
830
831 /// Start the actor and keep the claim.
832 pub(crate) fn spawn(mut self, seed: ActorSpawn) -> Mailbox {
833 let ActorSpawn {
834 services,
835 timers_enabled,
836 generation,
837 deadlines,
838 launch,
839 seeded,
840 } = seed;
841 let mailbox = self.mailbox.clone();
842 let flows = Arc::new(ComputerFlows::new(
843 self.id.clone(),
844 Arc::clone(&self.runtime),
845 Arc::clone(&services),
846 &mailbox,
847 launch,
848 ));
849 let unregister = {
850 let computers = Arc::clone(&self.computers);
851 let id = self.id.clone();
852 let incarnation = self.incarnation;
853 Arc::new(move || forget_computer(&computers, &id, incarnation))
854 };
855 let vm_dir = self.runtime.lock().unwrap().vm_dir.clone();
856 let actor = ComputerActor::new(
857 ComputerSeed {
858 id: self.id.clone(),
859 runtime: Arc::clone(&self.runtime),
860 unregister,
861 generation,
862 vm_dir,
863 records: Arc::clone(&services.records),
864 events: Arc::clone(&services.events),
865 tasks: flows,
866 deadlines,
867 timers_enabled,
868 seeded,
869 },
870 self.commands.take().expect("spawned once"),
871 self.snapshot_tx.take().expect("spawned once"),
872 );
873 // Constructors are synchronous and may run outside a runtime (unit
874 // tests of unrelated surfaces); such a manager has no actors to run
875 // and never provisions one.
876 if tokio::runtime::Handle::try_current().is_ok() {
877 tokio::spawn(actor.run());
878 }
879 mailbox
880 }
881}
882
883impl Drop for ActorReservation {
884 fn drop(&mut self) {
885 if self.commands.is_some() {
886 forget_computer(&self.computers, &self.id, self.incarnation);
887 }
888 }
889}
890
891/// Everything an actor needs that the reservation does not already hold.
892///
893/// Assembled from the manager's own pieces rather than from `&ComputerManager`
894/// so the startup sweep can seed actors too: it runs in a task spawned from
895/// the constructor, before the manager it belongs to exists.
896pub(crate) struct ActorSpawn {
897 pub(crate) services: Arc<ComputerServices>,
898 pub(crate) timers_enabled: tokio::sync::watch::Receiver<bool>,
899 pub(crate) generation: Option<Uuid>,
900 pub(crate) deadlines: Deadlines,
901 pub(crate) launch: Launch,
902 pub(crate) seeded: Seeded,
903}
904
905/// Drop `id`'s entry when it is still `incarnation`'s.
906///
907/// The identity check replaces the `Arc::ptr_eq` guard the instance map
908/// needed: a computer removed and re-created under the same id (deterministic
909/// caller-supplied ids make this common) installs a fresh entry, and the
910/// departing actor must not evict it.
911fn forget_computer(computers: &Computers, id: &ComputerId, incarnation: Uuid) {
912 let mut map = computers.write().unwrap();
913 if map
914 .get(id)
915 .is_some_and(|current| current.incarnation == incarnation)
916 {
917 map.remove(id);
918 }
919}
920
921/// Whether `id`'s entry is still this exact incarnation's — the inverse of
922/// [`forget_computer`], and the question an unanswerable mailbox raises.
923///
924/// Every ending unregisters: the actor's own loop tail whatever stopped it, the
925/// record it forgets on a removal, and [`ActorReservation::drop`] for a create
926/// that unwound before spawning one. A re-created id installs a fresh
927/// incarnation over the departed one, so this is `false` there too. What is
928/// left is an incarnation *still here* whose mailbox no longer answers: an
929/// actor whose task died without finishing, and the one case where a
930/// computer's resources outlive the thing that was managing them.
931///
932/// Reads nothing the actor owns, so it stays answerable for a computer whose
933/// runtime mutex a panic has poisoned.
934fn still_registered(computers: &Computers, id: &ComputerId, incarnation: Uuid) -> bool {
935 computers
936 .read()
937 .unwrap()
938 .get(id)
939 .is_some_and(|current| current.incarnation == incarnation)
940}
941
942#[cfg(test)]
943mod tests {
944 use arcbox_vm_driver::testkit::FakeDriver;
945
946 use super::*;
947
948 fn placeholder(id: &str) -> ComputerRuntime {
949 ComputerRuntime::new(
950 id.to_owned(),
951 ComputerSpec::default(),
952 None,
953 PathBuf::from("/tmp/x"),
954 )
955 }
956
957 /// The manager refuses, at construction, a driver that cannot spawn the
958 /// VMM ahead of a boot, dial the guest, or take its readiness dial-out:
959 /// every sandbox flow needs all three, so a driver without one would
960 /// fail at the first create instead.
961 #[test]
962 fn construction_requires_the_drivers_prepare_and_vsock_capabilities() {
963 use arcbox_vm_driver::testkit::FakeDriver;
964 use arcbox_vm_driver::{DriverCapabilities, VmDriver as _};
965
966 let dir = tempfile::tempdir().unwrap();
967 let mut config = RuntimeConfig::default();
968 config.firecracker.data_dir = dir.path().to_string_lossy().into_owned();
969 // The vm-proto factory, not the agent fake: only a dial-out
970 // readiness makes the driver's `vsock_listen` a requirement, which
971 // is one of the three refusals below.
972 let environment = |driver: FakeDriver| NodeEnvironment {
973 driver: Arc::new(driver),
974 agent: Arc::new(crate::agent::VmProtoAgentFactory::default()),
975 ..crate::testkit::fake_environment(&config).unwrap()
976 };
977 let all = FakeDriver::new().capabilities();
978
979 for (name, capabilities) in [
980 (
981 "prepare",
982 DriverCapabilities {
983 prepare: false,
984 ..all.clone()
985 },
986 ),
987 (
988 "vsock",
989 DriverCapabilities {
990 vsock: false,
991 ..all.clone()
992 },
993 ),
994 (
995 "vsock_listen",
996 DriverCapabilities {
997 vsock_listen: false,
998 ..all
999 },
1000 ),
1001 ] {
1002 let driver = FakeDriver::builder().capabilities(capabilities).build();
1003 let error = ComputerManager::new(config.clone(), environment(driver))
1004 .err()
1005 .unwrap_or_else(|| panic!("a driver without {name} is refused"));
1006 assert!(matches!(error, ComputerError::Config(_)), "{error}");
1007 assert!(error.to_string().contains(name), "{error}");
1008 }
1009
1010 let manager = ComputerManager::new(config.clone(), environment(FakeDriver::new()))
1011 .expect("a driver with every needed capability is accepted");
1012 assert_eq!(manager.services.driver.name(), "fake");
1013 }
1014
1015 #[test]
1016 fn a_claimed_id_rejects_a_concurrent_duplicate() {
1017 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1018 let first = reserve_actor(&computers, &"dup".to_owned(), placeholder("dup")).unwrap();
1019 // A second claim of the same id must fail while the first is live —
1020 // before either touches the per-id resources they both derive.
1021 assert!(matches!(
1022 reserve_actor(&computers, &"dup".to_owned(), placeholder("dup")),
1023 Err(ComputerError::AlreadyExists(_))
1024 ));
1025 drop(first);
1026 assert!(!computers.read().unwrap().contains_key("dup"));
1027 }
1028
1029 /// A jailed config, which is what makes a driver answer with a budget
1030 /// at all — the layout behind that answer is the driver's business.
1031 fn jailed_config() -> RuntimeConfig {
1032 let mut config = RuntimeConfig::default();
1033 config.firecracker.jailer = Some(crate::config::JailerConfig {
1034 uid: 0,
1035 gid: 0,
1036 chroot_base_dir: Some("/srv/jailer".into()),
1037 netns: None,
1038 new_pid_ns: false,
1039 cgroup_version: None,
1040 parent_cgroup: None,
1041 });
1042 config
1043 }
1044
1045 /// The id shape the platform node actually mints, in the rendering a
1046 /// driver can run: the control plane's `inst_<uuid v7>` with the `_`
1047 /// the VMM refuses turned into a `-`.
1048 const CONTROL_PLANE_ID: &str = "inst-019e409e-7546-7a3e-8b2c-1f2e3d4c5b6a";
1049
1050 #[test]
1051 fn validate_id_accepts_safe_ids_and_rejects_traversal() {
1052 for ok in ["sandbox1", "a-b_c", "0f3e9d16-1234", "A_B-9"] {
1053 assert!(validate_id("id", ok).is_ok(), "{ok} should be valid");
1054 }
1055 // The generic validator stays uncapped and keeps the underscore: it
1056 // also runs against persisted records and against snapshot /
1057 // execution ids that never become a VM identity, where a legacy id
1058 // must not become fatal.
1059 assert!(validate_id("id", &"a".repeat(60)).is_ok());
1060 assert!(validate_id("id", "inst_019e409e-7546").is_ok());
1061 for bad in ["", "..", ".", "a/b", "a\\b", "a b", "a.b", "a\0b", "../etc"] {
1062 assert!(
1063 validate_id("id", bad).is_err(),
1064 "{bad:?} should be rejected"
1065 );
1066 }
1067 }
1068
1069 /// Ingress holds a new id to whatever budget the *configured* driver
1070 /// reports for the isolation it will run under — this layer keeps no
1071 /// copy of a number. Anything past it must fail fast here instead of
1072 /// surfacing as a socket connect timeout.
1073 ///
1074 /// Whether a given layout leaves room for the ids an ArcBox node mints
1075 /// is the adapter's own question, answered against the deployed
1076 /// constants in `arcbox-fc-driver`
1077 /// (`the_deployed_jail_layout_admits_the_ids_a_node_mints`).
1078 #[test]
1079 fn a_new_sandbox_id_is_held_to_the_drivers_own_budget() {
1080 let mut config = jailed_config();
1081 let budget = CONTROL_PLANE_ID.len();
1082 let driver = FakeDriver::builder().jailed_id_budget(budget).build();
1083 assert!(validate_new_computer_id(CONTROL_PLANE_ID, &driver, &config).is_ok());
1084 assert!(validate_new_computer_id(&"a".repeat(budget), &driver, &config).is_ok());
1085 assert!(validate_new_computer_id(&"a".repeat(budget + 1), &driver, &config).is_err());
1086
1087 // A tighter budget refuses what the looser one took, rather than
1088 // silently reintroducing the connect timeout.
1089 let tighter = FakeDriver::builder().jailed_id_budget(budget - 1).build();
1090 assert!(validate_new_computer_id(CONTROL_PLANE_ID, &tighter, &config).is_err());
1091
1092 // A driver that reports no budget for this isolation bounds
1093 // nothing: only `VmId`'s own 64-byte ceiling is left.
1094 config.firecracker.jailer = None;
1095 assert!(validate_new_computer_id(&"a".repeat(budget + 1), &tighter, &config).is_ok());
1096 }
1097
1098 /// CORE-140. Firecracker validates the `--id` it is handed and refuses it
1099 /// by panicking — under the jailer *and* in direct mode, where the same
1100 /// flag is passed straight to the VMM. So the refusal cannot be gated on
1101 /// a jailer being configured: it belongs to the id itself.
1102 #[test]
1103 fn an_underscored_id_is_refused_at_ingress_in_every_spawn_mode() {
1104 let mut config = jailed_config();
1105 let driver = FakeDriver::builder()
1106 .jailed_id_budget(CONTROL_PLANE_ID.len())
1107 .build();
1108 for _ in 0..2 {
1109 let error =
1110 validate_new_computer_id(&CONTROL_PLANE_ID.replacen('-', "_", 1), &driver, &config)
1111 .expect_err("firecracker would refuse to run under this id");
1112 assert!(
1113 matches!(&error, ComputerError::Config(message) if message.contains('_')),
1114 "the error should name the offending character, got {error}"
1115 );
1116 assert!(validate_new_computer_id(CONTROL_PLANE_ID, &driver, &config).is_ok());
1117 // Direct mode passes the same `--id`, so it is refused there too.
1118 config.firecracker.jailer = None;
1119 }
1120 }
1121
1122 #[test]
1123 fn a_dropped_claim_frees_the_id() {
1124 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1125 {
1126 let _claim = reserve_actor(&computers, &"tmp".to_owned(), placeholder("tmp")).unwrap();
1127 assert!(computers.read().unwrap().contains_key("tmp"));
1128 // never spawned → dropped here
1129 }
1130 assert!(!computers.read().unwrap().contains_key("tmp"));
1131 // The id is free to claim again after an unwound restore.
1132 assert!(reserve_actor(&computers, &"tmp".to_owned(), placeholder("tmp")).is_ok());
1133 }
1134
1135 /// A departing actor unregisters *its own* entry and no other.
1136 ///
1137 /// The `Arc::ptr_eq` guard the instance map needed, as an incarnation:
1138 /// a computer removed and re-created under the same id (deterministic
1139 /// caller-supplied ids make this common) installs a fresh entry, and the
1140 /// one on its way out must not evict it.
1141 #[test]
1142 fn a_departing_actor_does_not_evict_its_replacement() {
1143 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1144 let departing = reserve_actor(&computers, &"same".to_owned(), placeholder("same")).unwrap();
1145 let incarnation = departing.incarnation;
1146 std::mem::forget(departing);
1147
1148 assert!(matches!(
1149 reserve_actor(&computers, &"same".to_owned(), placeholder("same")),
1150 Err(ComputerError::AlreadyExists(_))
1151 ));
1152 forget_computer(&computers, &"same".to_owned(), incarnation);
1153 let replacement = reserve_actor(&computers, &"same".to_owned(), placeholder("same"))
1154 .expect("the id is free once the departing actor let it go");
1155
1156 forget_computer(&computers, &"same".to_owned(), incarnation);
1157 assert!(
1158 computers.read().unwrap().contains_key("same"),
1159 "the stale incarnation must not evict the replacement"
1160 );
1161 drop(replacement);
1162 assert!(!computers.read().unwrap().contains_key("same"));
1163 }
1164
1165 /// Registration is per incarnation, which is what lets a handover pass
1166 /// tell an actor that finished from one that died.
1167 ///
1168 /// `detach_all` reads this after an unanswerable mailbox: `false` means
1169 /// the actor ended and unregistered (or its id was re-created since), so
1170 /// nothing was lost, while `true` means its task went away without
1171 /// finishing and its guest is still out there.
1172 #[test]
1173 fn registration_is_per_incarnation() {
1174 let computers: Computers = Arc::new(RwLock::new(HashMap::new()));
1175 let id = "box".to_owned();
1176 let departing = reserve_actor(&computers, &id, placeholder("box")).unwrap();
1177 let incarnation = departing.incarnation;
1178 assert!(still_registered(&computers, &id, incarnation));
1179
1180 // A different incarnation of a live id is not this one.
1181 assert!(!still_registered(&computers, &id, Uuid::new_v4()));
1182
1183 // An actor still holding its claim while unable to answer: the case
1184 // worth reporting.
1185 std::mem::forget(departing);
1186 assert!(still_registered(&computers, &id, incarnation));
1187
1188 // And once it has let go — its own exit, or a replacement taking the
1189 // id — that incarnation is no longer registered.
1190 forget_computer(&computers, &id, incarnation);
1191 assert!(!still_registered(&computers, &id, incarnation));
1192 let replacement = reserve_actor(&computers, &id, placeholder("box")).unwrap();
1193 assert!(!still_registered(&computers, &id, incarnation));
1194 assert!(still_registered(&computers, &id, replacement.incarnation));
1195 }
1196}