Skip to main content

alpha2_gpu/
diagnostic.rs

1use std::collections::VecDeque;
2use std::fmt;
3use std::sync::{Arc, Mutex, PoisonError};
4
5/// What class of error the driver reported.
6///
7/// WebGPU's three error filters. Only [`Self::OutOfMemory`] indicates memory pressure.
8#[derive(Copy, Clone, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum GpuErrorKind {
11    /// An allocation the driver was asked for could not be made.
12    OutOfMemory,
13    /// Invalid use of the API: a bug or bad data, not a resource shortage.
14    Validation,
15    /// Any other failure the implementation reports, including limits it hit.
16    Internal,
17}
18
19impl GpuErrorKind {
20    /// The kind `error` belongs to.
21    #[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    /// Stable lowercase name for logs and protocols.
32    #[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/// A device-level GPU event that no call return value reports.
43///
44/// These arrive asynchronously; observe them through [`GpuContext::set_diagnostic_handler`].
45///
46/// [`GpuContext::set_diagnostic_handler`]: crate::GpuContext::set_diagnostic_handler
47#[derive(Clone, Debug, Eq, PartialEq)]
48#[non_exhaustive]
49pub enum GpuDiagnostic {
50    /// The driver reported an error that no pending call returned.
51    UncapturedError {
52        /// Which of WebGPU's error classes it was.
53        kind: GpuErrorKind,
54        /// Driver-supplied description.
55        message: String,
56    },
57    /// The device was lost and can no longer serve work.
58    DeviceLost {
59        /// Loss reason reported by `wgpu`, or the browser event that lost it.
60        reason: String,
61        /// Driver-supplied description.
62        message: String,
63    },
64}
65
66impl GpuDiagnostic {
67    /// The diagnostic for an uncaptured `wgpu` error, keeping its class.
68    ///
69    /// `wgpu` describes out-of-memory errors only as "Out of Memory", so their sources (call,
70    /// label, cause) are appended after `": "`. The browser's `<WebGPU Error>` placeholder
71    /// source is omitted.
72    #[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    /// The export failure class: `OUT_OF_MEMORY`, `VALIDATION_FAILED`, `DEVICE_LOST`, or
94    /// `UNKNOWN` for internal errors.
95    #[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    /// Stable lowercase event name: `uncaptured-error` or `device-lost`.
115    #[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
154/// Longest message a [`GpuDiagnosticLog`] keeps, in bytes; validation errors can quote whole
155/// shaders.
156pub const MAX_DIAGNOSTIC_MESSAGE_BYTES: usize = 1_024;
157
158const ELLIPSIS: &str = "...";
159
160/// `message` cut to [`MAX_DIAGNOSTIC_MESSAGE_BYTES`] on a char boundary plus an ellipsis, in a
161/// right-sized buffer so the log does not retain the driver's whole allocation.
162fn 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/// The most recent [`GpuDiagnostic`]s in arrival order, bounded to `capacity` events of at most
178/// [`MAX_DIAGNOSTIC_MESSAGE_BYTES`] each.
179///
180/// Failure reports read it to find the cause of errors that carry none (such as
181/// `wgpu::BufferAsyncError`). Its memory is fixed because it is read when memory may be short.
182#[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/// What a [`GpuDiagnosticLog`] held when it was read.
197#[derive(Clone, Debug, Default, Eq, PartialEq)]
198pub struct GpuDiagnosticSnapshot {
199    /// The kept events, oldest first.
200    pub entries: Vec<GpuDiagnostic>,
201    /// Events that arrived and were pushed out by later ones.
202    pub dropped: u64,
203    /// Whether any event, kept or dropped, was a device loss.
204    pub device_lost: bool,
205    /// Whether any event since the last clear, kept or dropped, was out of memory.
206    ///
207    /// Later uses of the failed resource report validation errors that push the cause out.
208    pub out_of_memory: bool,
209}
210
211impl GpuDiagnosticSnapshot {
212    /// The failure class of the strongest evidence: device loss, then out of memory, then
213    /// validation. Internal errors name nothing.
214    #[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    /// Default number of events a log keeps.
231    pub const DEFAULT_CAPACITY: usize = 16;
232
233    /// An empty log keeping at most `capacity` events (at least one).
234    #[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    /// Records `diagnostic`, pushing out the oldest event when full.
243    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    /// A copy of what the log holds now.
255    #[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    /// Forgets the events, including out-of-memory reports. A device loss is kept.
267    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    /// Whether a device loss was ever recorded.
275    #[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
290/// Shared handler invoked for every [`GpuDiagnostic`].
291///
292/// The driver may call it from an arbitrary thread, so it must not block.
293pub type GpuDiagnosticHandler = Arc<dyn Fn(GpuDiagnostic) + Send + Sync + 'static>;
294
295/// Slot the device callbacks forward through, so handlers can be replaced after device creation.
296pub(crate) struct DiagnosticHook {
297    handler: Mutex<Option<GpuDiagnosticHandler>>,
298    strict: bool,
299}
300
301/// Whether an unhandled uncaptured error panics (debug builds or `ALPHA2_GPU_STRICT=1`) rather
302/// than being dropped.
303fn 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    /// Emits without holding the lock, so a re-entrant handler cannot deadlock.
325    ///
326    /// Without a handler, a strict hook panics on uncaptured errors; device loss is dropped
327    /// because dropping a device also reports it.
328    #[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        // Reports and retry policy read the class, not the text.
453        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        // Sources shaped like wgpu-core's ContextError.
487        #[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        // A browser's report carries only wgpu's placeholder, which names nothing.
510        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        // A validation description already spells its sources out.
518        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        // The cut falls inside a 3-byte character.
557        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            // The longest prefix ending on a boundary at or before the limit, then the ellipsis.
568            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        // Check the kept log, not a snapshot's compact clone.
579        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        // Validation errors push the out-of-memory report out, but it is still the cause.
635        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        // A clear starts a new span of evidence.
649        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}