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