Skip to main content

arcbox_computer_runtime/sandbox/
pool.rs

1//! Pre-warmed restore slots (CORE-78).
2//!
3//! The fixed host-side setup of a snapshot restore — jailer chroot, the
4//! VMM spawn, kernel/vmstate/mem staging, dm-snapshot — costs
5//! ~114 ms of the restore RPC. A [`PreparedSlot`] pre-executes exactly
6//! that sequence, so a restore that claims one proceeds directly to
7//! LoadSnapshot + guest reconfiguration.
8//!
9//! Slots are keyed by snapshot id and journaled under `pool-<uuid>` ids in
10//! the same `sandboxes/` namespace as real sandboxes, so a crash leaves
11//! nothing behind that the startup orphan sweep cannot reclaim. TAP and IP
12//! allocation deliberately stay in the claim path: the network override at
13//! LoadSnapshot time binds a claimed slot to its TAP, which keeps slots
14//! network-agnostic (the seam CORE-81 builds on).
15
16use super::boot::{StageError, stage_rootfs_cow_or_copy};
17use super::reconcile::{self, POOL_SLOT_PREFIX, SandboxStateRecord};
18use super::*;
19use crate::config::JailerConfig;
20use crate::snapshot::SnapshotMeta;
21
22/// The slot policy — LRU, refill accounting, eviction — lives in
23/// [`super::policy::pool`]; re-exported so callers keep naming one
24/// `pool::SlotPool`.
25pub use super::policy::pool::SlotPool;
26
27/// A fully staged restore slot: the VMM spawned (API socket up, nothing
28/// loaded yet), kernel + vmstate + mem staged into the area it reads from,
29/// dm-snapshot of the rootfs created.
30pub struct PreparedSlot {
31    /// Slot id (`pool-<uuid>`): the VM, its dm/CoW names, and its crash
32    /// journal are keyed by it.
33    pub slot_id: String,
34    /// The VMM the driver prepared, waiting for the snapshot load.
35    pub prepared: Arc<dyn PreparedVm>,
36    /// dm-snapshot of the snapshot's rootfs (`None` = copy fallback).
37    pub cow_handle: Option<CowHandle>,
38    /// The root disk as staging left it — the path the restore's spec must
39    /// name, which is not the caller's to recompute.
40    pub rootfs: PathBuf,
41    /// The checkpoint as staged for this slot, ready for the driver to
42    /// load.
43    pub image: CheckpointImage,
44    /// Slot runtime dir (`sandboxes/pool-<uuid>`) holding its crash journal.
45    pub vm_dir: PathBuf,
46}
47
48/// Pre-execute the restore setup sequence for `snapshot`: journal, prepare
49/// the jailed VMM through the driver, stage kernel + rootfs (dm-snapshot
50/// with copy fallback) + vmstate/mem hard links. A failure unwinds every
51/// partial resource before returning.
52pub(super) async fn prepare_slot(
53    driver: &dyn VmDriver,
54    config: &RuntimeConfig,
55    cow_manager: &CowManager,
56    snapshot: &SnapshotMeta,
57) -> Result<PreparedSlot> {
58    let fc_cfg = &config.firecracker;
59    let jc = fc_cfg
60        .jailer
61        .as_ref()
62        .ok_or_else(|| VmmError::Config("restore slot pooling requires jailer isolation".into()))?;
63    // Short suffix on purpose: the slot id becomes a VM identity and must
64    // fit the driver's own id budget (`VmDriver::id_budget`) under any
65    // configured chroot layout — internal mints bypass the ingress
66    // validator, so they keep themselves comfortably inside it. 16 hex
67    // chars keep collisions out of reach for a handful of pool slots;
68    // reconcile parses slot ids by prefix, so pre-existing full-UUID
69    // journals stay adoptable.
70    let mut suffix = Uuid::new_v4().simple().to_string();
71    suffix.truncate(16);
72    let slot_id = format!("{POOL_SLOT_PREFIX}{suffix}");
73    let vm_dir = PathBuf::from(&fc_cfg.data_dir)
74        .join("sandboxes")
75        .join(&slot_id);
76
77    // Journal before the first external resource exists so a crash
78    // mid-prepare is swept like any orphaned sandbox.
79    reconcile::create_runtime_dir(&vm_dir)?;
80    reconcile::write_state_record(
81        &vm_dir,
82        &SandboxStateRecord::new(&slot_id, None, None, None, config, None)?,
83    )?;
84
85    match stage_slot(driver, config, jc, cow_manager, snapshot, &slot_id, &vm_dir).await {
86        Ok(staged) => Ok(PreparedSlot {
87            slot_id,
88            prepared: staged.prepared,
89            cow_handle: staged.cow_handle,
90            rootfs: staged.rootfs,
91            image: staged.image,
92            vm_dir,
93        }),
94        Err(mut failure) => {
95            // Unwind whatever the failed stage acquired; on unwind failure
96            // the journal stays for the startup sweep to retry.
97            let fc_unwound = match failure.prepared.take() {
98                Some(prepared) => match prepared.discard().await {
99                    Ok(_) => true,
100                    Err(error) => {
101                        warn!(slot_id, error = %error, "failed slot prepare left its vmm behind");
102                        false
103                    }
104                },
105                None => true,
106            };
107            let cow_unwound = match failure.cow_handle.take() {
108                Some(handle) => match cow_manager.teardown_checked(&handle).await {
109                    Ok(()) => true,
110                    Err(error) => {
111                        warn!(slot_id, error = %error, "failed slot prepare left CoW resources behind");
112                        false
113                    }
114                },
115                None => true,
116            };
117            // The discard above took the area the slot's files were staged
118            // into with it, so what is left to unwind is the journal.
119            if fc_unwound && cow_unwound {
120                if let Err(error) = reconcile::clear_state_record(&vm_dir) {
121                    warn!(slot_id, error = %error, "failed slot prepare kept its journal");
122                } else {
123                    let _ = tokio::fs::remove_dir_all(&vm_dir).await;
124                }
125            }
126            Err(failure.error)
127        }
128    }
129}
130
131struct SlotFailure {
132    error: VmmError,
133    prepared: Option<Arc<dyn PreparedVm>>,
134    cow_handle: Option<CowHandle>,
135}
136
137impl From<VmmError> for SlotFailure {
138    fn from(error: VmmError) -> Self {
139        Self {
140            error,
141            prepared: None,
142            cow_handle: None,
143        }
144    }
145}
146
147/// What [`stage_slot`] pre-executes, and [`PreparedSlot`] then carries.
148struct StagedSlot {
149    prepared: Arc<dyn PreparedVm>,
150    cow_handle: Option<CowHandle>,
151    rootfs: PathBuf,
152    image: CheckpointImage,
153}
154
155#[allow(
156    clippy::result_large_err,
157    reason = "the failure hands back the half-built sandbox's resources; boxing it is a refactor of its own"
158)]
159async fn stage_slot(
160    driver: &dyn VmDriver,
161    config: &RuntimeConfig,
162    jc: &JailerConfig,
163    cow_manager: &CowManager,
164    snapshot: &SnapshotMeta,
165    slot_id: &str,
166    vm_dir: &Path,
167) -> std::result::Result<StagedSlot, SlotFailure> {
168    let prepared: Arc<dyn PreparedVm> = Arc::from(
169        super::prepare_capability(driver)
170            .prepare(
171                &VmId::new(slot_id).map_err(VmmError::from)?,
172                &IsolationSpec::try_from(jc)?,
173                vm_dir,
174            )
175            .await
176            .map_err(VmmError::from)?,
177    );
178    let pid = super::journaled_pid(&*prepared);
179    let vmm = super::journaled_vmm(&*prepared);
180    let journal = |cow: Option<&CowHandle>| {
181        reconcile::write_state_record(
182            vm_dir,
183            &SandboxStateRecord::new(slot_id, pid, None, cow, config, None)?.with_vmm(vmm.clone()),
184        )
185    };
186    let carry = |error: VmmError, prepared, cow_handle| SlotFailure {
187        error,
188        prepared,
189        cow_handle,
190    };
191    if let Err(error) = journal(None) {
192        return Err(carry(error, Some(prepared), None));
193    }
194
195    // Everything the restore would otherwise pay for on its critical path,
196    // brought into the area this slot's VMM reads from — under the same
197    // names a restore renders, so claiming the slot stages nothing twice.
198    let staging = super::staging_capability(prepared.staging());
199    if let Some(kernel) = snapshot.kernel_path.as_deref() {
200        if let Err(error) = staging.stage_kernel(Path::new(kernel)).await {
201            return Err(carry(error.into(), Some(prepared), None));
202        }
203    }
204
205    let Some(rootfs) = snapshot.rootfs_path.as_deref() else {
206        return Err(carry(
207            VmmError::Snapshot(format!(
208                "checkpoint {} records no rootfs template; there is no disk to pre-warm a slot \
209                 with",
210                snapshot.id
211            )),
212            Some(prepared),
213            None,
214        ));
215    };
216    let staged =
217        match stage_rootfs_cow_or_copy(cow_manager, staging, slot_id, rootfs, &journal).await {
218            Ok(staged) => staged,
219            Err(StageError {
220                error,
221                cow_handle: leaked,
222            }) => return Err(carry(error, Some(prepared), leaked)),
223        };
224
225    let image = match super::catalogued_checkpoint(snapshot) {
226        Ok(catalogued) => staging
227            .stage_checkpoint(&catalogued)
228            .await
229            .map_err(Into::into),
230        Err(error) => Err(error),
231    };
232    match image {
233        Ok(image) => Ok(StagedSlot {
234            prepared,
235            cow_handle: staged.cow_handle,
236            rootfs: staged.path,
237            image,
238        }),
239        Err(error) => Err(carry(error, Some(prepared), staged.cow_handle)),
240    }
241}
242
243/// Drain and tear down every ready slot staged from one snapshot (or from
244/// all of them). Teardown failures are logged and left to the startup
245/// sweep — the slots' crash journals keep them reclaimable.
246pub(super) async fn drain_pool_slots(
247    pool: &SlotPool,
248    cow_manager: &CowManager,
249    snapshot_id: Option<&str>,
250) {
251    for slot in pool.drain(snapshot_id) {
252        let slot_id = slot.slot_id.clone();
253        if let Err(error) = destroy_slot(cow_manager, slot).await {
254            warn!(
255                slot_id,
256                error = %error,
257                "pool slot teardown incomplete; the startup sweep will retry"
258            );
259        }
260    }
261}
262
263/// Tear down a slot completely: process (and the area its files were
264/// staged into, which the discard takes), CoW resources, journal.
265///
266/// On error the slot's crash journal is left in place so the startup
267/// sweep retries; callers log and move on.
268pub(super) async fn destroy_slot(cow_manager: &CowManager, mut slot: PreparedSlot) -> Result<()> {
269    // The VMM must be dead before the dm teardown (`dmsetup remove` returns
270    // EBUSY while the block device is open).
271    slot.prepared.discard().await?;
272    if let Some(handle) = slot.cow_handle.take() {
273        cow_manager.teardown_checked(&handle).await?;
274    }
275    reconcile::clear_state_record(&slot.vm_dir)?;
276    match tokio::fs::remove_dir_all(&slot.vm_dir).await {
277        Ok(()) => Ok(()),
278        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
279        Err(error) => Err(VmmError::Io(error)),
280    }
281}
282
283/// Claim a live pre-warmed slot for `snapshot_id`. Slots whose VMM died in
284/// the meantime are discarded and torn down in the background.
285///
286/// A free function, like [`spawn_pool_refill`] below: the restore flow that
287/// calls both runs in the computer's actor, while the pool stays
288/// manager-wide — it is keyed by *snapshot* and LRU across ids, so it cannot
289/// be per-computer.
290pub fn claim_restore_slot(
291    pool: &SlotPool,
292    cow_manager: &Arc<CowManager>,
293    snapshot_id: &str,
294) -> Option<PreparedSlot> {
295    loop {
296        let slot = pool.claim(snapshot_id)?;
297        if slot.prepared.alive() {
298            return Some(slot);
299        }
300        warn!(
301            snapshot_id,
302            slot_id = %slot.slot_id,
303            "discarding pre-warmed slot whose vmm died"
304        );
305        spawn_slot_teardown(cow_manager, slot);
306    }
307}
308
309fn spawn_slot_teardown(cow_manager: &Arc<CowManager>, slot: PreparedSlot) {
310    {
311        let cow_manager = Arc::clone(cow_manager);
312        tokio::spawn(async move {
313            let slot_id = slot.slot_id.clone();
314            if let Err(error) = destroy_slot(&cow_manager, slot).await {
315                warn!(
316                    slot_id,
317                    error = %error,
318                    "pool slot teardown incomplete; the startup sweep will retry"
319                );
320            }
321        });
322    }
323}
324
325/// Refill the pool for `snapshot_id` up to `firecracker.pool_size` spare
326/// slots, in the background. Fired after each successful restore — which is
327/// exactly what makes a snapshot "restored at least once" and eligible for
328/// pooling — so a failing restore never spawns warm processes in a loop.
329pub fn spawn_pool_refill(
330    pool: &Arc<SlotPool>,
331    driver: &Arc<dyn VmDriver>,
332    config: &Arc<RuntimeConfig>,
333    cow_manager: &Arc<CowManager>,
334    snapshots: &Arc<SnapshotCatalog>,
335    snapshot_id: &str,
336) {
337    {
338        let plan = pool.begin_fill(snapshot_id, config.firecracker.pool_size);
339        for slot in plan.evicted {
340            spawn_slot_teardown(cow_manager, slot);
341        }
342        for _ in 0..plan.spawn {
343            let pool = Arc::clone(pool);
344            let driver = Arc::clone(driver);
345            let config = Arc::clone(config);
346            let cow_manager = Arc::clone(cow_manager);
347            let snapshots = Arc::clone(snapshots);
348            let snapshot_id = snapshot_id.to_owned();
349            tokio::spawn(async move {
350                // Re-resolve the snapshot: it may have been deleted since.
351                let staged = match snapshots.find_by_id(&snapshot_id) {
352                    Ok(meta) => prepare_slot(driver.as_ref(), &config, &cow_manager, &meta).await,
353                    Err(error) => Err(error.into()),
354                };
355                match staged {
356                    Ok(slot) => match pool.offer(&snapshot_id, slot) {
357                        None => debug!(snapshot_id, "restore slot pre-warmed"),
358                        Some(rejected) => {
359                            // Evicted or drained while the fill was in flight.
360                            let slot_id = rejected.slot_id.clone();
361                            if let Err(error) = destroy_slot(&cow_manager, rejected).await {
362                                warn!(
363                                    slot_id,
364                                    error = %error,
365                                    "pool slot teardown incomplete; the startup sweep will retry"
366                                );
367                            }
368                        }
369                    },
370                    Err(error) => {
371                        pool.abandon_fill(&snapshot_id);
372                        warn!(snapshot_id, error = %error, "restore slot pre-warm failed");
373                    }
374                }
375            });
376        }
377    }
378}
379
380impl SandboxManager {
381    /// Tear down pooled slots: all of them, or those staged from one
382    /// snapshot.
383    pub(super) async fn drain_pool(&self, snapshot_id: Option<&str>) {
384        drain_pool_slots(&self.pool, &self.cow_manager, snapshot_id).await;
385    }
386
387    /// Release manager-held background resources: tears down every
388    /// pre-warmed restore slot. Live sandboxes are untouched. On a crash
389    /// (no shutdown) the startup orphan sweep reclaims slots via their
390    /// `pool-<uuid>` crash journals instead.
391    pub async fn shutdown(&self) {
392        self.drain_pool(None).await;
393    }
394}