Skip to main content

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}