1use std::any::Any;
6use std::cell::RefCell;
7use std::fmt;
8use std::panic::AssertUnwindSafe;
9use std::sync::{Arc, Mutex, Once};
10
11thread_local! {
12 static LAST_PANIC: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
18}
19
20static RECENT_PANIC_SITES: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
29
30const RECENT_PANIC_SITES_CAP: usize = 16;
33
34pub const BUILDING: &str = "building the model";
36
37pub const STEPPING: &str = "stepping the simulation";
39
40#[derive(Debug)]
42pub struct Fault {
43 pub during: &'static str,
45 pub kind: FaultKind,
47}
48
49#[derive(Debug)]
51#[non_exhaustive]
52pub enum FaultKind {
53 Device(wgpu::Error),
55 Panic {
57 message: String,
59 location: Option<String>,
61 },
62 Refused(String),
67 Poll(wgpu::PollError),
69 DeviceLost,
74}
75
76impl Fault {
77 pub fn device(during: &'static str, error: wgpu::Error) -> Self {
79 Self {
80 during,
81 kind: FaultKind::Device(error),
82 }
83 }
84
85 pub fn refused(during: &'static str, message: impl Into<String>) -> Self {
87 Self {
88 during,
89 kind: FaultKind::Refused(message.into()),
90 }
91 }
92
93 pub fn device_lost(during: &'static str) -> Self {
95 Self {
96 during,
97 kind: FaultKind::DeviceLost,
98 }
99 }
100
101 pub fn is_out_of_memory(&self) -> bool {
103 matches!(self.kind, FaultKind::Device(wgpu::Error::OutOfMemory { .. }))
104 }
105}
106
107impl fmt::Display for Fault {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write!(f, "while {}, ", self.during)?;
110 match &self.kind {
111 FaultKind::Device(error) => write!(f, "the GPU reported: {error}"),
112 FaultKind::Panic {
113 message,
114 location: Some(location),
115 } => write!(f, "the simulation panicked: {message} ({location})"),
116 FaultKind::Panic {
117 message,
118 location: None,
119 } => {
120 write!(f, "the simulation panicked: {message}")
121 }
122 FaultKind::Refused(message) => write!(f, "{message}"),
123 FaultKind::Poll(error) => write!(f, "the GPU failed to finish the submitted work: {error}"),
124 FaultKind::DeviceLost => f.write_str("the GPU device was lost"),
125 }
126 }
127}
128
129impl std::error::Error for Fault {
130 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
131 match &self.kind {
132 FaultKind::Device(error) => Some(error),
133 FaultKind::Poll(error) => Some(error),
134 FaultKind::Panic { .. } | FaultKind::Refused(_) | FaultKind::DeviceLost => None,
135 }
136 }
137}
138
139pub fn catching<T>(during: &'static str, f: impl FnOnce() -> T) -> Result<T, Fault> {
146 LAST_PANIC.with(|slot| slot.borrow_mut().take());
148 std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|payload| {
149 let message = payload_message(payload.as_ref());
150 Fault {
151 during,
152 kind: FaultKind::Panic {
153 location: take_location(&message),
154 message,
155 },
156 }
157 })
158}
159
160pub fn install_panic_hook() {
164 static INSTALLED: Once = Once::new();
165 INSTALLED.call_once(|| {
166 let previous = std::panic::take_hook();
167 std::panic::set_hook(Box::new(move |info| {
168 if let Some(at) = info.location() {
169 let site = format!("{}:{}", at.file(), at.line());
170 let message = payload_message(info.payload());
171 LAST_PANIC.with(|slot| *slot.borrow_mut() = Some((message.clone(), site.clone())));
172 if let Ok(mut recent) = RECENT_PANIC_SITES.lock() {
173 if recent.len() >= RECENT_PANIC_SITES_CAP {
174 recent.remove(0);
175 }
176 recent.push((message, site));
177 }
178 }
179 previous(info);
180 }));
181 });
182}
183
184fn take_location(message: &str) -> Option<String> {
189 let here = LAST_PANIC
190 .with(|slot| slot.borrow_mut().take())
191 .filter(|(seen, _)| seen == message);
192 let mut recent = RECENT_PANIC_SITES.lock().ok();
193 let Some(recent) = recent.as_mut() else {
194 return here.map(|(_, site)| site);
195 };
196 if let Some((_, site)) = here {
197 if let Some(found) = recent
198 .iter()
199 .rposition(|(seen, seen_site)| seen == message && *seen_site == site)
200 {
201 recent.remove(found);
202 }
203 return Some(site);
204 }
205 let found = recent.iter().rposition(|(seen, _)| seen == message)?;
206 Some(recent.remove(found).1)
207}
208
209fn payload_message(payload: &(dyn Any + Send)) -> String {
211 if let Some(message) = payload.downcast_ref::<&str>() {
212 (*message).to_owned()
213 } else if let Some(message) = payload.downcast_ref::<String>() {
214 message.clone()
215 } else {
216 "panicked".to_owned()
217 }
218}
219
220#[derive(Clone, Default)]
225pub struct FaultSink(Arc<Mutex<Option<Fault>>>);
226
227impl FaultSink {
228 pub fn new() -> Self {
230 Self::default()
231 }
232
233 pub fn set_once(&self, fault: Fault) {
235 if let Ok(mut slot) = self.0.lock()
236 && slot.is_none()
237 {
238 *slot = Some(fault);
239 }
240 }
241
242 pub fn take(&self) -> Option<Fault> {
244 self.0.lock().ok()?.take()
245 }
246
247 pub fn is_set(&self) -> bool {
249 self.0.lock().is_ok_and(|slot| slot.is_some())
250 }
251}
252
253impl fmt::Debug for FaultSink {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 f.debug_struct("FaultSink").finish_non_exhaustive()
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{Fault, FaultKind, FaultSink, STEPPING, catching, install_panic_hook};
262
263 fn panic_message(fault: &Fault) -> &str {
264 match &fault.kind {
265 FaultKind::Panic { message, .. } => message,
266 other => panic!("expected a panic fault, got {other:?}"),
267 }
268 }
269
270 #[test]
271 fn a_clean_closure_passes_its_value_through() {
272 assert_eq!(catching("testing", || 7).ok(), Some(7));
273 }
274
275 #[test]
277 fn a_panic_becomes_a_fault() {
278 let fault = catching("testing", || {
279 let zero = std::hint::black_box(0);
280 1 / zero
281 })
282 .expect_err("a division by zero should have been caught");
283 assert!(
284 panic_message(&fault).contains("divide by zero"),
285 "{}",
286 panic_message(&fault)
287 );
288 }
289
290 #[test]
291 fn a_panic_message_survives_the_catch() {
292 let fault = catching("testing", || panic!("a formatted {} message", 1)).expect_err("should panic");
293 assert_eq!(panic_message(&fault), "a formatted 1 message");
294 }
295
296 #[test]
298 fn the_hook_attaches_a_location() {
299 install_panic_hook();
300 let fault = catching("testing", || panic!("located")).expect_err("should panic");
301 let FaultKind::Panic { location, .. } = &fault.kind else {
302 panic!("expected a panic fault");
303 };
304 let location = location.as_deref().expect("the hook should have recorded a location");
305 assert!(location.contains("fault.rs"), "{location}");
306 }
307
308 #[test]
312 fn a_location_survives_a_panic_on_a_rayon_worker() {
313 use rayon::prelude::*;
314
315 install_panic_hook();
316 let outcome: Result<(), Fault> = catching("testing", || {
317 (0..64).into_par_iter().for_each(|i| {
318 assert!(i < 32, "from a worker");
319 });
320 });
321 let fault = outcome.expect_err("the worker's panic should have been caught");
322 let FaultKind::Panic { location, .. } = &fault.kind else {
323 panic!("expected a panic fault, got {fault:?}");
324 };
325 let location = location
326 .as_deref()
327 .expect("a worker panic must still carry its location");
328 assert!(location.contains("fault.rs"), "{location}");
329 }
330
331 #[test]
334 fn a_site_recorded_on_the_thread_for_another_panic_is_not_taken() {
335 use rayon::prelude::*;
336
337 install_panic_hook();
338 let nested_line = line!() + 3;
339 let worker_line = line!() + 4;
340 let outcome: Result<(), Fault> = catching("testing", || {
341 drop(std::panic::catch_unwind(|| panic!("from a nested job")));
342 (0..64).into_par_iter().for_each(|i| {
343 assert!(i < 32, "from a worker beside a nested job");
344 });
345 });
346 let fault = outcome.expect_err("the worker's panic should have been caught");
347 let FaultKind::Panic { location, .. } = &fault.kind else {
348 panic!("expected a panic fault, got {fault:?}");
349 };
350 let location = location.as_deref().expect("the worker's panic carries its location");
351 assert!(
352 !location.ends_with(&format!(":{nested_line}")),
353 "the nested job's site was taken: {location}"
354 );
355 assert!(location.ends_with(&format!(":{worker_line}")), "{location}");
356 }
357
358 #[test]
360 fn a_location_is_not_reused_by_the_next_catch() {
361 install_panic_hook();
362 drop(catching("testing", || panic!("first")));
363 let fault = catching("testing", || panic!("second")).expect_err("should panic");
364 let FaultKind::Panic { location, .. } = &fault.kind else {
365 panic!("expected a panic fault");
366 };
367 assert!(location.is_some(), "the second panic lost its own location");
368 }
369
370 #[test]
371 fn a_failed_wait_names_the_unfinished_work() {
372 let fault = Fault {
373 during: STEPPING,
374 kind: FaultKind::Poll(wgpu::PollError::Timeout),
375 };
376 let shown = fault.to_string();
377 assert!(
378 shown.starts_with("while stepping the simulation, the GPU failed to finish the submitted work: "),
379 "{shown}"
380 );
381 assert!(
382 std::error::Error::source(&fault).is_some(),
383 "the poll error is the source"
384 );
385 }
386
387 #[test]
388 fn only_a_device_out_of_memory_error_is_out_of_memory() {
389 let out_of_memory = Fault::device(
390 STEPPING,
391 wgpu::Error::OutOfMemory {
392 source: Box::new(std::fmt::Error),
393 },
394 );
395 assert!(out_of_memory.is_out_of_memory());
396
397 let validation = Fault::device(
398 STEPPING,
399 wgpu::Error::Validation {
400 source: Box::new(std::fmt::Error),
401 description: "a validation error".to_owned(),
402 },
403 );
404 assert!(!validation.is_out_of_memory());
405 assert!(!Fault::refused(STEPPING, "too large").is_out_of_memory());
406 }
407
408 #[test]
410 fn the_sink_keeps_the_first_fault() {
411 let sink = FaultSink::new();
412 assert!(!sink.is_set());
413 for message in ["first", "second"] {
414 let outcome: Result<(), Fault> = catching("testing", || panic!("{message}"));
415 sink.set_once(outcome.expect_err("the closure always panics"));
416 }
417 assert!(sink.is_set());
418 let taken = sink.take().expect("a fault was set");
419 assert_eq!(panic_message(&taken), "first");
420 assert!(sink.take().is_none(), "taking should empty the sink");
421 }
422}