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
6/// An error returned by a blocking device call.
7///
8/// When a task panics on a device's runner thread, the panic payload is
9/// captured and carried here instead of being logged and dropped.
10/// A `CallError` without a payload means the runner channel disconnected
11/// before the task could produce a result.
12pub struct CallError {
13 /// The captured panic payload, or `None` if the call failed because the
14 /// runner channel disconnected
15 payload: Option<Box<dyn Any + Send>>,
16}
17
18impl CallError {
19 // Only the `channel` backend (active when `multi_threading` is set) calls these
20 // constructors.
21 /// Builds an error from a panic payload captured by `catch_unwind`.
22 #[cfg_attr(not(multi_threading), allow(dead_code))]
23 pub(crate) fn from_panic(payload: Box<dyn Any + Send>) -> Self {
24 Self {
25 payload: Some(payload),
26 }
27 }
28
29 /// Builds an error for a runner channel that disconnected before producing
30 /// a result (no panic payload available).
31 #[cfg_attr(not(multi_threading), allow(dead_code))]
32 pub(crate) fn disconnected() -> Self {
33 Self { payload: None }
34 }
35
36 /// Returns the panic message if the failure was a panic whose payload is a
37 /// string (the common case for `panic!`, indexing, `unwrap`, ...).
38 pub fn message(&self) -> Option<&str> {
39 let payload = self.payload.as_ref()?;
40 if let Some(s) = payload.downcast_ref::<&'static str>() {
41 Some(*s)
42 } else if let Some(s) = payload.downcast_ref::<String>() {
43 Some(s.as_str())
44 } else {
45 None
46 }
47 }
48
49 /// Consumes the error and returns the captured panic payload, if any.
50 ///
51 /// The payload can be handed to [`std::panic::resume_unwind`] to re-raise
52 /// the original panic on the caller's thread.
53 pub fn into_panic(self) -> Option<Box<dyn Any + Send>> {
54 self.payload
55 }
56
57 /// Re-raises the failure on the current thread, diverging.
58 #[track_caller]
59 pub fn resume(self) -> ! {
60 match self.into_panic() {
61 #[cfg(feature = "std")]
62 Some(payload) => std::panic::resume_unwind(payload),
63 // std::panic::resume_unwind doesn't exist in a no_std build
64 #[cfg(not(feature = "std"))]
65 Some(_payload) => {
66 panic!("a device task panicked but its payload cannot be re-raised without `std`")
67 }
68 None => panic!("device runner channel disconnected before producing a result"),
69 }
70 }
71}
72
73/// Extension trait for re-raising a [`CallError`] on the caller's thread.
74pub trait CallResultExt<R> {
75 /// Returns the success value, or re-raises the original panic captured in the
76 /// [`CallError`] via [`CallError::resume`].
77 ///
78 /// Use this instead of [`Result::unwrap`] to panics with the original payload itself.
79 fn unwrap_or_resume(self) -> R;
80}
81
82impl<R> CallResultExt<R> for Result<R, CallError> {
83 #[track_caller]
84 fn unwrap_or_resume(self) -> R {
85 match self {
86 Ok(value) => value,
87 Err(err) => err.resume(),
88 }
89 }
90}
91
92impl core::fmt::Debug for CallError {
93 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
94 match self.message() {
95 Some(message) => write!(
96 f,
97 "CallError(task panicked on device runner thread: {message})"
98 ),
99 None if self.payload.is_some() => f.write_str(
100 "CallError(task panicked on device runner thread with a non-string payload)",
101 ),
102 None => f.write_str(
103 "CallError(device runner channel disconnected before producing a result)",
104 ),
105 }
106 }
107}
108
109#[derive(new, Clone, Debug)]
110/// Error when creating a [`DeviceService`].
111pub struct ServiceCreationError {
112 #[allow(dead_code)] // Debug uses it.
113 reason: alloc::string::String,
114}
115
116pub(crate) trait DeviceHandleSpec<S: DeviceService>: Sized {
117 /// If functions block the current thread even if they are non-blocking.
118 const BLOCKING: bool;
119
120 /// Creates or retrieves a context for the given device ID.
121 ///
122 /// If a runner thread for this `device_id` does not exist, it will be spawned.
123 fn insert(device_id: DeviceId, service: S) -> Result<Self, ServiceCreationError>;
124
125 /// Creates or retrieves a context for the given device ID.
126 ///
127 /// If a runner thread for this `device_id` does not exist, it will be spawned.
128 fn new(device_id: DeviceId) -> Self;
129
130 /// Retrieves the device ID for this handle.
131 fn device_id(&self) -> DeviceId;
132
133 /// Retrieves the server utilities for this thread.
134 fn utilities(&self) -> ServerUtilitiesHandle;
135
136 /// Doesn't flush the service state, but flushes any task enqueued in the communication
137 /// channel.
138 ///
139 /// # Notes
140 ///
141 /// This is often not necessary, except for distributed operations.
142 fn flush_queue(&self);
143
144 /// Executes a task on the dedicated device thread and returns the result of the task.
145 ///
146 /// # Notes
147 ///
148 /// Prefer using [`Self::submit`] if you don't need to wait for a returned type.
149 fn submit_blocking<'a, R: Send, T: FnOnce(&mut S) -> R + Send + 'a>(
150 &self,
151 task: T,
152 ) -> Result<R, CallError>;
153
154 /// Submit a task for execution on the dedicated device thread.
155 fn submit<T: FnOnce(&mut S) + Send + 'static>(&self, task: T);
156
157 /// TODO: Docs.
158 fn exclusive<R: Send, T: FnOnce() -> R + Send>(&self, task: T) -> Result<R, CallError>;
159
160 /// Stops any background runner this implementation keeps for `device_id`,
161 /// blocking until it exits. Queued tasks run before the runner stops, and
162 /// live handles keep it alive, so all handles for the device should be
163 /// dropped first. No-op for implementations without background threads.
164 ///
165 /// The scope is the whole device, not the service `S`: every service running on
166 /// `device_id`, across every stage, goes down with it.
167 fn shutdown(device_id: DeviceId) {
168 let _ = device_id;
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::CallError;
175 use alloc::boxed::Box;
176 use core::any::Any;
177
178 /// D.1 — A disconnected error carries neither a message nor a payload, which is
179 /// what distinguishes it from a non-string panic (both have `message() == None`).
180 #[test]
181 fn test_disconnected_has_no_message_or_payload() {
182 let err = CallError::disconnected();
183 assert_eq!(err.message(), None);
184 assert!(err.into_panic().is_none());
185 }
186
187 /// D.2 — Debug of a string payload includes the panic message.
188 #[test]
189 fn test_debug_string_payload_includes_message() {
190 let err = CallError::from_panic(Box::new("oops") as Box<dyn Any + Send>);
191 let debug = alloc::format!("{err:?}");
192 assert!(debug.contains("task panicked on device runner thread"));
193 assert!(debug.contains("oops"));
194 }
195
196 /// D.3 — Debug of a non-string payload reports it as such.
197 #[test]
198 fn test_debug_non_string_payload() {
199 let err = CallError::from_panic(Box::new(123u8) as Box<dyn Any + Send>);
200 let debug = alloc::format!("{err:?}");
201 assert!(debug.contains("non-string payload"), "got: {debug}");
202 }
203
204 /// D.4 — Debug of a disconnected error mentions the disconnection.
205 #[test]
206 fn test_debug_disconnected() {
207 let err = CallError::disconnected();
208 let debug = alloc::format!("{err:?}");
209 assert!(debug.contains("disconnected"), "got: {debug}");
210 }
211}