Skip to main content

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}