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