arcbox_computer_runtime/error.rs
1use thiserror::Error;
2
3/// Computer runtime error type.
4#[derive(Debug, Error)]
5pub enum ComputerError {
6 /// The requested VM was not found.
7 #[error("VM not found: {0}")]
8 NotFound(String),
9
10 /// A VM with the given name already exists.
11 #[error("VM already exists: {0}")]
12 AlreadyExists(String),
13
14 /// The VM is not in a state that allows the requested operation.
15 #[error("VM '{id}' is in wrong state: expected {expected}, got {actual}")]
16 WrongState {
17 id: String,
18 expected: String,
19 actual: String,
20 },
21
22 /// The sandbox is paused. Distinct from [`Self::WrongState`] so callers
23 /// (the daemon's transparent auto-resume, CORE-21) can recognise
24 /// "paused" machine-readably instead of parsing state strings.
25 #[error("sandbox '{0}' is paused")]
26 Paused(String),
27
28 /// I/O error (file system, sockets, etc.).
29 #[error("I/O error: {0}")]
30 Io(#[from] std::io::Error),
31
32 /// JSON serialisation/deserialisation error.
33 #[error("JSON error: {0}")]
34 Json(#[from] serde_json::Error),
35
36 /// The VM driver — the port's adapter, or a capability reached through
37 /// it — failed in a way that has no native shape here (an adapter
38 /// fault, a foreign checkpoint). The port's `NotFound`, `WrongState`,
39 /// `Io`, `Network` and `InvalidSpec` land on their native variants
40 /// instead (see the `From` impl), so the guest agent's wire mapping
41 /// keeps answering 404 / 412 for them rather than 500.
42 #[error("driver error: {0}")]
43 Driver(#[source] arcbox_vm_driver::Error),
44
45 /// Network-related error (TAP creation, IP allocation, etc.).
46 #[error("network error: {0}")]
47 Network(String),
48
49 /// Snapshot catalog error.
50 #[error("snapshot error: {0}")]
51 Snapshot(String),
52
53 /// Device-mapper / dm-snapshot error.
54 #[error("device-mapper error: {0}")]
55 DeviceMapper(String),
56
57 /// Building a sandbox rootfs image (OCI/overlay2 → ext4, vm-agent
58 /// injection, the default busybox image) failed.
59 #[error("rootfs error: {0}")]
60 Rootfs(String),
61
62 /// Process lifecycle error.
63 #[error("process error: {0}")]
64 Process(String),
65
66 /// Configuration error.
67 #[error("configuration error: {0}")]
68 Config(String),
69
70 /// Vsock / guest-agent communication error.
71 #[error("vsock error: {0}")]
72 Vsock(String),
73
74 /// A path inside a sandbox does not exist. The message shape is a
75 /// contract: the daemon's error classifier keys on the "path not
76 /// found:" prefix to attach the `FILE_NOT_FOUND` registry code.
77 #[error("path not found: {0}")]
78 PathNotFound(String),
79
80 /// A directory operation addressed a non-directory path.
81 #[error("not a directory: {0}")]
82 NotADirectory(String),
83
84 /// Refused to remove a non-empty directory without `recursive`
85 /// (`FAILED_PRECONDITION` per the filesystem contract).
86 #[error("directory not empty: {0}")]
87 DirectoryNotEmpty(String),
88
89 /// A catalog template reference did not resolve. The message shape is a
90 /// contract: the daemon's classifier keys on the "template not found:"
91 /// prefix to attach the `TEMPLATE_NOT_FOUND` registry code (CORE-107).
92 #[error("template not found: {0}")]
93 TemplateNotFound(String),
94
95 /// Publishing would repoint an existing immutable template version at
96 /// different content (409 on the daemon surface).
97 #[error("template version already exists: {0}")]
98 TemplateVersionExists(String),
99
100 /// A required precondition does not hold and retrying the same request
101 /// never helps (`FAILED_PRECONDITION` on the daemon surface).
102 #[error("failed precondition: {0}")]
103 FailedPrecondition(String),
104
105 /// A bounded wait elapsed before the awaited condition held
106 /// (`DEADLINE_EXCEEDED` on the daemon surface).
107 #[error("deadline exceeded: {0}")]
108 DeadlineExceeded(String),
109
110 /// A retryable operation whose durable result could not be confirmed.
111 #[error("service unavailable: {0}")]
112 Unavailable(String),
113
114 /// The operation's side effects committed — the sandbox exists and is
115 /// running — but the acknowledging record's durability is unconfirmed.
116 /// Distinct from [`ComputerError::Unavailable`] so callers with a fallback
117 /// (warm create) can tell "nothing happened, retry freely" from "it
118 /// happened, do NOT re-execute".
119 #[error("sandbox {id} committed, but ACK durability is unconfirmed: {detail}")]
120 AckUnconfirmed {
121 /// The sandbox whose operation committed.
122 id: String,
123 /// The underlying durability failure.
124 detail: String,
125 },
126
127 /// A stdin write starts past the accepted byte count — the caller must
128 /// resume from `accepted` (its offsets have a gap).
129 #[error("stdin offset {offset} is past the {accepted} accepted bytes")]
130 StdinGap {
131 /// Bytes accepted so far — the offset the next write must start at.
132 accepted: u64,
133 /// The rejected write's offset.
134 offset: u64,
135 },
136
137 /// Generic catch-all error.
138 #[error("{0}")]
139 Other(String),
140}
141
142/// Convenience alias.
143pub type Result<T> = std::result::Result<T, ComputerError>;
144
145/// Variant-for-variant, so a snapshot or template failure keeps the exact
146/// shape callers already match on — the daemon maps `TemplateNotFound`
147/// and `TemplateVersionExists` onto their own wire codes, and folding
148/// either into a generic error would change the surface.
149impl From<arcbox_snapshot::SnapshotError> for ComputerError {
150 fn from(err: arcbox_snapshot::SnapshotError) -> Self {
151 use arcbox_error::CommonError;
152 use arcbox_snapshot::SnapshotError as S;
153 match err {
154 S::Common(CommonError::Io(io)) => Self::Io(io),
155 S::Common(CommonError::Config(msg)) => Self::Config(msg),
156 S::Common(CommonError::NotFound(msg)) => Self::NotFound(msg),
157 S::Common(CommonError::AlreadyExists(msg)) => Self::AlreadyExists(msg),
158 S::Common(other) => Self::Other(other.to_string()),
159 S::Snapshot(msg) => Self::Snapshot(msg),
160 S::DeviceMapper(msg) => Self::DeviceMapper(msg),
161 S::TemplateNotFound(msg) => Self::TemplateNotFound(msg),
162 S::TemplateVersionExists(msg) => Self::TemplateVersionExists(msg),
163 S::FailedPrecondition(msg) => Self::FailedPrecondition(msg),
164 S::Unavailable(msg) => Self::Unavailable(msg),
165 }
166 }
167}
168
169/// Variant-for-variant where a native shape exists, so a driver-reported
170/// `NotFound` or `WrongState` keeps the wire code the equivalent native
171/// error already has (the guest agent classifies `ComputerError` by variant;
172/// anything it does not know becomes a 500), and so do the guest
173/// network's two protocol answers — retry-later and failed precondition,
174/// the 503 and 412 of the cleanup-token protocol. What has no native
175/// shape — an adapter fault, a checkpoint another driver wrote — stays
176/// [`ComputerError::Driver`] with the port error intact for its `source` chain.
177impl From<arcbox_vm_driver::Error> for ComputerError {
178 fn from(err: arcbox_vm_driver::Error) -> Self {
179 use arcbox_vm_driver::Error as D;
180 match err {
181 D::NotFound(id) => Self::NotFound(id.to_string()),
182 D::WrongState {
183 id,
184 state,
185 expected,
186 } => Self::WrongState {
187 id: id.to_string(),
188 expected: expected.to_string(),
189 actual: state.to_string(),
190 },
191 D::Io(io) => Self::Io(io),
192 D::Network(msg) => Self::Network(msg),
193 D::InvalidSpec(msg) => Self::Config(msg),
194 // The cleanup-token protocol's two answers: come back later
195 // (503) and this token is not the one (412). They arrive here
196 // from the guest network and are what the agent's classifier
197 // turns back into those codes.
198 D::Unavailable(msg) => Self::Unavailable(msg),
199 D::PreconditionFailed(msg) => Self::FailedPrecondition(msg),
200 other @ (D::Driver { .. } | D::ForeignCheckpoint(_)) => Self::Driver(other),
201 }
202 }
203}
204
205/// A durable write that never landed is an I/O failure; one that landed
206/// without a confirmed rename is [`ComputerError::Unavailable`] — the caller
207/// may retry, and the retry is safe because the write is idempotent.
208///
209/// Callers that can do better than this (the sandbox record store keeps
210/// the record and warns) match on [`AtomicWriteError`] themselves instead
211/// of going through here.
212impl From<arcbox_atomic_file::AtomicWriteError> for ComputerError {
213 fn from(err: arcbox_atomic_file::AtomicWriteError) -> Self {
214 use arcbox_atomic_file::AtomicWriteError;
215 match err {
216 AtomicWriteError::NotCommitted { source, .. } => Self::Io(source),
217 error @ AtomicWriteError::DurabilityUncertain { .. } => {
218 Self::Unavailable(error.to_string())
219 }
220 }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_not_found_display() {
230 let e = ComputerError::NotFound("vm-123".into());
231 assert_eq!(e.to_string(), "VM not found: vm-123");
232 }
233
234 #[test]
235 fn test_already_exists_display() {
236 let e = ComputerError::AlreadyExists("my-vm".into());
237 assert_eq!(e.to_string(), "VM already exists: my-vm");
238 }
239
240 #[test]
241 fn test_wrong_state_display() {
242 let e = ComputerError::WrongState {
243 id: "vm-1".into(),
244 expected: "running".into(),
245 actual: "stopped".into(),
246 };
247 let s = e.to_string();
248 assert!(s.contains("vm-1"));
249 assert!(s.contains("running"));
250 assert!(s.contains("stopped"));
251 }
252
253 #[test]
254 fn test_from_io_error() {
255 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
256 let vmm_err = ComputerError::from(io_err);
257 assert!(matches!(vmm_err, ComputerError::Io(_)));
258 assert!(vmm_err.to_string().contains("I/O error"));
259 }
260
261 #[test]
262 fn test_network_error_display() {
263 let e = ComputerError::Network("TAP creation failed".into());
264 assert_eq!(e.to_string(), "network error: TAP creation failed");
265 }
266
267 /// The guest network answers the cleanup-token protocol with "come
268 /// back later" and "that is not the token"; both cross the driver port
269 /// and must land on the variants the agent classifies as 503 and 412.
270 /// Folding either into `Network` would make it a 500.
271 #[test]
272 fn the_ports_cleanup_answers_keep_their_classification() {
273 use arcbox_vm_driver::Error as D;
274
275 let retry = ComputerError::from(D::Unavailable("startup cleanup pending".into()));
276 assert!(
277 matches!(&retry, ComputerError::Unavailable(m) if m == "startup cleanup pending"),
278 "{retry}"
279 );
280 let mismatch = ComputerError::from(D::PreconditionFailed("token b is not token a".into()));
281 assert!(
282 matches!(&mismatch, ComputerError::FailedPrecondition(m) if m == "token b is not token a"),
283 "{mismatch}"
284 );
285 }
286}