oxicuda_webgpu/device.rs
1//! WebGPU device wrapper — owns the wgpu instance, adapter, device, and queue.
2
3use std::sync::{
4 Arc, Mutex,
5 atomic::{AtomicBool, Ordering},
6};
7
8use wgpu;
9
10use crate::error::{WebGpuError, WebGpuResult};
11
12/// A fully initialised WebGPU device together with its submit queue.
13///
14/// Created via [`WebGpuDevice::new`] which blocks the calling thread using
15/// [`pollster`] until the async device request completes.
16pub struct WebGpuDevice {
17 /// The wgpu instance used to enumerate adapters.
18 /// Kept alive to ensure the adapter and device remain valid.
19 #[allow(dead_code)]
20 pub(crate) instance: wgpu::Instance,
21 /// The selected GPU adapter.
22 /// Kept alive to ensure the device remains valid.
23 #[allow(dead_code)]
24 pub(crate) adapter: wgpu::Adapter,
25 /// The logical device (command encoder, buffer allocator, …).
26 pub(crate) device: wgpu::Device,
27 /// The queue for submitting command buffers.
28 pub(crate) queue: wgpu::Queue,
29 /// Human-readable adapter name for diagnostics.
30 pub adapter_name: String,
31 /// Whether the `SHADER_F16` feature was successfully enabled on the device.
32 /// Gates the FP16 GEMM path, whose WGSL declares `enable f16;`.
33 pub(crate) supports_f16: bool,
34 /// Effective device limits, resolved from `adapter.limits()` and
35 /// requested verbatim when the device was created (see
36 /// [`WebGpuDevice::new_async`]).
37 ///
38 /// Requesting `wgpu::Limits::default()` (the previous behaviour) silently
39 /// caps every allocation and dispatch at the WebGPU conformance
40 /// *baseline* (e.g. a 256 MiB `max_buffer_size`, a 65535
41 /// workgroups-per-dimension cap) even when the real adapter — Metal, on
42 /// this machine — supports far more. [`crate::memory::WebGpuMemoryManager::alloc`]
43 /// validates against this field instead of the baseline.
44 pub(crate) limits: wgpu::Limits,
45 /// Most recent uncaptured wgpu error, if any.
46 ///
47 /// wgpu's default uncaptured-error handler is fatal to the process — the
48 /// handler installed in [`WebGpuDevice::new_async`] records the message
49 /// here instead. Drained (and cleared) by [`WebGpuDevice::poll_error`].
50 last_error: Arc<Mutex<Option<String>>>,
51 /// Set by the device-lost callback installed in [`WebGpuDevice::new_async`]
52 /// once wgpu reports this device unusable (GPU reset, driver failure, or
53 /// an external `Device::destroy()` call).
54 device_lost: Arc<AtomicBool>,
55}
56
57impl WebGpuDevice {
58 /// Create a WebGPU device by selecting the highest-performance adapter.
59 ///
60 /// Blocks the calling thread until the device is ready.
61 pub fn new() -> WebGpuResult<Self> {
62 pollster::block_on(Self::new_async())
63 }
64
65 async fn new_async() -> WebGpuResult<Self> {
66 // wgpu 29: `InstanceDescriptor` does not impl `Default`; use the
67 // provided constructor instead.
68 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
69
70 let adapter = instance
71 .request_adapter(&wgpu::RequestAdapterOptions {
72 power_preference: wgpu::PowerPreference::HighPerformance,
73 compatible_surface: None,
74 force_fallback_adapter: false,
75 })
76 .await
77 .map_err(|e| {
78 // `RequestAdapterError` is `#[non_exhaustive]`, but every
79 // variant it currently has (`NotFound`, `EnvNotSet`) means
80 // the same thing to a caller: no usable adapter was
81 // obtained. Preserve the detail via `tracing` and report the
82 // typed, matchable `NoAdapter` variant so headless-CI skip
83 // branches (see `webgpu_device_new_graceful` below) are
84 // actually reachable instead of dead code.
85 tracing::warn!("wgpu adapter request failed: {e}");
86 WebGpuError::NoAdapter
87 })?;
88
89 let adapter_info = adapter.get_info();
90 let adapter_name = adapter_info.name.clone();
91
92 // Enable FP16 shader support when the adapter advertises it, so the
93 // `gemm_f16` path (whose WGSL declares `enable f16;`) validates instead
94 // of being rejected for a missing capability. When the adapter lacks
95 // it we simply do not request it, and `gemm_f16` returns a typed
96 // `Unsupported` error rather than emitting an invalid module.
97 let supports_f16 = adapter.features().contains(wgpu::Features::SHADER_F16);
98 let required_features = if supports_f16 {
99 wgpu::Features::SHADER_F16
100 } else {
101 wgpu::Features::empty()
102 };
103
104 // Request the limits the adapter itself reports rather than
105 // `wgpu::Limits::default()` (the WebGPU conformance *baseline* — a
106 // 256 MiB `max_buffer_size`, a 65535 workgroups-per-dimension cap,
107 // etc. — regardless of what the hardware can actually do). Per the
108 // wgpu contract, requesting exactly `adapter.limits()` can never
109 // fail where `Limits::default()` would have succeeded: only
110 // requesting limits *better* than the adapter supports can fail, and
111 // an adapter's own reported limits are always achievable on it.
112 let adapter_limits = adapter.limits();
113
114 // `DeviceDescriptor` does implement `Default` in wgpu-types 29 so we
115 // can use struct-update syntax.
116 let (device, queue) = adapter
117 .request_device(&wgpu::DeviceDescriptor {
118 label: Some("oxicuda-webgpu"),
119 required_features,
120 required_limits: adapter_limits.clone(),
121 memory_hints: wgpu::MemoryHints::default(),
122 ..Default::default()
123 })
124 .await
125 // Name the adapter the device was requested *from*. Which adapter
126 // `request_adapter` hands back is the decisive fact when this
127 // fails, and it is invisible in wgpu's own message: a Linux box
128 // with a GPU but no Vulkan loader (`libvulkan.so.1`) installed
129 // enumerates only wgpu's OpenGL fallback, whose `request_device`
130 // reports the thoroughly unhelpful "Parent device is lost".
131 // Reporting `backend=Gl` alongside it turns that into an
132 // actionable "the Vulkan adapter never appeared".
133 .map_err(|e| {
134 WebGpuError::DeviceRequest(format!(
135 "{e} (adapter: {adapter_name}, backend {:?}, device type {:?})",
136 adapter_info.backend, adapter_info.device_type
137 ))
138 })?;
139
140 // Install a non-fatal uncaptured-error handler. wgpu's default
141 // handler panics/aborts the process on any validation, out-of-memory,
142 // or internal error that is not caught by an explicit error scope —
143 // the CHANGELOG records three separate past point-fixes (write_buffer
144 // overrun, SHADER_F16 probe, copy_htod overrun) that were all
145 // symptoms of this one missing handler. Route the message into a
146 // shared slot instead, so callers can observe it via `poll_error()`
147 // and return a typed `Err` rather than crashing the process.
148 let last_error: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
149 let error_slot = Arc::clone(&last_error);
150 device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| {
151 tracing::error!("wgpu uncaptured error: {e}");
152 if let Ok(mut slot) = error_slot.lock() {
153 *slot = Some(e.to_string());
154 }
155 }));
156
157 // Install a device-lost callback so a GPU reset, driver failure, or
158 // an external `Device::destroy()` call becomes observable via
159 // `is_device_lost()` instead of leaving subsequent operations to fail
160 // later in confusing, un-attributable ways.
161 let device_lost = Arc::new(AtomicBool::new(false));
162 let lost_flag = Arc::clone(&device_lost);
163 device.set_device_lost_callback(move |reason: wgpu::DeviceLostReason, message: String| {
164 tracing::error!("wgpu device lost ({reason:?}): {message}");
165 lost_flag.store(true, Ordering::Release);
166 });
167
168 Ok(Self {
169 instance,
170 adapter,
171 device,
172 queue,
173 adapter_name,
174 supports_f16,
175 limits: adapter_limits,
176 last_error,
177 device_lost,
178 })
179 }
180
181 /// Effective device limits (`max_buffer_size`,
182 /// `max_storage_buffer_binding_size`,
183 /// `max_compute_workgroups_per_dimension`, …), resolved from the adapter
184 /// at device-creation time rather than the WebGPU conformance baseline.
185 pub fn limits(&self) -> &wgpu::Limits {
186 &self.limits
187 }
188
189 /// Drain and return the most recent uncaptured wgpu error recorded since
190 /// the last call, if any.
191 ///
192 /// wgpu delivers validation, out-of-memory, and internal errors that were
193 /// not caught by an explicit error scope through the non-fatal handler
194 /// installed in [`WebGpuDevice::new`] rather than aborting the process.
195 /// Call this immediately after an operation that might have triggered one
196 /// — on the native wgpu-core backends the handler fires synchronously,
197 /// before the triggering call returns — and turn `Some(_)` into a typed
198 /// `Err(WebGpuError::UncapturedError(_))`.
199 pub fn poll_error(&self) -> Option<String> {
200 self.last_error
201 .lock()
202 .ok()
203 .and_then(|mut guard| guard.take())
204 }
205
206 /// Returns `true` once wgpu has reported this device as lost (GPU reset,
207 /// driver failure, or an external `Device::destroy()` call).
208 pub fn is_device_lost(&self) -> bool {
209 self.device_lost.load(Ordering::Acquire)
210 }
211}
212
213impl std::fmt::Debug for WebGpuDevice {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 write!(f, "WebGpuDevice({})", self.adapter_name)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 /// Confirm that WebGpuDevice::new() does not panic — it may return Ok or Err
224 /// depending on whether a GPU is available in the test environment.
225 #[test]
226 fn webgpu_device_new_graceful() {
227 match WebGpuDevice::new() {
228 Ok(dev) => {
229 assert!(!dev.adapter_name.is_empty());
230 // Debug impl should not panic.
231 let _ = format!("{dev:?}");
232 }
233 Err(WebGpuError::NoAdapter) => {
234 // Expected on headless CI without a GPU.
235 }
236 Err(e) => {
237 // Any other error is also acceptable; we just must not panic.
238 let _ = format!("device init error (non-fatal): {e}");
239 }
240 }
241 }
242
243 /// A freshly created device starts with no recorded error and is not lost.
244 #[test]
245 fn poll_error_and_device_lost_start_clean() {
246 let Ok(dev) = WebGpuDevice::new() else {
247 return; // No GPU — skip.
248 };
249 assert!(dev.poll_error().is_none());
250 assert!(!dev.is_device_lost());
251 }
252
253 /// Resolved limits must be at least the WebGPU conformance baseline
254 /// (`adapter.limits()` is defined to be >= the baseline on every
255 /// conformant adapter) and must be what was actually granted to the
256 /// `wgpu::Device` — i.e. `device.limits()` must not silently fall back to
257 /// the baseline internally despite what we requested.
258 #[test]
259 fn limits_are_adapter_derived_not_baseline_default() {
260 let Ok(dev) = WebGpuDevice::new() else {
261 return; // No GPU — skip.
262 };
263 let baseline = wgpu::Limits::default();
264 assert!(dev.limits().max_buffer_size >= baseline.max_buffer_size);
265 assert!(
266 dev.limits().max_storage_buffer_binding_size
267 >= baseline.max_storage_buffer_binding_size
268 );
269 assert_eq!(
270 dev.device.limits().max_buffer_size,
271 dev.limits().max_buffer_size
272 );
273 }
274
275 /// Deliberately requests an absurd buffer size directly against the raw
276 /// `wgpu::Device` (bypassing every higher-level guard in
277 /// `WebGpuMemoryManager`) to prove the handler installed in `new_async`
278 /// catches the resulting validation error instead of letting wgpu's fatal
279 /// default handler abort the process — the whole point of this finding.
280 #[test]
281 fn uncaptured_error_handler_is_non_fatal_and_drains() {
282 let Ok(dev) = WebGpuDevice::new() else {
283 return; // No GPU — skip.
284 };
285 assert!(dev.poll_error().is_none(), "no error recorded yet");
286
287 let _bogus = dev.device.create_buffer(&wgpu::BufferDescriptor {
288 label: Some("oxicuda-webgpu-test-oversize"),
289 size: u64::MAX,
290 usage: wgpu::BufferUsages::STORAGE
291 | wgpu::BufferUsages::COPY_SRC
292 | wgpu::BufferUsages::COPY_DST,
293 mapped_at_creation: false,
294 });
295
296 assert!(
297 dev.poll_error().is_some(),
298 "expected the uncaptured-error handler to record an error instead of aborting"
299 );
300 assert!(
301 dev.poll_error().is_none(),
302 "poll_error() should drain the slot"
303 );
304 }
305
306 /// `Device::destroy()` must reach the `set_device_lost_callback` we
307 /// install, flipping `is_device_lost()`.
308 #[test]
309 fn device_lost_callback_fires_on_destroy() {
310 let Ok(dev) = WebGpuDevice::new() else {
311 return; // No GPU — skip.
312 };
313 assert!(!dev.is_device_lost());
314
315 dev.device.destroy();
316 // `destroy()` only flips the device to invalid; wgpu-core actually
317 // invokes the lost closure from `maintain()`, which runs
318 // synchronously inside `poll()`. Retry briefly for robustness
319 // against timing differences across wgpu backends/versions.
320 for _ in 0..20 {
321 if dev.is_device_lost() {
322 break;
323 }
324 let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
325 std::thread::sleep(std::time::Duration::from_millis(10));
326 }
327 assert!(
328 dev.is_device_lost(),
329 "Device::destroy() should trigger the device-lost callback"
330 );
331 }
332}