use std::any::Any;
use std::cell::RefCell;
use std::fmt;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex, Once};
thread_local! {
static LAST_PANIC: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
}
static RECENT_PANIC_SITES: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
const RECENT_PANIC_SITES_CAP: usize = 16;
pub const BUILDING: &str = "building the model";
pub const STEPPING: &str = "stepping the simulation";
#[derive(Debug)]
pub struct Fault {
pub during: &'static str,
pub kind: FaultKind,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum FaultKind {
Device(wgpu::Error),
Panic {
message: String,
location: Option<String>,
},
Refused(String),
Poll(wgpu::PollError),
DeviceLost,
}
impl Fault {
pub fn device(during: &'static str, error: wgpu::Error) -> Self {
Self {
during,
kind: FaultKind::Device(error),
}
}
pub fn refused(during: &'static str, message: impl Into<String>) -> Self {
Self {
during,
kind: FaultKind::Refused(message.into()),
}
}
pub fn device_lost(during: &'static str) -> Self {
Self {
during,
kind: FaultKind::DeviceLost,
}
}
pub fn is_out_of_memory(&self) -> bool {
matches!(self.kind, FaultKind::Device(wgpu::Error::OutOfMemory { .. }))
}
}
impl fmt::Display for Fault {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "while {}, ", self.during)?;
match &self.kind {
FaultKind::Device(error) => write!(f, "the GPU reported: {error}"),
FaultKind::Panic {
message,
location: Some(location),
} => write!(f, "the simulation panicked: {message} ({location})"),
FaultKind::Panic {
message,
location: None,
} => {
write!(f, "the simulation panicked: {message}")
}
FaultKind::Refused(message) => write!(f, "{message}"),
FaultKind::Poll(error) => write!(f, "the GPU failed to finish the submitted work: {error}"),
FaultKind::DeviceLost => f.write_str("the GPU device was lost"),
}
}
}
impl std::error::Error for Fault {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.kind {
FaultKind::Device(error) => Some(error),
FaultKind::Poll(error) => Some(error),
FaultKind::Panic { .. } | FaultKind::Refused(_) | FaultKind::DeviceLost => None,
}
}
}
pub fn catching<T>(during: &'static str, f: impl FnOnce() -> T) -> Result<T, Fault> {
LAST_PANIC.with(|slot| slot.borrow_mut().take());
std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|payload| {
let message = payload_message(payload.as_ref());
Fault {
during,
kind: FaultKind::Panic {
location: take_location(&message),
message,
},
}
})
}
pub fn install_panic_hook() {
static INSTALLED: Once = Once::new();
INSTALLED.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(at) = info.location() {
let site = format!("{}:{}", at.file(), at.line());
let message = payload_message(info.payload());
LAST_PANIC.with(|slot| *slot.borrow_mut() = Some((message.clone(), site.clone())));
if let Ok(mut recent) = RECENT_PANIC_SITES.lock() {
if recent.len() >= RECENT_PANIC_SITES_CAP {
recent.remove(0);
}
recent.push((message, site));
}
}
previous(info);
}));
});
}
fn take_location(message: &str) -> Option<String> {
let here = LAST_PANIC
.with(|slot| slot.borrow_mut().take())
.filter(|(seen, _)| seen == message);
let mut recent = RECENT_PANIC_SITES.lock().ok();
let Some(recent) = recent.as_mut() else {
return here.map(|(_, site)| site);
};
if let Some((_, site)) = here {
if let Some(found) = recent
.iter()
.rposition(|(seen, seen_site)| seen == message && *seen_site == site)
{
recent.remove(found);
}
return Some(site);
}
let found = recent.iter().rposition(|(seen, _)| seen == message)?;
Some(recent.remove(found).1)
}
fn payload_message(payload: &(dyn Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_owned()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"panicked".to_owned()
}
}
#[derive(Clone, Default)]
pub struct FaultSink(Arc<Mutex<Option<Fault>>>);
impl FaultSink {
pub fn new() -> Self {
Self::default()
}
pub fn set_once(&self, fault: Fault) {
if let Ok(mut slot) = self.0.lock()
&& slot.is_none()
{
*slot = Some(fault);
}
}
pub fn take(&self) -> Option<Fault> {
self.0.lock().ok()?.take()
}
pub fn is_set(&self) -> bool {
self.0.lock().is_ok_and(|slot| slot.is_some())
}
}
impl fmt::Debug for FaultSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FaultSink").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::{Fault, FaultKind, FaultSink, STEPPING, catching, install_panic_hook};
fn panic_message(fault: &Fault) -> &str {
match &fault.kind {
FaultKind::Panic { message, .. } => message,
other => panic!("expected a panic fault, got {other:?}"),
}
}
#[test]
fn a_clean_closure_passes_its_value_through() {
assert_eq!(catching("testing", || 7).ok(), Some(7));
}
#[test]
fn a_panic_becomes_a_fault() {
let fault = catching("testing", || {
let zero = std::hint::black_box(0);
1 / zero
})
.expect_err("a division by zero should have been caught");
assert!(
panic_message(&fault).contains("divide by zero"),
"{}",
panic_message(&fault)
);
}
#[test]
fn a_panic_message_survives_the_catch() {
let fault = catching("testing", || panic!("a formatted {} message", 1)).expect_err("should panic");
assert_eq!(panic_message(&fault), "a formatted 1 message");
}
#[test]
fn the_hook_attaches_a_location() {
install_panic_hook();
let fault = catching("testing", || panic!("located")).expect_err("should panic");
let FaultKind::Panic { location, .. } = &fault.kind else {
panic!("expected a panic fault");
};
let location = location.as_deref().expect("the hook should have recorded a location");
assert!(location.contains("fault.rs"), "{location}");
}
#[test]
fn a_location_survives_a_panic_on_a_rayon_worker() {
use rayon::prelude::*;
install_panic_hook();
let outcome: Result<(), Fault> = catching("testing", || {
(0..64).into_par_iter().for_each(|i| {
assert!(i < 32, "from a worker");
});
});
let fault = outcome.expect_err("the worker's panic should have been caught");
let FaultKind::Panic { location, .. } = &fault.kind else {
panic!("expected a panic fault, got {fault:?}");
};
let location = location
.as_deref()
.expect("a worker panic must still carry its location");
assert!(location.contains("fault.rs"), "{location}");
}
#[test]
fn a_site_recorded_on_the_thread_for_another_panic_is_not_taken() {
use rayon::prelude::*;
install_panic_hook();
let nested_line = line!() + 3;
let worker_line = line!() + 4;
let outcome: Result<(), Fault> = catching("testing", || {
drop(std::panic::catch_unwind(|| panic!("from a nested job")));
(0..64).into_par_iter().for_each(|i| {
assert!(i < 32, "from a worker beside a nested job");
});
});
let fault = outcome.expect_err("the worker's panic should have been caught");
let FaultKind::Panic { location, .. } = &fault.kind else {
panic!("expected a panic fault, got {fault:?}");
};
let location = location.as_deref().expect("the worker's panic carries its location");
assert!(
!location.ends_with(&format!(":{nested_line}")),
"the nested job's site was taken: {location}"
);
assert!(location.ends_with(&format!(":{worker_line}")), "{location}");
}
#[test]
fn a_location_is_not_reused_by_the_next_catch() {
install_panic_hook();
drop(catching("testing", || panic!("first")));
let fault = catching("testing", || panic!("second")).expect_err("should panic");
let FaultKind::Panic { location, .. } = &fault.kind else {
panic!("expected a panic fault");
};
assert!(location.is_some(), "the second panic lost its own location");
}
#[test]
fn a_failed_wait_names_the_unfinished_work() {
let fault = Fault {
during: STEPPING,
kind: FaultKind::Poll(wgpu::PollError::Timeout),
};
let shown = fault.to_string();
assert!(
shown.starts_with("while stepping the simulation, the GPU failed to finish the submitted work: "),
"{shown}"
);
assert!(
std::error::Error::source(&fault).is_some(),
"the poll error is the source"
);
}
#[test]
fn only_a_device_out_of_memory_error_is_out_of_memory() {
let out_of_memory = Fault::device(
STEPPING,
wgpu::Error::OutOfMemory {
source: Box::new(std::fmt::Error),
},
);
assert!(out_of_memory.is_out_of_memory());
let validation = Fault::device(
STEPPING,
wgpu::Error::Validation {
source: Box::new(std::fmt::Error),
description: "a validation error".to_owned(),
},
);
assert!(!validation.is_out_of_memory());
assert!(!Fault::refused(STEPPING, "too large").is_out_of_memory());
}
#[test]
fn the_sink_keeps_the_first_fault() {
let sink = FaultSink::new();
assert!(!sink.is_set());
for message in ["first", "second"] {
let outcome: Result<(), Fault> = catching("testing", || panic!("{message}"));
sink.set_once(outcome.expect_err("the closure always panics"));
}
assert!(sink.is_set());
let taken = sink.take().expect("a fault was set");
assert_eq!(panic_message(&taken), "first");
assert!(sink.take().is_none(), "taking should empty the sink");
}
}