godspeed/error.rs
1// SPDX-License-Identifier: Apache-2.0
2//! What can go wrong, and which of those you may safely try again.
3//!
4//! # This is not a new failure model
5//!
6//! GodspeedOS already had one. `ServiceContext::request_with_reply_deadline_outcome` returns
7//! `DeadlineOutcome { Reply, SendFailed, QueueFull, Timeout }`, and those four states are right as
8//! far as they go. This module carries them to the application, with ONE more the kernel already
9//! reported and that enum folds away: a peer that died holding the request (`ReplyDead`, CLAUDE.md
10//! 8.6), which is [`Error::PeerDied`]. Alongside the filesystem's own status byte, one `Error` answers
11//! both "what happened" and "what may I do about it".
12//!
13//! # The distinction everything turns on
14//!
15//! ```text
16//! you sent a request and did not get a reply. WHY?
17//!
18//! the send never left -> Unreachable RETRY IS SAFE
19//! (peer restarted, cap stale) nothing happened
20//!
21//! the peer's queue was full -> Busy RETRY IS SAFE
22//! (peer alive, congested) nothing happened; pace yourself
23//!
24//! the peer died holding it -> PeerDied RETRY IS NOT SAFE
25//! (delivered, then ReplyDead) it may ALREADY have happened
26//!
27//! the deadline passed in silence -> OutcomeUnknown RETRY IS NOT SAFE
28//! (peer may be slow or wedged) it may ALREADY have happened
29//! ```
30//!
31//! Those last two cases are the whole reason this type is shaped the way it is. A `delete` that
32//! returns `OutcomeUnknown` may have deleted the file. Re-sending it is not "trying again", it is
33//! performing a second, different operation whose failure would look like success. `services/copier`
34//! says the same thing at its own call site, having learned it the hard way, and before this library
35//! several services reached for a longer-named SDK function to recover a distinction the short one
36//! threw away.
37//!
38//! [`Error::retry_is_safe`] is the answer in one call, so nobody has to re-derive it.
39
40/// Everything a Godspeed operation can fail with.
41///
42/// Deliberately small. Each variant exists because a caller would do something DIFFERENT about it;
43/// a distinction nobody would act on belongs in a log line, not in a type.
44#[derive(Copy, Clone, PartialEq, Eq, Debug)]
45pub enum Error {
46 // ---- the service answered, and said no -------------------------------------------------
47 /// No such file or directory. The service is healthy; the path is not there.
48 NotFound,
49 /// The operation needs a right this capability does not carry (§7.3 non-escalation). Asking
50 /// again cannot help: rights never widen.
51 PermissionDenied,
52 /// The volume is not mounted, or holds no GodspeedOS filesystem.
53 NoFilesystem,
54 /// The storage is present but unreadable. Data may still be intact, so this is NOT an
55 /// invitation to reformat.
56 Unavailable,
57 /// The disk holds a foreign partition table. Refused deliberately rather than overwritten.
58 Foreign,
59 /// The file is there, and a block of it failed its checksum or could not be read: it is DAMAGED,
60 /// not missing. `drives scrub` names every such block. `fs` answered this as "not found" until
61 /// 2026-10-10 (`backlog/fixed/80` V2).
62 Damaged,
63 /// The service tried and failed, and said so. A real failure with a real answer behind it.
64 Failed,
65
66 // ---- no answer came, and the four are NOT interchangeable ---------------------------------
67 /// The request never left this task: the peer's capability is stale because it restarted
68 /// (§14.2), or its name does not currently resolve. **Nothing happened.** Retrying after
69 /// reacquiring the peer by name reaches the fresh instance, which [`crate::call::request_within`]
70 /// already does for you once - for a send that never left, and for nothing else. A request that
71 /// was delivered is never re-sent ([`Error::OutcomeUnknown`], [`Error::PeerDied`]).
72 Unreachable,
73 /// The peer is alive and its queue is full. **Nothing happened.** Congestion is transient by
74 /// definition: pace and retry, and do not go looking for a peer that never went anywhere.
75 Busy,
76 /// The deadline passed with no reply. **The operation may have completed.** The peer may be
77 /// slow, or may be wedged. (A peer that DIED holding the request is [`Error::PeerDied`] on the
78 /// `CallDeadline` path, [`crate::call::request_within`]; only paths that wait with a plain timed
79 /// receive, such as [`crate::call::request_within_notice`] with a callback, report a death here
80 /// too, because no `ReplyDead` reaches them.) This is the one failure you must
81 /// not paper over: for anything that changes state, report it or re-read the truth, never
82 /// re-send.
83 OutcomeUnknown,
84 /// The request ARRIVED and the service died before answering it (the kernel's `ReplyDead`,
85 /// CLAUDE.md 8.6). **The operation may have completed**, exactly as with [`Error::OutcomeUnknown`],
86 /// so this is not safe to re-send blindly either.
87 ///
88 /// It is its own variant because the caller does two things differently. It does not wait out a
89 /// deadline or call the peer "silent" - the peer is gone, and saying it was slow sends an operator
90 /// after the wrong fault. And it REACQUIRES before anything else, because the instance it asked no
91 /// longer exists. For an operation that is safe to repeat - a read, a block transfer of the same
92 /// sector - reacquire and ask again; that was what callers did before this variant existed, and
93 /// now they say so.
94 PeerDied,
95
96 // ---- the answer arrived and made no sense ------------------------------------------------
97 /// A reply came back that this library could not parse: too short, wrong tag, or a status byte
98 /// outside the protocol. A bug somewhere, not a condition to handle - but it is reported rather
99 /// than guessed past, because guessing is how a wrong byte index becomes a wrong answer.
100 Malformed,
101
102 /// **The caller stopped waiting** - a person pressed `q`, or the program gave up on purpose.
103 ///
104 /// Not a fault, and the distinction is load-bearing: `services/shell` says it plainly, that "the
105 /// user's own `q` is NOT a fault - reporting it as one teaches the operator to distrust the error
106 /// line, which is the thing they most need to trust". A cancelled operation should be reported as
107 /// cancelled, never as a failure.
108 ///
109 /// It is still not safe to retry blindly. The request may already have been delivered and acted
110 /// on; cancelling only means this caller stopped listening.
111 Cancelled,
112
113 /// The capability is no longer valid: whatever issued it revoked it, or died and was
114 /// replaced (7.5 - a generation bump invalidates every outstanding cap).
115 ///
116 /// **The kernel refused this before the owning service was reached**, so nothing happened and
117 /// nothing was asked of anyone. Distinct from [`Error::Unreachable`] on purpose: the service is
118 /// very likely up and well. Reporting this as "could not be reached" sends an operator to check
119 /// a service that is running perfectly.
120 ///
121 /// [`retry_is_safe`](Error::retry_is_safe) is **false** here, and for the less obvious of its
122 /// two reasons: a repeat cannot double-apply anything (the kernel refused before routing), but
123 /// it can never succeed either, and a caller looping on that predicate would spin forever.
124 ///
125 /// **Re-open the resource** - and remember that a capability obtained from a service's PREVIOUS
126 /// instance is stale even after you reacquire that service by name (14.3).
127 Revoked,
128
129 /// The caller's buffer is too small for the answer. Nothing was consumed; call again with room.
130 BufferTooSmall,
131
132 /// The request could not be built: a path longer than the protocol allows, or a payload past
133 /// the message ceiling. It was never sent.
134 InvalidInput,
135}
136
137impl Error {
138 /// **May I simply send this again?**
139 ///
140 /// `true` only when the request provably never reached the service, so a retry is the same
141 /// operation rather than a second one. `false` for `OutcomeUnknown` even though a retry might
142 /// work, because "might" is not good enough when the first attempt may already have committed.
143 ///
144 /// For a read-only operation the caller may retry regardless; this is about what the LIBRARY
145 /// can promise without knowing what the request was.
146 pub fn retry_is_safe(self) -> bool {
147 matches!(self, Error::Unreachable | Error::Busy)
148 }
149
150 /// `true` when the service answered. Distinguishes "no" from "no answer", which is the coarse
151 /// version of the distinction above and often the only one a caller needs.
152 pub fn service_answered(self) -> bool {
153 matches!(self,
154 Error::NotFound | Error::PermissionDenied | Error::NoFilesystem
155 | Error::Unavailable | Error::Foreign | Error::Damaged | Error::Failed)
156 }
157
158 /// A short, stable, human-readable phrase. Present tense, no trailing punctuation, no leading
159 /// capital, so it drops into a sentence a utility is already building.
160 pub fn as_str(self) -> &'static str {
161 match self {
162 Error::NotFound => "not found",
163 Error::PermissionDenied => "permission denied",
164 Error::NoFilesystem => "no filesystem on this volume",
165 Error::Unavailable => "storage unavailable",
166 Error::Foreign => "the disk holds a foreign partition table",
167 Error::Damaged => "the file is damaged - a block failed its checksum (it is not missing)",
168 Error::Failed => "the operation failed",
169 Error::Unreachable => "the service could not be reached (nothing happened)",
170 Error::Busy => "the service is busy (nothing happened)",
171 Error::OutcomeUnknown => "no answer before the deadline - THE OUTCOME IS UNKNOWN",
172 Error::PeerDied => "the service died before answering - THE OUTCOME IS UNKNOWN",
173 Error::Revoked => "the capability was revoked - re-open it (nothing happened)",
174 Error::Cancelled => "cancelled",
175 Error::Malformed => "the service sent a reply this library could not parse",
176 Error::BufferTooSmall => "the buffer is too small for the answer",
177 Error::InvalidInput => "the request was not valid and was never sent",
178 }
179 }
180}
181
182/// The filesystem's status byte, as `services/fs` defines it.
183///
184/// Owned HERE rather than copied into every crate that talks to `fs`. Before this library, `FS_OK`
185/// was declared independently in four crates and the shell re-declared 21 of the filesystem's 27
186/// opcodes; a guessed opcode in one of them made a command write nothing for twelve seconds and
187/// report success.
188pub(crate) fn from_fs_status(status: u8) -> Result<(), Error> {
189 match status {
190 0 => Ok(()), // FS_OK
191 1 => Err(Error::Failed), // FS_ERR
192 2 => Err(Error::NotFound), // FS_NOTFOUND
193 3 => Err(Error::NoFilesystem), // FS_NOFS
194 4 => Err(Error::Unavailable), // FS_UNAVAIL
195 5 => Err(Error::PermissionDenied), // FS_DENIED
196 6 => Err(Error::Foreign), // FS_FOREIGN
197 7 => Err(Error::Damaged), // FS_DAMAGED
198 _ => Err(Error::Malformed),
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 /// The distinction the whole library exists to preserve. If this test ever goes green with
207 /// `OutcomeUnknown` in the safe set, a delete has become a double delete somewhere.
208 #[test]
209 fn retry_is_safe_only_when_nothing_happened() {
210 assert!(Error::Unreachable.retry_is_safe(), "the send never left");
211 assert!(Error::Busy.retry_is_safe(), "the queue was full; nothing left either");
212 assert!(!Error::OutcomeUnknown.retry_is_safe(), "IT MAY HAVE COMMITTED");
213 assert!(!Error::PeerDied.retry_is_safe(), "it arrived before the peer died: IT MAY HAVE COMMITTED");
214 assert!(!Error::Cancelled.retry_is_safe(),
215 "cancelling stops this caller listening; it does not un-send the request");
216
217 // A service that answered told us what happened; there is nothing to retry into.
218 for e in [Error::NotFound, Error::PermissionDenied, Error::Failed,
219 Error::NoFilesystem, Error::Unavailable, Error::Foreign, Error::Damaged] {
220 assert!(!e.retry_is_safe(), "{e:?} is an answer, not a lost request");
221 }
222 }
223
224 /// Every status byte `services/fs` can send maps to exactly one error, and an unknown byte is
225 /// reported rather than guessed past.
226 #[test]
227 fn fs_status_bytes_map_exactly() {
228 assert_eq!(from_fs_status(0), Ok(()));
229 assert_eq!(from_fs_status(1), Err(Error::Failed));
230 assert_eq!(from_fs_status(2), Err(Error::NotFound));
231 assert_eq!(from_fs_status(3), Err(Error::NoFilesystem));
232 assert_eq!(from_fs_status(4), Err(Error::Unavailable));
233 assert_eq!(from_fs_status(5), Err(Error::PermissionDenied));
234 assert_eq!(from_fs_status(6), Err(Error::Foreign));
235 assert_eq!(from_fs_status(7), Err(Error::Damaged));
236 // A byte outside the protocol is a malformed reply, NOT a success and NOT a guess.
237 assert_eq!(from_fs_status(8), Err(Error::Malformed));
238 assert_eq!(from_fs_status(255), Err(Error::Malformed));
239 }
240
241 #[test]
242 fn service_answered_separates_no_from_no_answer() {
243 assert!(Error::NotFound.service_answered());
244 assert!(Error::PermissionDenied.service_answered());
245 assert!(!Error::Unreachable.service_answered());
246 assert!(!Error::OutcomeUnknown.service_answered());
247 assert!(!Error::Busy.service_answered());
248 assert!(!Error::Cancelled.service_answered(), "nobody answered - we stopped asking");
249 // A revoked capability is refused by the KERNEL on the generation check, before the
250 // owning service is reached. So nothing answered - and a caller must not read this as
251 // evidence about whether that service is healthy. It very likely is.
252 assert!(!Error::Revoked.service_answered(),
253 "the kernel refused it; the service was never asked");
254 // FALSE, and for the less obvious of the two reasons. Retrying a revoked capability cannot
255 // double-apply anything - the kernel refused it before routing - so it is harmless. It also
256 // cannot ever succeed, and a caller looping on `retry_is_safe` would spin forever. The
257 // question this answers is "should I retry", and the honest answer is no: re-open.
258 assert!(!Error::Revoked.retry_is_safe(),
259 "retrying a revoked capability can never succeed - re-open it instead");
260 }
261
262 /// The phrasing is user-facing, so it is pinned: no trailing full stop, and the one that matters
263 /// most says so loudly.
264 #[test]
265 fn messages_are_house_style() {
266 for e in [Error::NotFound, Error::Unreachable, Error::OutcomeUnknown, Error::Busy,
267 Error::Failed, Error::Malformed, Error::BufferTooSmall, Error::InvalidInput,
268 Error::Cancelled, Error::Revoked] {
269 let s = e.as_str();
270 assert!(!s.is_empty());
271 assert!(!s.ends_with('.'), "{s:?} ends with a full stop");
272 }
273 assert!(Error::OutcomeUnknown.as_str().contains("UNKNOWN"),
274 "the unknown-outcome message must not read like an ordinary failure");
275 }
276}