Skip to main content

doom_fish_utils/
panic_safe.rs

1//! Panic-safety helpers for the C ABI boundary.
2//!
3//! Rust panics that unwind across `extern "C"` into Swift are undefined
4//! behaviour. Extern callbacks that invoke user code must use the
5//! appropriate helper in this module to catch supported panics and report
6//! a best-effort diagnostic without unwinding into their caller.
7//!
8//! A destructor that panics while another panic is already unwinding
9//! aborts the process before `catch_unwind` can recover. Callbacks with
10//! potentially panicking teardown state must use
11//! [`catch_user_panic_result_with_cleanup`] so callback execution,
12//! cleanup, and best-effort destruction happen in explicit phases.
13//!
14//! No helper can safely contain multiple destructor panics from one opaque
15//! aggregate without leaking arbitrary user state. Callback closures,
16//! cleanup closures, state, results, panic payloads, and body-local values
17//! must uphold Rust's standard destructor invariant: their aggregate
18//! destruction must not produce a second panic while already unwinding.
19//!
20//! This is intentionally a single shared helper rather than ad-hoc
21//! `catch_unwind` calls so the diagnostic format and the
22//! payload-destruction defence-in-depth stay consistent.
23
24use std::any::Any;
25use std::panic::AssertUnwindSafe;
26
27/// Run `f` and swallow any panic it produces.
28///
29/// On panic, writes a best-effort diagnostic to stderr identifying the
30/// callback site and the panic message (when the payload is a `&str` or
31/// `String`). Diagnostics and panic-payload destruction are contained by
32/// an outer unwind boundary. A panic payload whose destruction produces one
33/// panic is contained; multiple panicking destructors within one payload
34/// aggregate can still abort as required by Rust's double-panic semantics.
35///
36/// `AssertUnwindSafe` is required because trait objects are not
37/// generally `UnwindSafe` and we accept the user's responsibility for
38/// their own state consistency on panic.
39///
40/// # Panicking capture destructors
41///
42/// This function cannot recover if `f` panics and destruction of one of
43/// its captures also panics during that unwind; Rust aborts on that double
44/// panic. Keep potentially panicking teardown state out of `f` and use
45/// [`catch_user_panic_result_with_cleanup`] instead.
46///
47/// `f`, its captures, body-local values, and its panic payload must not
48/// produce multiple destructor panics during one aggregate destruction.
49pub fn catch_user_panic<F: FnOnce()>(site: &str, f: F) {
50    let _ = catch_user_panic_result(site, f);
51}
52
53/// Run a result-returning callback and convert any contained panic to `None`.
54///
55/// Returns `Some(result)` when `f` completes normally. On panic, reports a
56/// best-effort diagnostic, contains panic-payload destruction, and returns
57/// `None` so the caller can provide the ABI-appropriate fallback value.
58///
59/// Like [`catch_user_panic`], this cannot contain a capture destructor
60/// that panics while `f` is already unwinding. Use
61/// [`catch_user_panic_result_with_cleanup`] for potentially panicking
62/// teardown state.
63///
64/// This helper contains one panic from panic-payload destruction, but cannot
65/// contain multiple panicking fields within one opaque aggregate.
66#[must_use = "return an ABI-safe fallback when the callback panics"]
67pub fn catch_user_panic_result<R, F: FnOnce() -> R>(site: &str, f: F) -> Option<R> {
68    let boundary_result = std::panic::catch_unwind(AssertUnwindSafe(|| {
69        match std::panic::catch_unwind(AssertUnwindSafe(f)) {
70            Ok(result) => Some(result),
71            Err(payload) => {
72                log_callback_panic(site, payload.as_ref());
73                drop(payload);
74                None
75            }
76        }
77    }));
78
79    match boundary_result {
80        Ok(result) => result,
81        Err(payload) => {
82            log_callback_panic(site, payload.as_ref());
83            drop_payload_best_effort(payload);
84            None
85        }
86    }
87}
88
89/// Run a callback and an explicit library-owned cleanup phase.
90///
91/// `state`, `f`, and `cleanup` are retained in this function's frame while
92/// each closure body is invoked by mutable reference. The cleanup body runs
93/// immediately after the callback boundary and before any attempt to destroy
94/// the callback closure or state. The callback closure, cleanup closure, and
95/// state are then destroyed one at a time under best-effort boundaries.
96///
97/// Returns `Some(result)` only when the callback body, callback-closure
98/// destruction, cleanup body, cleanup-closure destruction, and state
99/// destruction all complete normally. If a later phase fails after the
100/// callback produced a result, that result is also destroyed under a separate
101/// boundary before this function returns `None`.
102///
103/// This sequencing does not make arbitrary user destruction safe. `F`, `C`,
104/// `S`, `R`, their fields, and body-local values must uphold the standard
105/// destructor invariant: one opaque aggregate destruction must not produce
106/// multiple panics. A second destructor panic during the same drop glue aborts
107/// before `catch_unwind` can recover.
108#[must_use = "return an ABI-safe fallback when any protected phase panics"]
109pub fn catch_user_panic_result_with_cleanup<S, R, F, C>(
110    site: &str,
111    mut state: S,
112    mut f: F,
113    mut cleanup: C,
114) -> Option<R>
115where
116    F: FnMut(&mut S) -> R,
117    C: FnMut(&mut S),
118{
119    let callback_result = catch_user_panic_result(site, || f(&mut state));
120    let cleanup_succeeded = catch_user_panic_result(site, || cleanup(&mut state)).is_some();
121    let callback_drop_succeeded = catch_user_panic_result(site, || drop(f)).is_some();
122    let cleanup_drop_succeeded = catch_user_panic_result(site, || drop(cleanup)).is_some();
123    let state_drop_succeeded = catch_user_panic_result(site, || drop(state)).is_some();
124
125    if callback_drop_succeeded
126        && cleanup_succeeded
127        && cleanup_drop_succeeded
128        && state_drop_succeeded
129    {
130        callback_result
131    } else {
132        if let Some(result) = callback_result {
133            catch_user_panic(site, || drop(result));
134        }
135        None
136    }
137}
138
139/// Best-effort logger for panics caught at the C ABI boundary.
140///
141/// Public to support call sites that already have a panic payload
142/// (e.g. those that need to dispatch multiple callbacks individually).
143/// Most callers want [`catch_user_panic`] instead.
144pub fn log_callback_panic(site: &str, payload: &(dyn Any + Send)) {
145    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
146        let message = payload.downcast_ref::<&'static str>().map_or_else(
147            || {
148                payload
149                    .downcast_ref::<String>()
150                    .map_or("<non-string panic payload>", String::as_str)
151            },
152            |message| *message,
153        );
154        eprintln!("doom-fish-utils: panic in {site} caught at C ABI boundary: {message}");
155    }));
156    if let Err(payload) = result {
157        drop_payload_best_effort(payload);
158    }
159}
160
161fn drop_payload_best_effort(payload: Box<dyn Any + Send>) {
162    if let Err(undroppable_payload) = std::panic::catch_unwind(AssertUnwindSafe(|| drop(payload))) {
163        // Leaking only this secondary payload avoids another attempted drop.
164        // Multiple panicking fields in the original aggregate can still abort
165        // before catch_unwind returns, as required by Rust's drop semantics.
166        std::mem::forget(undroppable_payload);
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::panic::{catch_unwind, panic_any, AssertUnwindSafe};
173    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
174    use std::sync::Arc;
175
176    use super::{catch_user_panic, catch_user_panic_result, catch_user_panic_result_with_cleanup};
177
178    struct DropFlag(Arc<AtomicBool>);
179
180    impl Drop for DropFlag {
181        fn drop(&mut self) {
182            self.0.store(true, Ordering::SeqCst);
183        }
184    }
185
186    #[test]
187    fn normal_panic_payload_is_dropped() {
188        let dropped = Arc::new(AtomicBool::new(false));
189        let payload = DropFlag(Arc::clone(&dropped));
190
191        catch_user_panic("normal_panic_payload_is_dropped", move || {
192            panic_any(payload);
193        });
194
195        assert!(dropped.load(Ordering::SeqCst));
196    }
197
198    #[test]
199    fn result_helper_preserves_success_and_maps_panic_to_none() {
200        assert_eq!(catch_user_panic_result("result success", || 42), Some(42));
201        assert_eq!(
202            catch_user_panic_result("result panic", || -> u32 {
203                panic!("result callback panic");
204            }),
205            None
206        );
207    }
208
209    #[test]
210    fn cleanup_helper_preserves_successful_result() {
211        let cleanup_ran = Arc::new(AtomicBool::new(false));
212        let cleanup_ran_in_closure = Arc::clone(&cleanup_ran);
213
214        let result = catch_user_panic_result_with_cleanup(
215            "cleanup_helper_preserves_successful_result",
216            40_u32,
217            |state| *state + 2,
218            move |_state| {
219                cleanup_ran_in_closure.store(true, Ordering::SeqCst);
220            },
221        );
222
223        assert_eq!(result, Some(42));
224        assert!(cleanup_ran.load(Ordering::SeqCst));
225    }
226
227    #[test]
228    fn captured_release_runs_during_callback_unwind() {
229        let released = Arc::new(AtomicBool::new(false));
230        let guard = DropFlag(Arc::clone(&released));
231
232        catch_user_panic("captured_release_runs_during_callback_unwind", move || {
233            let _guard = guard;
234            panic!("callback panic");
235        });
236
237        assert!(released.load(Ordering::SeqCst));
238    }
239
240    struct PanicOnDrop;
241
242    impl Drop for PanicOnDrop {
243        fn drop(&mut self) {
244            panic!("panic while dropping panic payload");
245        }
246    }
247
248    #[test]
249    fn single_panic_payload_destructor_is_contained() {
250        let result = catch_unwind(AssertUnwindSafe(|| {
251            catch_user_panic("single_panic_payload_destructor_is_contained", || {
252                panic_any(PanicOnDrop);
253            });
254        }));
255
256        assert!(result.is_ok());
257    }
258
259    struct ReleaseGuard {
260        released: Arc<AtomicBool>,
261    }
262
263    impl Drop for ReleaseGuard {
264        fn drop(&mut self) {
265            self.released.store(true, Ordering::SeqCst);
266            panic!("panic in captured release");
267        }
268    }
269
270    #[test]
271    fn single_capture_drop_panic_after_normal_return_is_contained() {
272        let released = Arc::new(AtomicBool::new(false));
273        let guard = ReleaseGuard {
274            released: Arc::clone(&released),
275        };
276
277        let result = catch_unwind(AssertUnwindSafe(|| {
278            catch_user_panic(
279                "single_capture_drop_panic_after_normal_return_is_contained",
280                move || {
281                    let _guard = guard;
282                },
283            );
284        }));
285
286        assert!(result.is_ok());
287        assert!(released.load(Ordering::SeqCst));
288    }
289
290    struct OrderedPanicOnDrop {
291        sequence: Arc<AtomicUsize>,
292        drop_order: Arc<AtomicUsize>,
293    }
294
295    impl Drop for OrderedPanicOnDrop {
296        fn drop(&mut self) {
297            let order = self.sequence.fetch_add(1, Ordering::SeqCst) + 1;
298            self.drop_order.store(order, Ordering::SeqCst);
299            panic!("single callback closure drop panic");
300        }
301    }
302
303    #[test]
304    fn cleanup_runs_before_single_callback_closure_drop_panic() {
305        let sequence = Arc::new(AtomicUsize::new(0));
306        let cleanup_order = Arc::new(AtomicUsize::new(0));
307        let drop_order = Arc::new(AtomicUsize::new(0));
308        let guard = OrderedPanicOnDrop {
309            sequence: Arc::clone(&sequence),
310            drop_order: Arc::clone(&drop_order),
311        };
312        let cleanup_sequence = Arc::clone(&sequence);
313        let cleanup_order_in_closure = Arc::clone(&cleanup_order);
314
315        let survived = catch_unwind(AssertUnwindSafe(|| {
316            let result = catch_user_panic_result_with_cleanup(
317                "cleanup_runs_before_single_callback_closure_drop_panic",
318                (),
319                move |_state| -> u32 {
320                    let _guard = &guard;
321                    panic!("callback panic");
322                },
323                move |_state| {
324                    let order = cleanup_sequence.fetch_add(1, Ordering::SeqCst) + 1;
325                    cleanup_order_in_closure.store(order, Ordering::SeqCst);
326                },
327            );
328            assert_eq!(result, None);
329        }));
330
331        assert!(survived.is_ok());
332        assert_eq!(cleanup_order.load(Ordering::SeqCst), 1);
333        assert_eq!(drop_order.load(Ordering::SeqCst), 2);
334    }
335}