1use std::collections::VecDeque;
2use std::fmt;
3use std::sync::{Arc, Mutex, PoisonError};
4
5#[derive(Copy, Clone, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum GpuErrorKind {
11 OutOfMemory,
13 Validation,
15 Internal,
17}
18
19impl GpuErrorKind {
20 #[must_use]
22 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
23 pub const fn of(error: &wgpu::Error) -> Self {
24 match error {
25 wgpu::Error::OutOfMemory { .. } => Self::OutOfMemory,
26 wgpu::Error::Validation { .. } => Self::Validation,
27 wgpu::Error::Internal { .. } => Self::Internal,
28 }
29 }
30
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::OutOfMemory => "out-of-memory",
36 Self::Validation => "validation",
37 Self::Internal => "internal",
38 }
39 }
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
48#[non_exhaustive]
49pub enum GpuDiagnostic {
50 UncapturedError {
52 kind: GpuErrorKind,
54 message: String,
56 },
57 DeviceLost {
59 reason: String,
61 message: String,
63 },
64}
65
66impl GpuDiagnostic {
67 #[must_use]
73 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
74 pub fn uncaptured(error: &wgpu::Error) -> Self {
75 let mut message = error.to_string();
76 if matches!(error, wgpu::Error::OutOfMemory { .. }) {
77 let mut source = std::error::Error::source(error);
78 while let Some(cause) = source {
79 let text = cause.to_string();
80 if !text.is_empty() && text != "<WebGPU Error>" {
81 message.push_str(": ");
82 message.push_str(&text);
83 }
84 source = cause.source();
85 }
86 }
87 Self::UncapturedError {
88 kind: GpuErrorKind::of(error),
89 message,
90 }
91 }
92
93 #[must_use]
96 pub const fn failure_code(&self) -> &'static str {
97 match self {
98 Self::UncapturedError {
99 kind: GpuErrorKind::OutOfMemory,
100 ..
101 } => "OUT_OF_MEMORY",
102 Self::UncapturedError {
103 kind: GpuErrorKind::Validation,
104 ..
105 } => "VALIDATION_FAILED",
106 Self::UncapturedError {
107 kind: GpuErrorKind::Internal,
108 ..
109 } => "UNKNOWN",
110 Self::DeviceLost { .. } => "DEVICE_LOST",
111 }
112 }
113
114 #[must_use]
116 pub const fn event(&self) -> &'static str {
117 match self {
118 Self::UncapturedError { .. } => "uncaptured-error",
119 Self::DeviceLost { .. } => "device-lost",
120 }
121 }
122
123 fn truncated(self) -> Self {
124 match self {
125 Self::UncapturedError { kind, message } => Self::UncapturedError {
126 kind,
127 message: truncate(message),
128 },
129 Self::DeviceLost { reason, message } => Self::DeviceLost {
130 reason: truncate(reason),
131 message: truncate(message),
132 },
133 }
134 }
135}
136
137impl fmt::Display for GpuDiagnostic {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 Self::UncapturedError { kind, message } => {
141 write!(
142 formatter,
143 "uncaptured GPU {} error: {message}",
144 kind.as_str()
145 )
146 }
147 Self::DeviceLost { reason, message } => {
148 write!(formatter, "GPU device lost ({reason}): {message}")
149 }
150 }
151 }
152}
153
154pub const MAX_DIAGNOSTIC_MESSAGE_BYTES: usize = 1_024;
157
158const ELLIPSIS: &str = "...";
159
160fn truncate(mut message: String) -> String {
163 if message.len() <= MAX_DIAGNOSTIC_MESSAGE_BYTES {
164 message.shrink_to_fit();
165 return message;
166 }
167 let mut end = MAX_DIAGNOSTIC_MESSAGE_BYTES;
168 while !message.is_char_boundary(end) {
169 end -= 1;
170 }
171 let mut kept = String::with_capacity(end + ELLIPSIS.len());
172 kept.push_str(&message[..end]);
173 kept.push_str(ELLIPSIS);
174 kept
175}
176
177#[derive(Debug)]
183pub struct GpuDiagnosticLog {
184 state: Mutex<LogState>,
185 capacity: usize,
186}
187
188#[derive(Debug, Default)]
189struct LogState {
190 entries: VecDeque<GpuDiagnostic>,
191 dropped: u64,
192 device_lost: bool,
193 out_of_memory: bool,
194}
195
196#[derive(Clone, Debug, Default, Eq, PartialEq)]
198pub struct GpuDiagnosticSnapshot {
199 pub entries: Vec<GpuDiagnostic>,
201 pub dropped: u64,
203 pub device_lost: bool,
205 pub out_of_memory: bool,
209}
210
211impl GpuDiagnosticSnapshot {
212 #[must_use]
215 pub fn failure_code(&self) -> Option<&'static str> {
216 if self.device_lost {
217 return Some("DEVICE_LOST");
218 }
219 if self.out_of_memory {
220 return Some("OUT_OF_MEMORY");
221 }
222 self.entries
223 .iter()
224 .any(|entry| entry.failure_code() == "VALIDATION_FAILED")
225 .then_some("VALIDATION_FAILED")
226 }
227}
228
229impl GpuDiagnosticLog {
230 pub const DEFAULT_CAPACITY: usize = 16;
232
233 #[must_use]
235 pub fn new(capacity: usize) -> Self {
236 Self {
237 state: Mutex::new(LogState::default()),
238 capacity: capacity.max(1),
239 }
240 }
241
242 pub fn push(&self, diagnostic: GpuDiagnostic) {
244 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
245 state.device_lost |= matches!(diagnostic, GpuDiagnostic::DeviceLost { .. });
246 state.out_of_memory |= diagnostic.failure_code() == "OUT_OF_MEMORY";
247 if state.entries.len() == self.capacity {
248 state.entries.pop_front();
249 state.dropped += 1;
250 }
251 state.entries.push_back(diagnostic.truncated());
252 }
253
254 #[must_use]
256 pub fn snapshot(&self) -> GpuDiagnosticSnapshot {
257 let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
258 GpuDiagnosticSnapshot {
259 entries: state.entries.iter().cloned().collect(),
260 dropped: state.dropped,
261 device_lost: state.device_lost,
262 out_of_memory: state.out_of_memory,
263 }
264 }
265
266 pub fn clear(&self) {
268 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
269 state.entries.clear();
270 state.dropped = 0;
271 state.out_of_memory = false;
272 }
273
274 #[must_use]
276 pub fn device_lost(&self) -> bool {
277 self.state
278 .lock()
279 .unwrap_or_else(PoisonError::into_inner)
280 .device_lost
281 }
282}
283
284impl Default for GpuDiagnosticLog {
285 fn default() -> Self {
286 Self::new(Self::DEFAULT_CAPACITY)
287 }
288}
289
290pub type GpuDiagnosticHandler = Arc<dyn Fn(GpuDiagnostic) + Send + Sync + 'static>;
294
295pub(crate) struct DiagnosticHook {
297 handler: Mutex<Option<GpuDiagnosticHandler>>,
298 strict: bool,
299}
300
301fn strict_by_default() -> bool {
304 cfg!(debug_assertions)
305 || std::env::var_os("ALPHA2_GPU_STRICT").is_some_and(|value| value == "1")
306}
307
308impl DiagnosticHook {
309 pub(crate) fn new() -> Self {
310 Self::with_strict(strict_by_default())
311 }
312
313 pub(crate) fn with_strict(strict: bool) -> Self {
314 Self {
315 handler: Mutex::new(None),
316 strict,
317 }
318 }
319
320 pub(crate) fn set(&self, handler: Option<GpuDiagnosticHandler>) {
321 *self.handler.lock().unwrap_or_else(PoisonError::into_inner) = handler;
322 }
323
324 #[cfg(not(target_arch = "wasm32"))]
329 pub(crate) fn emit(&self, diagnostic: GpuDiagnostic) {
330 let handler = self
331 .handler
332 .lock()
333 .unwrap_or_else(PoisonError::into_inner)
334 .clone();
335 if let Some(handler) = handler {
336 handler(diagnostic);
337 } else if self.strict && matches!(diagnostic, GpuDiagnostic::UncapturedError { .. }) {
338 panic!(
339 "{diagnostic}\n(strict GPU diagnostics: no handler is installed; capture the error with an error scope or install one with GpuContext::set_diagnostic_handler)"
340 );
341 }
342 }
343}
344
345impl fmt::Debug for DiagnosticHook {
346 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347 let installed = self
348 .handler
349 .lock()
350 .unwrap_or_else(PoisonError::into_inner)
351 .is_some();
352 formatter
353 .debug_struct("DiagnosticHook")
354 .field("installed", &installed)
355 .field("strict", &self.strict)
356 .finish()
357 }
358}
359
360#[cfg(all(test, not(target_arch = "wasm32")))]
361mod tests {
362 use super::*;
363 use std::sync::atomic::{AtomicUsize, Ordering};
364
365 #[test]
366 fn an_uninstalled_lenient_hook_drops_diagnostics() {
367 let hook = DiagnosticHook::with_strict(false);
368 hook.emit(GpuDiagnostic::UncapturedError {
369 kind: GpuErrorKind::Internal,
370 message: "ignored".to_owned(),
371 });
372 }
373
374 #[test]
375 #[should_panic(expected = "uncaptured GPU validation error: unseen")]
376 fn an_uninstalled_strict_hook_panics_on_an_uncaptured_error() {
377 DiagnosticHook::with_strict(true).emit(GpuDiagnostic::UncapturedError {
378 kind: GpuErrorKind::Validation,
379 message: "unseen".to_owned(),
380 });
381 }
382
383 #[test]
384 fn a_strict_hook_drops_device_loss_and_defers_to_a_handler() {
385 let hook = DiagnosticHook::with_strict(true);
386 hook.emit(GpuDiagnostic::DeviceLost {
387 reason: "Destroyed".to_owned(),
388 message: "device dropped".to_owned(),
389 });
390 let count = Arc::new(AtomicUsize::new(0));
391 let counted = Arc::clone(&count);
392 hook.set(Some(Arc::new(move |_| {
393 counted.fetch_add(1, Ordering::Release);
394 })));
395 hook.emit(GpuDiagnostic::UncapturedError {
396 kind: GpuErrorKind::Validation,
397 message: "handled".to_owned(),
398 });
399 assert_eq!(count.load(Ordering::Acquire), 1);
400 }
401
402 #[test]
403 fn the_installed_handler_receives_every_diagnostic() {
404 let hook = DiagnosticHook::new();
405 let seen = Arc::new(Mutex::new(Vec::new()));
406 let recorded = Arc::clone(&seen);
407 hook.set(Some(Arc::new(move |diagnostic: GpuDiagnostic| {
408 recorded
409 .lock()
410 .unwrap_or_else(PoisonError::into_inner)
411 .push(diagnostic.to_string());
412 })));
413 hook.emit(GpuDiagnostic::UncapturedError {
414 kind: GpuErrorKind::Validation,
415 message: "validation".to_owned(),
416 });
417 hook.emit(GpuDiagnostic::DeviceLost {
418 reason: "Destroyed".to_owned(),
419 message: "device dropped".to_owned(),
420 });
421 assert_eq!(
422 *seen.lock().unwrap_or_else(PoisonError::into_inner),
423 vec![
424 "uncaptured GPU validation error: validation".to_owned(),
425 "GPU device lost (Destroyed): device dropped".to_owned(),
426 ]
427 );
428 }
429
430 #[test]
431 fn clearing_the_handler_stops_delivery() {
432 let hook = DiagnosticHook::with_strict(false);
433 let count = Arc::new(AtomicUsize::new(0));
434 let counted = Arc::clone(&count);
435 hook.set(Some(Arc::new(move |_| {
436 counted.fetch_add(1, Ordering::Release);
437 })));
438 hook.emit(GpuDiagnostic::UncapturedError {
439 kind: GpuErrorKind::Internal,
440 message: "first".to_owned(),
441 });
442 hook.set(None);
443 hook.emit(GpuDiagnostic::UncapturedError {
444 kind: GpuErrorKind::Internal,
445 message: "second".to_owned(),
446 });
447 assert_eq!(count.load(Ordering::Acquire), 1);
448 }
449
450 #[test]
451 fn every_wgpu_error_keeps_its_class() {
452 let source = || -> wgpu::ErrorSource { Box::new(std::io::Error::other("validation")) };
454 for (error, kind, code) in [
455 (
456 wgpu::Error::OutOfMemory { source: source() },
457 GpuErrorKind::OutOfMemory,
458 "OUT_OF_MEMORY",
459 ),
460 (
461 wgpu::Error::Validation {
462 source: source(),
463 description: "out of memory, says the text".to_owned(),
464 },
465 GpuErrorKind::Validation,
466 "VALIDATION_FAILED",
467 ),
468 (
469 wgpu::Error::Internal {
470 source: source(),
471 description: "out of memory".to_owned(),
472 },
473 GpuErrorKind::Internal,
474 "UNKNOWN",
475 ),
476 ] {
477 assert_eq!(GpuErrorKind::of(&error), kind);
478 let diagnostic = GpuDiagnostic::uncaptured(&error);
479 assert_eq!(diagnostic.failure_code(), code, "{diagnostic}");
480 assert_eq!(diagnostic.event(), "uncaptured-error");
481 }
482 }
483
484 #[test]
485 fn an_out_of_memory_error_names_the_call_and_resource_that_ran_out() {
486 #[derive(Debug)]
488 struct Context(std::io::Error);
489 impl fmt::Display for Context {
490 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491 formatter.write_str("In Device::create_texture, label = 'alpha2 intermediate'")
492 }
493 }
494 impl std::error::Error for Context {
495 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
496 Some(&self.0)
497 }
498 }
499 let error = wgpu::Error::OutOfMemory {
500 source: Box::new(Context(std::io::Error::other("Not enough memory left."))),
501 };
502 assert_eq!(
503 GpuDiagnostic::uncaptured(&error),
504 GpuDiagnostic::UncapturedError {
505 kind: GpuErrorKind::OutOfMemory,
506 message: "Out of Memory: In Device::create_texture, label = 'alpha2 intermediate': Not enough memory left.".to_owned(),
507 }
508 );
509 let browser = wgpu::Error::OutOfMemory {
511 source: Box::<dyn std::error::Error + Send + Sync>::from("<WebGPU Error>"),
512 };
513 assert_eq!(
514 GpuDiagnostic::uncaptured(&browser).to_string(),
515 "uncaptured GPU out-of-memory error: Out of Memory"
516 );
517 let validation = wgpu::Error::Validation {
519 source: Box::new(std::io::Error::other("inner")),
520 description: "Validation Error\n\nCaused by:\n inner".to_owned(),
521 };
522 let GpuDiagnostic::UncapturedError { message, .. } = GpuDiagnostic::uncaptured(&validation)
523 else {
524 panic!("an uncaptured error");
525 };
526 assert_eq!(message, "Validation Error\n\nCaused by:\n inner");
527 }
528
529 fn oom(message: &str) -> GpuDiagnostic {
530 GpuDiagnostic::UncapturedError {
531 kind: GpuErrorKind::OutOfMemory,
532 message: message.to_owned(),
533 }
534 }
535
536 #[test]
537 fn the_log_keeps_the_newest_events_in_fixed_space() {
538 let log = GpuDiagnosticLog::new(2);
539 log.push(oom("a"));
540 log.push(oom("b"));
541 log.push(oom("c"));
542 let snapshot = log.snapshot();
543 assert_eq!(snapshot.entries, vec![oom("b"), oom("c")]);
544 assert_eq!(snapshot.dropped, 1);
545 }
546
547 fn kept_message(log: &GpuDiagnosticLog) -> String {
548 let GpuDiagnostic::UncapturedError { message, .. } = &log.snapshot().entries[0] else {
549 panic!("an uncaptured error");
550 };
551 message.clone()
552 }
553
554 #[test]
555 fn the_log_truncates_long_messages_on_a_char_boundary() {
556 for (input, kept) in [
558 (
559 format!("a{}", "é".repeat(MAX_DIAGNOSTIC_MESSAGE_BYTES)),
560 format!("a{}", "é".repeat(511)),
561 ),
562 ("─".repeat(MAX_DIAGNOSTIC_MESSAGE_BYTES), "─".repeat(341)),
563 ] {
564 assert!(!input.is_char_boundary(MAX_DIAGNOSTIC_MESSAGE_BYTES));
565 let log = GpuDiagnosticLog::new(1);
566 log.push(oom(&input));
567 assert_eq!(kept_message(&log), format!("{kept}{ELLIPSIS}"));
569 }
570 let log = GpuDiagnosticLog::new(1);
571 let exact = "x".repeat(MAX_DIAGNOSTIC_MESSAGE_BYTES);
572 log.push(oom(&exact));
573 assert_eq!(kept_message(&log), exact, "a message at the limit is whole");
574 }
575
576 #[test]
577 fn the_log_keeps_no_more_memory_than_the_messages_it_keeps() {
578 let log = GpuDiagnosticLog::new(2);
580 let mut quoted = String::with_capacity(64 * 1024);
581 quoted.push_str(&"v".repeat(64 * 1024));
582 log.push(GpuDiagnostic::UncapturedError {
583 kind: GpuErrorKind::Validation,
584 message: quoted,
585 });
586 let mut short = String::with_capacity(16 * 1024);
587 short.push_str("short");
588 log.push(GpuDiagnostic::DeviceLost {
589 reason: short.clone(),
590 message: short,
591 });
592 let state = log.state.lock().unwrap_or_else(PoisonError::into_inner);
593 let held: Vec<usize> = state
594 .entries
595 .iter()
596 .flat_map(|entry| match entry {
597 GpuDiagnostic::UncapturedError { message, .. } => vec![message.capacity()],
598 GpuDiagnostic::DeviceLost { reason, message } => {
599 vec![reason.capacity(), message.capacity()]
600 }
601 })
602 .collect();
603 assert_eq!(
604 held,
605 [MAX_DIAGNOSTIC_MESSAGE_BYTES + ELLIPSIS.len(), 5, 5],
606 "each kept text owns its length and no more"
607 );
608 }
609
610 #[test]
611 fn only_typed_evidence_names_a_failure_class() {
612 let log = GpuDiagnosticLog::default();
613 log.push(GpuDiagnostic::UncapturedError {
614 kind: GpuErrorKind::Internal,
615 message: "out of memory, says the text".to_owned(),
616 });
617 assert_eq!(log.snapshot().failure_code(), None);
618 log.push(GpuDiagnostic::UncapturedError {
619 kind: GpuErrorKind::Validation,
620 message: "bad layout".to_owned(),
621 });
622 assert_eq!(log.snapshot().failure_code(), Some("VALIDATION_FAILED"));
623 log.push(oom("buffer"));
624 assert_eq!(log.snapshot().failure_code(), Some("OUT_OF_MEMORY"));
625 log.push(GpuDiagnostic::DeviceLost {
626 reason: "Unknown".to_owned(),
627 message: "driver reset".to_owned(),
628 });
629 assert_eq!(log.snapshot().failure_code(), Some("DEVICE_LOST"));
630 }
631
632 #[test]
633 fn an_out_of_memory_report_survives_eviction_until_cleared() {
634 let log = GpuDiagnosticLog::default();
636 log.push(oom("Device::create_texture"));
637 for _ in 0..GpuDiagnosticLog::DEFAULT_CAPACITY {
638 log.push(GpuDiagnostic::UncapturedError {
639 kind: GpuErrorKind::Validation,
640 message: "texture is invalid".to_owned(),
641 });
642 }
643 let snapshot = log.snapshot();
644 assert_eq!(snapshot.dropped, 1);
645 assert!(!snapshot.entries.contains(&oom("Device::create_texture")));
646 assert!(snapshot.out_of_memory);
647 assert_eq!(snapshot.failure_code(), Some("OUT_OF_MEMORY"));
648 log.clear();
650 log.push(GpuDiagnostic::UncapturedError {
651 kind: GpuErrorKind::Validation,
652 message: "bad layout".to_owned(),
653 });
654 let snapshot = log.snapshot();
655 assert!(!snapshot.out_of_memory);
656 assert_eq!(snapshot.failure_code(), Some("VALIDATION_FAILED"));
657 }
658
659 #[test]
660 fn a_device_loss_survives_clearing_and_eviction() {
661 let log = GpuDiagnosticLog::new(1);
662 log.push(GpuDiagnostic::DeviceLost {
663 reason: "Unknown".to_owned(),
664 message: String::new(),
665 });
666 log.push(oom("later"));
667 log.clear();
668 let snapshot = log.snapshot();
669 assert_eq!(snapshot.entries, [] as [GpuDiagnostic; 0]);
670 assert!(snapshot.device_lost);
671 assert_eq!(snapshot.failure_code(), Some("DEVICE_LOST"));
672 }
673}