Skip to main content

henad_compute/
fault.rs

1//! Faults caught while building or stepping a model.
2//!
3//! A panic or a device error becomes a [`Fault`] the host reports, and the process carries on.
4
5use std::any::Any;
6use std::cell::RefCell;
7use std::fmt;
8use std::panic::AssertUnwindSafe;
9use std::sync::{Arc, Mutex, Once};
10
11thread_local! {
12    /// Last panic raised on this thread, as `(message, site)`. Written by [`install_panic_hook`], read by
13    /// [`catching`] on the same thread when the message matches.
14    ///
15    /// Note that a pool worker waiting in a join can run another host's job and record its panic here. The message
16    /// check keeps that site off the catch in progress.
17    static LAST_PANIC: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
18}
19
20/// Fallback for a panic raised on a thread other than the one catching it, as `(message, site)`.
21///
22/// Every hot kernel runs under rayon, and rayon catches a worker's panic and re-raises it on the
23/// caller with `resume_unwind`, which does not run the hook a second time. Without this the modal
24/// loses the line for exactly the panics most worth locating.
25///
26/// Keyed by message and read newest first. A catch can pick up a site belonging to some other panic only when two
27/// panics on concurrent threads have the same message.
28static RECENT_PANIC_SITES: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
29
30/// Number of unclaimed sites kept before the oldest is dropped. Nothing reads a stale entry, and every
31/// claimed one is removed, so this only bounds what a burst can leave behind.
32const RECENT_PANIC_SITES_CAP: usize = 16;
33
34/// Phase of a fault raised while a host builds a model.
35pub const BUILDING: &str = "building the model";
36
37/// Phase of a fault raised while a built model advances a tick.
38pub const STEPPING: &str = "stepping the simulation";
39
40/// A failure Henad caught.
41#[derive(Debug)]
42pub struct Fault {
43    /// Phase the fault happened in, such as [`BUILDING`] or [`STEPPING`].
44    pub during: &'static str,
45    /// Kind of failure, with its details.
46    pub kind: FaultKind,
47}
48
49/// Kind of failure a [`Fault`] records.
50#[derive(Debug)]
51#[non_exhaustive]
52pub enum FaultKind {
53    /// A wgpu error, from an error scope or from the device's uncaptured error handler.
54    Device(wgpu::Error),
55    /// A panic out of model or engine code, caught by [`catching`].
56    Panic {
57        /// Message the panic carried.
58        message: String,
59        /// Source location of the panic, `None` when nothing installed [`install_panic_hook`].
60        location: Option<String>,
61    },
62    /// The host refused to build or step the model.
63    ///
64    /// A model incompatible with the device is rejected, and so is a [`Simulation`](crate::simulation::Simulation)
65    /// that already returned a fault.
66    Refused(String),
67    /// A failed wait for the GPU to finish its submitted work.
68    Poll(wgpu::PollError),
69    /// The GPU device was lost, and runs no more work.
70    ///
71    /// wgpu does not report a loss to any error scope.
72    /// [`GpuContext::is_lost`](crate::gpu::GpuContext::is_lost) reads it.
73    DeviceLost,
74}
75
76impl Fault {
77    /// Creates a fault for a wgpu error.
78    pub fn device(during: &'static str, error: wgpu::Error) -> Self {
79        Self {
80            during,
81            kind: FaultKind::Device(error),
82        }
83    }
84
85    /// Creates a [`FaultKind::Refused`] fault explained by `message`.
86    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    /// Creates a fault for a lost device.
94    pub fn device_lost(during: &'static str) -> Self {
95        Self {
96            during,
97            kind: FaultKind::DeviceLost,
98        }
99    }
100
101    /// Returns whether the device ran out of memory.
102    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
139/// Runs `f` and catches a panic out of it as a [`Fault`].
140///
141/// # Errors
142///
143/// Returns a [`FaultKind::Panic`] fault if `f` panics. The message comes from the panic payload, and the location is
144/// recorded once [`install_panic_hook`] has run.
145pub fn catching<T>(during: &'static str, f: impl FnOnce() -> T) -> Result<T, Fault> {
146    // A panic caught and swallowed inside `f` would otherwise leave its site here for the next panic caught.
147    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
160/// Installs a panic hook that records where each panic came from, then calls the hook that was already installed.
161///
162/// Stderr and test output are unchanged. Only the first call has an effect.
163pub 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
184/// Returns the location of the panic that carried `message`, if any.
185///
186/// The site this thread recorded wins when its message matches. Otherwise the newest site recorded with `message` on
187/// any thread is taken. Either way the site leaves the process-wide list.
188fn 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
209/// Returns the `&str` or `String` that a panic carried, or `"panicked"` for anything else.
210fn 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/// Slot for a fault raised away from a `Result` boundary, for the host to take each frame.
221///
222/// The sink keeps the first fault. A device error usually produces a cascade, and the first is the cause. A clone
223/// shares the slot.
224#[derive(Clone, Default)]
225pub struct FaultSink(Arc<Mutex<Option<Fault>>>);
226
227impl FaultSink {
228    /// Creates an empty sink.
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Stores `fault` unless the sink already holds one.
234    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    /// Takes the stored fault, leaving the sink empty.
243    pub fn take(&self) -> Option<Fault> {
244        self.0.lock().ok()?.take()
245    }
246
247    /// Returns whether the sink holds a fault.
248    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    /// Model code that divides by zero must not end the process.
276    #[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    /// Without the hook the modal can show the panic message but not the line it came from.
297    #[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    /// Every hot kernel runs under rayon, and rayon catches a worker's panic and re-raises it on
309    /// the caller with `resume_unwind`, which does not run the hook again. Without a fallback the
310    /// modal loses the line for exactly the panics most worth locating.
311    #[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    /// A pool worker waiting in a join can run another host's job. The site of a panic in that job stays on the
332    /// worker's thread, and must not be pinned onto the next panic caught there.
333    #[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    /// A stale location from an earlier caught panic must not be pinned onto a later one.
359    #[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    /// A device error cascades. Only the first fault is worth reporting.
409    #[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}