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