//! Host-owned shared-device failure forwarding. Device handlers belong to the executable or //! embedding host, never to a library borrowing its device. This bridge does no device work, //! document mutation, file I/O or renderer locking inside a notification. use std::sync::{Arc, Mutex}; type Notifier = Arc; #[derive(Clone, Debug, PartialEq, Eq)] pub struct GpuFailure { pub reason: String, /// Device loss requires restarting/recreating the presentation backend too. CPU /// compositing alone cannot make a lost eframe presentation device usable again. pub presentation_lost: bool, } #[derive(Default)] struct State { failure: Option, notifier: Option, } /// One bridge per host device. Failure is latched; duplicate reports cannot grow a queue. #[derive(Clone)] pub struct GpuFailureBridge { state: Arc>, ctx: egui::Context, } impl GpuFailureBridge { pub fn new(ctx: &egui::Context) -> Self { Self { state: Arc::default(), ctx: ctx.clone() } } pub fn failure(&self) -> Option { self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).failure.clone() } /// Forward to a compositor's weak notifier once construction succeeds. A fault that /// preceded registration is replayed; the compositor must not then be attached to the UI. pub fn forward_to(&self, notifier: impl Fn(String) + Send + Sync + 'static) { let notifier: Notifier = Arc::new(notifier); let failure = { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); state.notifier = Some(notifier.clone()); state.failure.clone() }; if let Some(failure) = failure { notify(¬ifier, failure.reason); } } /// Called by the device owner for an uncaptured error or device loss. A later device-loss /// notification escalates the recovery message without completing pending work twice. pub fn report(&self, reason: &str, presentation_lost: bool) -> bool { let (notification, changed) = { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(failure) = &mut state.failure { let changed = presentation_lost && !failure.presentation_lost; failure.presentation_lost |= presentation_lost; (None, changed) } else { let reason: String = reason.chars().take(2048).collect(); state.failure = Some(GpuFailure { reason: reason.clone(), presentation_lost }); (state.notifier.clone().map(|notifier| (notifier, reason)), true) } }; if let Some((notifier, reason)) = notification { notify(¬ifier, reason); } if changed { self.ctx.request_repaint(); } changed } } fn notify(notifier: &Notifier, reason: String) { // A host callback must never propagate a consumer panic into wgpu's error handling. #[cfg(not(target_arch = "wasm32"))] if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| notifier(reason))).is_err() { log::error!("GPU failure consumer panicked"); } #[cfg(target_arch = "wasm32")] notifier(reason); } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; #[test] fn a_construction_failure_is_replayed_after_notifier_registration() { let bridge = GpuFailureBridge::new(&egui::Context::default()); bridge.report("pipeline allocation failed", false); let observed = Arc::new(Mutex::new(Vec::new())); let copy = observed.clone(); let probe = Arc::downgrade(&bridge.state); bridge.forward_to(move |reason| { assert!(probe.upgrade().unwrap().try_lock().is_ok(), "consumer runs outside bridge lock"); copy.lock().unwrap().push(reason); }); assert_eq!(*observed.lock().unwrap(), ["pipeline allocation failed"]); bridge.report("duplicate", false); assert_eq!(observed.lock().unwrap().len(), 1); } #[test] fn a_late_failure_notifies_once_and_later_device_loss_escalates() { let bridge = GpuFailureBridge::new(&egui::Context::default()); let calls = Arc::new(AtomicUsize::new(0)); let copy = calls.clone(); bridge.forward_to(move |_| { copy.fetch_add(1, Ordering::SeqCst); }); assert!(bridge.failure().is_none()); bridge.report("uncaptured allocation failure", false); bridge.report("device lost", true); bridge.report("another error", false); assert_eq!(calls.load(Ordering::SeqCst), 1); let failure = bridge.failure().unwrap(); assert!(failure.presentation_lost); assert_eq!(failure.reason, "uncaptured allocation failure"); } #[test] fn diagnostics_are_bounded_and_native_consumer_panics_are_isolated() { let bridge = GpuFailureBridge::new(&egui::Context::default()); #[cfg(not(target_arch = "wasm32"))] bridge.forward_to(|_| panic!("synthetic consumer failure")); bridge.report(&"é".repeat(3000), false); assert_eq!(bridge.failure().unwrap().reason.chars().count(), 2048); bridge.report("device lost after consumer failure", true); assert!(bridge.failure().unwrap().presentation_lost); } #[test] #[cfg(not(target_arch = "wasm32"))] fn concurrent_registration_and_failure_deliver_exactly_once() { let bridge = GpuFailureBridge::new(&egui::Context::default()); let barrier = Arc::new(std::sync::Barrier::new(2)); let calls = Arc::new(AtomicUsize::new(0)); let reporter = bridge.clone(); let ready = barrier.clone(); let thread = std::thread::spawn(move || { ready.wait(); reporter.report("device failed during subscription", false); }); let count = calls.clone(); barrier.wait(); bridge.forward_to(move |_| { count.fetch_add(1, Ordering::SeqCst); }); thread.join().unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 1); } }