Skip to main content

cubecl_common/device/handle/
base.rs

1use crate::device::{DeviceId, DeviceService, ServerUtilitiesHandle};
2use alloc::boxed::Box;
3use alloc::string::String;
4use core::any::Any;
5#[cfg(feature = "std")]
6use cubecl_environment::future::channel::oneshot;
7
8/// An error returned by a blocking device call.
9///
10/// When a task panics on a device's runner thread, the panic payload is
11/// captured and carried here instead of being logged and dropped.
12pub struct CallError {
13    failure: Failure,
14}
15
16/// Why a device call produced no result.
17enum Failure {
18    /// The task panicked; the payload is the one `catch_unwind` captured.
19    Panicked(Box<dyn Any + Send>),
20    /// The service was closed ([`DeviceHandle::close`](super::DeviceHandle::close)) before
21    /// the task reached it.
22    Closed,
23    /// The runner channel disconnected before the task could produce a result.
24    Disconnected,
25}
26
27impl CallError {
28    // Only the `channel` backend (active when `multi_threading` is set) produces these
29    // errors.
30    /// Builds an error from a panic payload captured by `catch_unwind`.
31    #[cfg_attr(not(multi_threading), allow(dead_code))]
32    pub(crate) fn from_panic(payload: Box<dyn Any + Send>) -> Self {
33        Self {
34            failure: Failure::Panicked(payload),
35        }
36    }
37
38    /// Builds an error for a runner channel that disconnected before producing
39    /// a result (no panic payload available).
40    #[cfg_attr(not(multi_threading), allow(dead_code))]
41    pub(crate) fn disconnected() -> Self {
42        Self {
43            failure: Failure::Disconnected,
44        }
45    }
46
47    /// Builds an error for a task that reached a closed service.
48    #[cfg_attr(not(multi_threading), allow(dead_code))]
49    pub(crate) fn closed() -> Self {
50        Self {
51            failure: Failure::Closed,
52        }
53    }
54
55    /// Whether the service was closed before the task reached it, which only happens as the
56    /// process exits: the call is refused rather than run on a runtime being torn down.
57    pub fn is_closed(&self) -> bool {
58        matches!(self.failure, Failure::Closed)
59    }
60
61    /// Returns the panic message if the failure was a panic whose payload is a
62    /// string (the common case for `panic!`, indexing, `unwrap`, ...).
63    pub fn message(&self) -> Option<&str> {
64        let Failure::Panicked(payload) = &self.failure else {
65            return None;
66        };
67        if let Some(s) = payload.downcast_ref::<&'static str>() {
68            Some(*s)
69        } else if let Some(s) = payload.downcast_ref::<String>() {
70            Some(s.as_str())
71        } else {
72            None
73        }
74    }
75
76    /// Consumes the error and returns the captured panic payload, if any.
77    ///
78    /// The payload can be handed to [`std::panic::resume_unwind`] to re-raise
79    /// the original panic on the caller's thread.
80    pub fn into_panic(self) -> Option<Box<dyn Any + Send>> {
81        match self.failure {
82            Failure::Panicked(payload) => Some(payload),
83            Failure::Closed | Failure::Disconnected => None,
84        }
85    }
86
87    /// Re-raises the failure on the current thread, diverging.
88    #[track_caller]
89    pub fn resume(self) -> ! {
90        match self.failure {
91            #[cfg(feature = "std")]
92            Failure::Panicked(payload) => std::panic::resume_unwind(payload),
93            // std::panic::resume_unwind doesn't exist in a no_std build
94            #[cfg(not(feature = "std"))]
95            Failure::Panicked(_payload) => {
96                panic!("a device task panicked but its payload cannot be re-raised without `std`")
97            }
98            Failure::Closed => panic!("the device service was closed, as the process exits"),
99            Failure::Disconnected => {
100                panic!("device runner channel disconnected before producing a result")
101            }
102        }
103    }
104}
105
106/// Extension trait for re-raising a [`CallError`] on the caller's thread.
107pub trait CallResultExt<R> {
108    /// Returns the success value, or re-raises the original panic captured in the
109    /// [`CallError`] via [`CallError::resume`].
110    ///
111    /// Use this instead of [`Result::unwrap`] to panics with the original payload itself.
112    fn unwrap_or_resume(self) -> R;
113}
114
115impl<R> CallResultExt<R> for Result<R, CallError> {
116    #[track_caller]
117    fn unwrap_or_resume(self) -> R {
118        match self {
119            Ok(value) => value,
120            Err(err) => err.resume(),
121        }
122    }
123}
124
125/// A call [spawned](super::DeviceHandle::spawn) on a device that may still be running.
126#[must_use = "the call's result is lost unless it is joined"]
127pub struct SpawnedCall<R> {
128    state: SpawnedState<R>,
129}
130
131enum SpawnedState<R> {
132    /// The result is already known.
133    Done(Result<R, CallError>),
134    #[cfg(feature = "std")]
135    Running(oneshot::Receiver<Result<R, CallError>>),
136}
137
138impl<R> SpawnedCall<R> {
139    pub(crate) fn done(result: Result<R, CallError>) -> Self {
140        Self {
141            state: SpawnedState::Done(result),
142        }
143    }
144
145    #[cfg(feature = "std")]
146    pub(crate) fn running(receiver: oneshot::Receiver<Result<R, CallError>>) -> Self {
147        Self {
148            state: SpawnedState::Running(receiver),
149        }
150    }
151
152    /// Blocks until the call returns, failing as
153    /// [`submit_blocking`](super::DeviceHandle::submit_blocking) does.
154    pub fn join(self) -> Result<R, CallError> {
155        match self.state {
156            SpawnedState::Done(result) => result,
157            #[cfg(feature = "std")]
158            SpawnedState::Running(receiver) => receiver
159                .recv()
160                .unwrap_or_else(|_| Err(CallError::disconnected())),
161        }
162    }
163}
164
165impl core::fmt::Debug for CallError {
166    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
167        match (&self.failure, self.message()) {
168            (_, Some(message)) => write!(
169                f,
170                "CallError(task panicked on device runner thread: {message})"
171            ),
172            (Failure::Panicked(_), None) => f.write_str(
173                "CallError(task panicked on device runner thread with a non-string payload)",
174            ),
175            (Failure::Closed, None) => {
176                f.write_str("CallError(the device service was closed, as the process exits)")
177            }
178            (Failure::Disconnected, None) => f.write_str(
179                "CallError(device runner channel disconnected before producing a result)",
180            ),
181        }
182    }
183}
184
185#[derive(new, Clone, Debug)]
186/// Error when creating a [`DeviceService`].
187pub struct ServiceCreationError {
188    #[allow(dead_code)] // Debug uses it.
189    reason: alloc::string::String,
190}
191
192#[derive(new, Clone, Debug)]
193/// Error when closing a [`DeviceService`]: the service may still be serving.
194pub struct ServiceCloseError {
195    #[allow(dead_code)] // Debug uses it.
196    reason: alloc::string::String,
197}
198
199/// How a service's state is reached: the transport under a [`DeviceHandle`].
200///
201/// A transport holds no service type. It hands a task the boxed state as
202/// `&mut dyn Any`; the handle above it knows what that is.
203pub trait DeviceHandleSpec: Sized + Clone {
204    /// If functions block the current thread even if they are non-blocking.
205    const BLOCKING: bool;
206
207    /// Registers `service` for the device and returns a transport to it.
208    ///
209    /// If a runner thread for this `device_id` does not exist, it will be spawned.
210    fn insert<S: DeviceService>(
211        device_id: DeviceId,
212        service: S,
213    ) -> Result<Self, ServiceCreationError>;
214
215    /// Creates or retrieves a transport for the given device ID.
216    ///
217    /// If a runner thread for this `device_id` does not exist, it will be spawned.
218    fn new<S: DeviceService>(device_id: DeviceId) -> Self;
219
220    /// Retrieves the device ID for this handle.
221    fn device_id(&self) -> DeviceId;
222
223    /// Retrieves the server utilities for this thread.
224    fn utilities(&self) -> ServerUtilitiesHandle;
225
226    /// Doesn't flush the service state, but flushes any task enqueued in the communication
227    /// channel.
228    ///
229    /// # Notes
230    ///
231    /// This is a no-op for blocking handles.
232    fn flush_queue(&self);
233
234    fn submit_blocking<'a, R: Send, T: FnOnce(&mut dyn Any) -> R + Send + 'a>(
235        &self,
236        task: T,
237    ) -> Result<R, CallError>;
238
239    fn submit<T: FnOnce(&mut dyn Any) + Send + 'static>(&self, task: T);
240
241    /// Runs `task` without waiting for it to return.
242    ///
243    /// A transport whose calls run on the caller's thread has run it by the time this returns.
244    fn spawn<R: Send + 'static, T: FnOnce(&mut dyn Any) -> R + Send + 'static>(
245        &self,
246        task: T,
247    ) -> SpawnedCall<R>;
248
249    fn exclusive<R: Send, T: FnOnce() -> R + Send>(&self, task: T) -> Result<R, CallError>;
250
251    fn shutdown(device_id: DeviceId) {
252        let _ = device_id;
253    }
254
255    /// Stops serving `S` on `device_id` though handles to it remain, dropping it where it
256    /// lives; later calls to it answer [`CallError::is_closed`], and it is not started again.
257    ///
258    /// A transport whose calls run on the caller's thread has no runner to close it on, and
259    /// keeps serving: only the channel transport serves a native runtime.
260    fn close<S: DeviceService>(device_id: DeviceId) -> Result<(), ServiceCloseError> {
261        let _ = device_id;
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::CallError;
269    use alloc::boxed::Box;
270    use core::any::Any;
271
272    /// D.1 — A disconnected error carries neither a message nor a payload, which is
273    /// what distinguishes it from a non-string panic (both have `message() == None`).
274    #[test]
275    fn test_disconnected_has_no_message_or_payload() {
276        let err = CallError::disconnected();
277        assert_eq!(err.message(), None);
278        assert!(err.into_panic().is_none());
279    }
280
281    /// A closed service is not a panic and not a dead runner: callers map exactly this case to
282    /// an error, and re-raise the other two.
283    #[test]
284    fn test_closed_is_neither_a_panic_nor_a_disconnect() {
285        let err = CallError::closed();
286        assert!(err.is_closed());
287        assert!(!CallError::disconnected().is_closed());
288        assert!(alloc::format!("{err:?}").contains("closed"));
289        assert!(err.into_panic().is_none());
290    }
291
292    /// D.2 — Debug of a string payload includes the panic message.
293    #[test]
294    fn test_debug_string_payload_includes_message() {
295        let err = CallError::from_panic(Box::new("oops") as Box<dyn Any + Send>);
296        let debug = alloc::format!("{err:?}");
297        assert!(debug.contains("task panicked on device runner thread"));
298        assert!(debug.contains("oops"));
299    }
300
301    /// D.3 — Debug of a non-string payload reports it as such.
302    #[test]
303    fn test_debug_non_string_payload() {
304        let err = CallError::from_panic(Box::new(123u8) as Box<dyn Any + Send>);
305        let debug = alloc::format!("{err:?}");
306        assert!(debug.contains("non-string payload"), "got: {debug}");
307    }
308
309    /// D.4 — Debug of a disconnected error mentions the disconnection.
310    #[test]
311    fn test_debug_disconnected() {
312        let err = CallError::disconnected();
313        let debug = alloc::format!("{err:?}");
314        assert!(debug.contains("disconnected"), "got: {debug}");
315    }
316}