Skip to main content

arcbox_computer_runtime/sandbox/
pause.rs

1//! Pause / Resume — checkpoint a sandbox and release its VM while keeping
2//! the record, checkpoint, and disk overlay under the same id (CORE-21).
3//!
4//! Pause differs from Checkpoint+Stop in two load-bearing ways:
5//!
6//! - The VM is **not resumed** after the snapshot. Any guest progress after
7//!   the memory image is written would diverge from the retained disk, so
8//!   the snapshot is the sandbox's final state until Resume.
9//! - The disk survives. The dm-snapshot overlay is detached but its
10//!   persistent (`P`) COW file stays on disk (or, in copy-mode fallback,
11//!   the staged rootfs copy is moved out of the chroot), and Resume
12//!   re-assembles exactly the device the memory image expects. A plain
13//!   Restore instead builds a fresh overlay from the template, discarding
14//!   writes — which is why restoring *from* a pause checkpoint is refused.
15//!
16//! Resume mirrors the fresh-network restore path: a new TAP + IP is
17//! allocated (`RestoreComputerSpec::network_override` semantics) and the
18//! sandbox returns to `Ready` under its original id. The old allocation was
19//! quarantined at pause time and its host forwarding state cleaned via the
20//! same durable ticket flow Stop uses. Whether the guest needs re-addressing
21//! at all depends on the snapshot's addressing mode — an invariant-addressed
22//! guest (CORE-81) already holds the fixed link-local identity and only the
23//! host-side TAP changes.
24//!
25//! A sandbox that adopted a pre-warmed restore slot (CORE-78) lives in the
26//! slot's chroot with slot-keyed dm/CoW names. Pause releases that chroot and
27//! renames the retained overlay onto the sandbox-id path, so from `Paused`
28//! onwards every resource is keyed by the sandbox id again and resume,
29//! reconciliation, and `Remove` all see one naming scheme.
30
31use super::*;
32use crate::lifecycle::actor::PauseReason;
33
34/// Reserved catalog name for internal pause checkpoints.
35///
36/// Everything with this name is lifecycle state owned by the pause
37/// machinery: hidden from ListSnapshots, refused by DeleteSnapshot and
38/// Restore, rejected as a user checkpoint name, and deleted by Remove.
39pub const PAUSE_SNAPSHOT_NAME: &str = "arcbox-pause";
40
41/// Where a copy-mode rootfs is parked inside `vm_dir` while paused.
42pub const PAUSED_ROOTFS_FILE: &str = "paused-rootfs.ext4";
43
44/// Reason attribute values for pause/resume events.
45pub mod reason {
46    /// Client-driven `Pause`.
47    pub const PAUSE: &str = "pause";
48    /// Idle-detector pause (`on_idle: PAUSE`, CORE-21).
49    pub const IDLE_TIMEOUT: &str = "idle_timeout";
50    /// Client-driven `Resume`.
51    pub const RESUME: &str = "resume";
52    /// Daemon-side transparent resume on a data-plane call.
53    pub const AUTO_RESUME: &str = "auto_resume";
54}
55
56/// Delete every internal pause checkpoint of `sandbox_id`.
57///
58/// Scans by the reserved name rather than the recorded snapshot id so a
59/// checkpoint leaked by an interrupted pause (committed to the catalog but
60/// never recorded durably) is cleaned up too.
61pub fn delete_pause_snapshots(
62    snapshots: &crate::snapshot::SnapshotCatalog,
63    sandbox_id: &str,
64) -> Result<()> {
65    for info in snapshots.list(sandbox_id)? {
66        if info.name.as_deref() == Some(PAUSE_SNAPSHOT_NAME) {
67            snapshots.delete_by_id(&info.id)?;
68        }
69    }
70    Ok(())
71}
72
73/// Runtime resources a successful in-place restore hands back to the
74/// instance.
75pub struct ResumedRuntime {
76    pub prepared: Arc<dyn PreparedVm>,
77    pub handle: Arc<dyn VmHandle>,
78    pub network: Option<NetworkLease>,
79    /// What the resumed guest holds on its interface — see
80    /// [`ComputerRuntime::net_identity`].
81    pub net_identity: Option<NetworkIdentity>,
82    pub cow_handle: Option<CowHandle>,
83}
84
85/// How a failed resume left the sandbox.
86pub struct ResumeFailure {
87    pub error: ComputerError,
88    /// True when every re-created resource was released again and the
89    /// retained pause state (checkpoint + disk) is intact — the sandbox can
90    /// go back to `Paused` and a retry can succeed.
91    pub unwound: bool,
92}
93
94impl ComputerManager {
95    /// Pause a `Ready` sandbox: checkpoint it, then release its runtime
96    /// resources while keeping the record, checkpoint, and disk overlay
97    /// under the same id.
98    ///
99    /// Idempotent: pausing a `Paused` sandbox is a no-op. Any other state
100    /// answers `WrongState` — an active execution must finish (or be
101    /// stopped) first, matching the contract's "requires READY".
102    pub async fn pause_computer(&self, id: &ComputerId) -> Result<()> {
103        self.pause_computer_with_reason(id, PauseReason::Requested)
104            .await
105    }
106
107    /// [`Self::pause_computer`] with an explicit PAUSING-event reason —
108    /// the idle detector reports `idle_timeout` (see [`reason`]).
109    pub(super) async fn pause_computer_with_reason(
110        &self,
111        id: &ComputerId,
112        reason: PauseReason,
113    ) -> Result<()> {
114        self.await_reconcile().await?;
115        let computer = self.computer(id)?;
116        // Resume restores into a fresh jailer chroot; direct-mode vmstate
117        // pins origin paths, so a direct-mode pause could never resume. Read
118        // off the snapshot rather than left to the flow, because it is a
119        // refusal the caller gets *instead* of the claim: only a computer
120        // that would otherwise be paused hears it, exactly as today.
121        if computer.snapshot.borrow().state == ComputerState::Ready
122            && self.config.firecracker.jailer.is_none()
123        {
124            return Err(ComputerError::Config(
125                "sandbox pause requires jailer isolation; direct mode cannot resume".into(),
126            ));
127        }
128        computer
129            .mailbox
130            .ask(id, |reply| Command::Pause { reason, reply })
131            .await
132    }
133
134    /// Resume a `Paused` sandbox in place: restore from its internal pause
135    /// checkpoint with a fresh network allocation, back to `Ready` under
136    /// the same id.
137    ///
138    /// Idempotent: `Ready`/`Running` return the current IP without touching
139    /// anything. `reason` is surfaced verbatim as the `RESUMED` event's
140    /// "reason" attribute (see [`reason`]).
141    ///
142    /// Returns the sandbox's (fresh) IP address, empty without networking.
143    pub async fn resume_computer(&self, id: &ComputerId, resume_reason: &str) -> Result<String> {
144        self.await_reconcile().await?;
145        let computer = self.computer(id)?;
146        computer
147            .mailbox
148            .ask(id, |reply| Command::Resume {
149                reason: resume_reason.to_owned(),
150                reply,
151            })
152            .await?;
153        // The lease the resume reserved, read back off the snapshot the
154        // actor republished when the flow reported.
155        Ok(computer
156            .snapshot
157            .borrow()
158            .lease
159            .as_ref()
160            .map(|lease| lease.ip.to_string())
161            .unwrap_or_default())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    use crate::snapshot::{SnapshotCatalog, SnapshotDraft};
170
171    /// Commit a snapshot of `vm_id` under `name`.
172    fn publish(catalog: &SnapshotCatalog, vm_id: &str, name: &str) -> String {
173        let pending = catalog.begin(vm_id).unwrap();
174        std::fs::write(pending.dir().join("vmstate"), b"vmstate").unwrap();
175        pending
176            .commit(SnapshotDraft {
177                name: Some(name.to_owned()),
178                labels: HashMap::new(),
179                snapshot_type: crate::config::SnapshotType::Full,
180                parent_id: None,
181                kernel_path: None,
182                rootfs_path: None,
183                net_invariant: false,
184                geometry: None,
185                format: super::checkpoint::CHECKPOINT_FORMAT.to_owned(),
186            })
187            .unwrap()
188            .id
189    }
190
191    /// The pause sweep goes by the reserved *name*, not by the id the
192    /// record holds — which is the only thing that reaches a checkpoint an
193    /// interrupted pause committed to the catalog but never recorded.
194    ///
195    /// Driven against the catalog directly because that leak has no flow
196    /// that produces it: the manager-level half (a pause checkpoint stays
197    /// out of `ListSnapshots`, and Remove takes it with the computer) is a
198    /// real flow, and lives in `tests/manager_over_fakes.rs`.
199    #[test]
200    fn the_pause_sweep_deletes_every_reserved_checkpoint_and_nothing_else() {
201        let dir = tempfile::tempdir().unwrap();
202        let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
203        let recorded = publish(&catalog, "box", PAUSE_SNAPSHOT_NAME);
204        let leaked = publish(&catalog, "box", PAUSE_SNAPSHOT_NAME);
205        let user = publish(&catalog, "box", "warm");
206        let other = publish(&catalog, "elsewhere", PAUSE_SNAPSHOT_NAME);
207
208        delete_pause_snapshots(&catalog, "box").unwrap();
209
210        assert!(catalog.find_by_id(&recorded).is_err());
211        assert!(catalog.find_by_id(&leaked).is_err());
212        assert!(catalog.find_by_id(&user).is_ok(), "a user checkpoint stays");
213        assert!(
214            catalog.find_by_id(&other).is_ok(),
215            "another computer's pause state is not this one's to delete"
216        );
217    }
218}