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}