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`] is evidence that
8/// an allocation failed; the others are never read as memory pressure.
9#[derive(Copy, Clone, Debug, Eq, PartialEq)]
10#[non_exhaustive]
11pub enum GpuErrorKind {
12    /// An allocation the driver was asked for could not be made.
13    OutOfMemory,
14    /// Invalid use of the API: a bug or bad data, not a resource shortage.
15    Validation,
16    /// Any other failure the implementation reports, including limits it hit.
17    Internal,
18}
19
20impl GpuErrorKind {
21    /// The kind `error` belongs to.
22    #[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    /// Stable lowercase name for logs and protocols.
33    #[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/// A device-level GPU event that no call return value reports.
44///
45/// These arrive asynchronously from the driver, so an embedding application
46/// observes them through [`GpuContext::set_diagnostic_handler`] rather than
47/// from the call that triggered them.
48///
49/// [`GpuContext::set_diagnostic_handler`]: crate::GpuContext::set_diagnostic_handler
50#[derive(Clone, Debug, Eq, PartialEq)]
51#[non_exhaustive]
52pub enum GpuDiagnostic {
53    /// The driver reported an error that no pending call returned.
54    UncapturedError {
55        /// Which of WebGPU's error classes it was.
56        kind: GpuErrorKind,
57        /// Driver-supplied description.
58        message: String,
59    },
60    /// The device was lost and can no longer serve work.
61    DeviceLost {
62        /// Loss reason reported by `wgpu`, or the browser event that lost it.
63        reason: String,
64        /// Driver-supplied description.
65        message: String,
66    },
67}
68
69impl GpuDiagnostic {
70    /// The diagnostic for an uncaptured `wgpu` error, keeping its class.
71    ///
72    /// `wgpu` spells a validation or internal error's sources out in its
73    /// description, but writes an out-of-memory error as the bare words "Out
74    /// of Memory"; its sources name the call, the resource label, and the
75    /// driver's cause, so they are appended, each after `": "`. A browser's
76    /// report has only `wgpu`'s `<WebGPU Error>` placeholder there, which
77    /// names nothing and is left out.
78    #[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    /// The export failure class this event is evidence of:
100    /// `OUT_OF_MEMORY`, `VALIDATION_FAILED`, `DEVICE_LOST`, or `UNKNOWN` for
101    /// an internal error, which names no cause.
102    #[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    /// Stable lowercase event name: `uncaptured-error` or `device-lost`.
122    #[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
161/// Longest message a [`GpuDiagnosticLog`] keeps, in bytes. A validation
162/// error can quote a whole shader; the log only needs enough to identify it.
163pub const MAX_DIAGNOSTIC_MESSAGE_BYTES: usize = 1_024;
164
165/// What a cut message ends with.
166const ELLIPSIS: &str = "...";
167
168/// `message` cut to [`MAX_DIAGNOSTIC_MESSAGE_BYTES`] on a char boundary,
169/// with an ellipsis where it was cut, in a buffer of its own length: the log
170/// keeps what it returns, so it must not keep the driver's whole buffer too.
171fn 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/// The most recent [`GpuDiagnostic`]s, in arrival order, in fixed memory:
187/// at most `capacity` events, each text at most
188/// [`MAX_DIAGNOSTIC_MESSAGE_BYTES`] and an ellipsis.
189///
190/// A failure report reads it to connect an error a call returned (a readback
191/// that failed to map carries no cause: `wgpu::BufferAsyncError` is a unit)
192/// with what the device reported around it. Its size never grows
193/// with the events, because it is consulted exactly when memory may be short.
194#[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/// What a [`GpuDiagnosticLog`] held when it was read.
209#[derive(Clone, Debug, Default, Eq, PartialEq)]
210pub struct GpuDiagnosticSnapshot {
211    /// The kept events, oldest first.
212    pub entries: Vec<GpuDiagnostic>,
213    /// Events that arrived and were pushed out by later ones.
214    pub dropped: u64,
215    /// Whether any event, kept or dropped, was a device loss.
216    pub device_lost: bool,
217    /// Whether any event since the log was last cleared, kept or dropped,
218    /// was an out-of-memory report.
219    ///
220    /// An allocation that ran out leaves an invalid resource behind, and
221    /// every later use of it is reported as a validation error, so the
222    /// report that names the cause is the first to be pushed out.
223    pub out_of_memory: bool,
224}
225
226impl GpuDiagnosticSnapshot {
227    /// The failure class the strongest evidence here names, if any: a device
228    /// loss first, since everything after it fails too; then an out-of-memory
229    /// report, kept or dropped; then a validation error. Internal errors
230    /// name nothing.
231    #[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    /// Default number of events a log keeps.
248    pub const DEFAULT_CAPACITY: usize = 16;
249
250    /// An empty log keeping at most `capacity` events (at least one).
251    #[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    /// Records `diagnostic`, pushing out the oldest event when full.
260    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    /// A copy of what the log holds now.
272    #[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    /// Forgets the events, so the next snapshot covers only what follows,
284    /// out-of-memory reports included. A device loss is kept: a lost device
285    /// stays lost.
286    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    /// Whether a device loss was ever recorded.
294    #[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
309/// Shared handler invoked for every [`GpuDiagnostic`].
310///
311/// The driver may call it from an arbitrary thread, so it must not block.
312pub type GpuDiagnosticHandler = Arc<dyn Fn(GpuDiagnostic) + Send + Sync + 'static>;
313
314/// Interior-mutable slot the device callbacks forward through.
315///
316/// Devices take their callbacks once at creation, so the slot is what lets a
317/// caller install or replace a handler on an already-created context.
318pub(crate) struct DiagnosticHook {
319    handler: Mutex<Option<GpuDiagnosticHandler>>,
320    strict: bool,
321}
322
323/// Whether an uncaptured error with no handler installed panics, as wgpu's own default does,
324/// instead of being dropped: in debug builds, so every test that creates a context fails on the
325/// call that made the error, and wherever `ALPHA2_GPU_STRICT=1`.
326fn 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    /// Emits without holding the lock across the call, so a handler that
348    /// re-enters the context cannot deadlock.
349    ///
350    /// With no handler installed a strict hook panics on an uncaptured error,
351    /// on the thread whose call made it; device loss is still dropped, since
352    /// dropping a device reports it too.
353    ///
354    /// Only the native device callbacks emit; the browser owns its own
355    /// callbacks, as [`GpuContext::set_diagnostic_handler`] documents.
356    ///
357    /// [`GpuContext::set_diagnostic_handler`]: crate::GpuContext::set_diagnostic_handler
358    #[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        // The class is what a failure report and the retry policy read; text says nothing.
483        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        // wgpu writes only "Out of Memory" for this class; the call, label and cause are in its
517        // sources, shaped as wgpu-core's ContextError.
518        #[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        // A browser's report carries only wgpu's placeholder, which names nothing.
541        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        // A validation description already spells its sources out.
549        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        // The cut falls inside a 3-byte character (naga's box drawing), so the walk back runs.
588        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            // The longest prefix ending on a boundary at or before the limit, then the ellipsis.
599            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        // A cut message must not keep the buffer it was cut from; read through the kept log, not a
610        // snapshot's compact clone.
611        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        // Later uses of the invalid resource push the report out with validation errors, yet the
667        // out-of-memory report is still the cause.
668        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        // What follows a clear is the next span's evidence; memory may be back.
682        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}