Skip to main content

crcbl_shell/
error.rs

1//! Errors crossing the shell seam.
2//!
3//! One enum, unlike `crcbl-hal`'s two. The HAL splits `SurfaceError` out
4//! because `OutOfDate` is *routine* traffic that the frame loop must handle
5//! every resize; the shell has no equivalent — nothing here happens on a normal
6//! frame. Every variant below means a caller made a mistake, or the window
7//! system went away.
8//!
9//! No platform type appears in any variant. Where a backend has detail worth
10//! keeping — a compositor's protocol error string, an X11 error code's name —
11//! it goes into a `String`, for the same reason `crcbl-hal` does it: the
12//! alternative is losing it or leaking `xcb_generic_error_t` into every caller.
13
14use crate::{ShellBackend, WindowId};
15
16/// A shell operation failed.
17#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum ShellError {
20    /// No backend could be opened.
21    ///
22    /// `tried` lists the backends that were attempted in order, so the log line
23    /// distinguishes "there is no `WAYLAND_DISPLAY` and no `DISPLAY`" from "the
24    /// Wayland backend is not compiled into this build". See
25    /// [`open`](crate::open).
26    #[error("no shell backend available (tried: {tried})")]
27    NoBackend {
28        /// Comma-separated backend names, in the order they were attempted.
29        tried: String,
30    },
31
32    /// `CRCBL_SHELL` named a backend this build does not have.
33    ///
34    /// A hard error rather than a fallback: someone who set the variable wanted
35    /// that backend, and silently running on a different one turns a debugging
36    /// session into a wild goose chase.
37    #[error("CRCBL_SHELL={requested:?} is not a backend in this build (have: {available})")]
38    UnknownBackend {
39        /// The value of `CRCBL_SHELL`.
40        requested: String,
41        /// Comma-separated names of the backends that are compiled in.
42        available: String,
43    },
44
45    /// Connecting to the window system failed — no compositor socket, a refused
46    /// X11 connection, a browser with no canvas.
47    #[error("cannot connect to the {backend} display server: {detail}")]
48    Connect {
49        /// Backend that failed to connect.
50        backend: ShellBackend,
51        /// What went wrong, verbatim from the backend.
52        detail: String,
53    },
54
55    /// The connection to the window system was lost mid-session. Nothing the
56    /// shell owns is usable afterwards.
57    ///
58    /// Distinct from [`ShellError::Connect`] because the recovery differs: a
59    /// failed connect is a startup problem a user can fix, and a lost
60    /// connection means the compositor died or the session ended, which is a
61    /// clean-shutdown signal.
62    #[error("{backend} display server connection lost: {detail}")]
63    Disconnected {
64        /// Backend whose connection dropped.
65        backend: ShellBackend,
66        /// What the backend reported.
67        detail: String,
68    },
69
70    /// A [`WindowId`] did not resolve: it was destroyed, never created, or came
71    /// from a different shell.
72    ///
73    /// The generational [`Handle`](crcbl_core::Handle) behind `WindowId` makes
74    /// this a clean failure rather than an operation applied to whatever window
75    /// took the slot — the same argument as `crcbl-hal`'s `InvalidHandle`.
76    #[error("invalid or stale window handle {bits:#x}")]
77    InvalidWindow {
78        /// The handle's packed bits, from
79        /// [`Handle::to_bits`](crcbl_core::Handle::to_bits).
80        bits: u64,
81    },
82
83    /// A [`MonitorId`](crate::MonitorId) named a monitor that is not connected —
84    /// usually one that was unplugged between
85    /// [`monitors`](crate::Shell::monitors) and the call that used its id.
86    #[error("no such monitor: {0}")]
87    NoSuchMonitor(u32),
88
89    /// Creating the window failed.
90    #[error("window creation failed: {0}")]
91    WindowCreation(String),
92
93    /// A descriptor field was out of range or self-inconsistent — a caller bug,
94    /// which is why the message is expected to name the field.
95    #[error("invalid descriptor: {0}")]
96    InvalidDescriptor(String),
97
98    /// The reply named a window with no close request outstanding.
99    ///
100    /// An error rather than a no-op because it is always a state-machine bug in
101    /// the caller: the reply either arrived twice or was addressed to the wrong
102    /// window, and both silently leave a real close request unanswered.
103    #[error("window {window:?} has no outstanding close request")]
104    NoPendingCloseRequest {
105        /// The window the reply named.
106        window: WindowId,
107    },
108
109    /// The operation is real but this backend cannot do it.
110    ///
111    /// Always paired with a [`ShellCaps`](crate::ShellCaps) flag: a caller that
112    /// checks caps first never sees this, and a caller that does not gets an
113    /// error naming the capability it should have checked. Getting this back is
114    /// the signal that a consumer is missing a caps branch — which is exactly
115    /// the platform-sniffing regression `ShellCaps` exists to prevent.
116    #[error("unsupported by the {backend} shell backend: {what}")]
117    Unsupported {
118        /// Backend that refused.
119        backend: ShellBackend,
120        /// What was asked for.
121        what: &'static str,
122    },
123
124    /// Anything else the backend wants to report verbatim.
125    #[error("{0}")]
126    Backend(String),
127
128    /// The window system requires a recent user interaction for this, and there
129    /// has not been one.
130    ///
131    /// # Why this is a named case rather than a backend string
132    ///
133    /// It is a **policy**, not a fault, and the policy is the same one on three
134    /// platforms:
135    ///
136    /// * Wayland's `wl_data_device.set_selection` quotes the serial of the
137    ///   input event that caused the copy, and a compositor checks it. A client
138    ///   that has received no input on a seat cannot take the clipboard.
139    /// * A browser gates `navigator.clipboard.write()` and Pointer Lock behind
140    ///   a *user gesture*, with the same reasoning and the same failure.
141    /// * X11 has no such requirement at all, and neither does Win32.
142    ///
143    /// What it prevents is a background process silently taking the clipboard —
144    /// so a caller must treat it as "ask the user to do that again", never as a
145    /// bug to work around. A backend on a platform without the rule **never
146    /// returns this**, and that is obviously correct rather than obviously
147    /// incomplete.
148    #[error("{backend} needs a recent user interaction for {what}")]
149    NeedsUserInteraction {
150        /// Which backend refused.
151        backend: ShellBackend,
152        /// What was being attempted, for the log line.
153        what: &'static str,
154    },
155}
156
157impl ShellError {
158    /// Builds a [`ShellError::InvalidWindow`] from a window handle.
159    ///
160    /// Backends call this from their handle-lookup path so the error carries the
161    /// same bits that appear in `WindowId`'s `Debug` output.
162    #[must_use]
163    pub fn invalid_window(window: WindowId) -> Self {
164        Self::InvalidWindow {
165            bits: window.to_bits(),
166        }
167    }
168
169    /// Whether the error means the window system is gone and the process should
170    /// shut down rather than retry.
171    #[must_use]
172    pub fn is_fatal(&self) -> bool {
173        matches!(self, Self::Disconnected { .. })
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crcbl_core::Pool;
181
182    #[test]
183    fn messages_name_the_specific_problem() {
184        let error = ShellError::Unsupported {
185            backend: ShellBackend::Wayland,
186            what: "pointer warp",
187        };
188        assert_eq!(
189            error.to_string(),
190            "unsupported by the wayland shell backend: pointer warp"
191        );
192        assert!(!error.is_fatal());
193
194        let error = ShellError::NoBackend {
195            tried: "wayland, x11".to_string(),
196        };
197        assert!(error.to_string().contains("wayland, x11"));
198
199        assert!(
200            ShellError::Disconnected {
201                backend: ShellBackend::X11,
202                detail: "server closed the connection".to_string(),
203            }
204            .is_fatal()
205        );
206    }
207
208    #[test]
209    fn invalid_window_carries_the_handle_bits() {
210        let mut pool: Pool<u8> = Pool::new();
211        let window: WindowId = pool.insert(0).cast();
212        let ShellError::InvalidWindow { bits } = ShellError::invalid_window(window) else {
213            panic!("wrong variant");
214        };
215        assert_eq!(bits, window.to_bits());
216        assert_ne!(bits, 0, "packed handle bits are never zero");
217    }
218}