Skip to main content

nautilus_plugin/
panic.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Catch-unwind wrapper used by every plug-in `extern "C"` thunk.
17//!
18//! Unwinding across an FFI boundary is undefined behaviour, so every host-bound
19//! call from a plug-in must be wrapped to convert a panic into a returned
20//! [`PluginError`] with code [`PluginErrorCode::Panic`].
21
22use std::panic::{AssertUnwindSafe, catch_unwind};
23
24use crate::boundary::{PluginError, PluginErrorCode, PluginResult};
25
26/// Wraps a closure in `catch_unwind` and maps a panic to a `PluginError`.
27///
28/// Macro-generated thunks call this so plug-in panics surface as errors instead
29/// of unwinding through the FFI.
30pub fn guard<T>(f: impl FnOnce() -> Result<T, PluginError>) -> PluginResult<T> {
31    let result = catch_unwind(AssertUnwindSafe(f));
32    match result {
33        Ok(Ok(t)) => PluginResult::Ok(t),
34        Ok(Err(e)) => PluginResult::Err(e),
35        Err(payload) => {
36            let message = panic_message(payload.as_ref());
37            drop_payload(payload);
38            PluginResult::Err(PluginError::new(PluginErrorCode::Panic, message))
39        }
40    }
41}
42
43/// Runs a closure under `catch_unwind` for thunks whose return type cannot
44/// carry a `PluginError` (e.g. `extern "C" fn(...) -> u64`).
45///
46/// On panic, logs the message and aborts the process. Aborting is the only
47/// sound option once a panic reaches this point: returning a sentinel would
48/// silently corrupt downstream computation, and unwinding across the FFI
49/// boundary is undefined behaviour.
50pub fn guard_infallible<T>(thunk_name: &str, f: impl FnOnce() -> T) -> T {
51    match catch_unwind(AssertUnwindSafe(f)) {
52        Ok(t) => t,
53        Err(payload) => {
54            let msg = panic_message(payload.as_ref());
55            drop_payload(payload);
56            log::error!(
57                target: "nautilus_plugin",
58                "plug-in panicked in `{thunk_name}` thunk; aborting process: {msg}",
59            );
60            std::process::abort();
61        }
62    }
63}
64
65/// Runs a closure under `catch_unwind` for thunks that return a raw pointer
66/// where null already signals failure (`create`, `clone_handle`).
67///
68/// On panic, logs the message and returns null so the host can surface a
69/// recoverable error instead of the process aborting.
70pub fn guard_or_null<T>(thunk_name: &str, f: impl FnOnce() -> *mut T) -> *mut T {
71    match catch_unwind(AssertUnwindSafe(f)) {
72        Ok(ptr) => ptr,
73        Err(payload) => {
74            let msg = panic_message(payload.as_ref());
75            drop_payload(payload);
76            // A panicking logger must not unwind out of the thunk; the
77            // null-return contract holds even when reporting fails.
78            let _ = catch_unwind(AssertUnwindSafe(|| {
79                log::error!(
80                    target: "nautilus_plugin",
81                    "plug-in panicked in `{thunk_name}` thunk; returning null: {msg}",
82                );
83            }));
84            std::ptr::null_mut()
85        }
86    }
87}
88
89/// Runs a destructor closure under `catch_unwind` for `drop_handle` thunks.
90///
91/// On panic, logs the message and returns normally, leaking whatever the
92/// destructor failed to release. A leaked value is recoverable; unwinding
93/// across the FFI boundary or aborting the process is not.
94pub fn guard_drop(thunk_name: &str, f: impl FnOnce()) {
95    if let Err(payload) = catch_unwind(AssertUnwindSafe(f)) {
96        let msg = panic_message(payload.as_ref());
97        drop_payload(payload);
98        // A panicking logger must not unwind out of the thunk; the
99        // swallow-and-leak contract holds even when reporting fails.
100        let _ = catch_unwind(AssertUnwindSafe(|| {
101            log::error!(
102                target: "nautilus_plugin",
103                "plug-in panicked in `{thunk_name}` thunk; value leaked: {msg}",
104            );
105        }));
106    }
107}
108
109/// Drops a panic payload while suppressing any unwind from its `Drop` impl.
110///
111/// `std::panic::catch_unwind` catches the original panic, but if the payload
112/// itself panics on drop the second panic unwinds the caller. For an
113/// `extern "C"` thunk that is undefined behaviour. Wrapping the drop in
114/// another `catch_unwind` keeps the surface around the FFI boundary
115/// unwind-free even with adversarial payloads (e.g. `panic_any(T)` where
116/// `T: Drop` panics).
117pub fn drop_payload(payload: Box<dyn std::any::Any + Send>) {
118    let _ = catch_unwind(AssertUnwindSafe(move || drop(payload)));
119}
120
121pub(crate) fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
122    if let Some(s) = payload.downcast_ref::<&'static str>() {
123        (*s).to_string()
124    } else if let Some(s) = payload.downcast_ref::<String>() {
125        s.clone()
126    } else {
127        "plug-in panicked with non-string payload".to_string()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::sync::atomic::{AtomicUsize, Ordering};
134
135    use rstest::rstest;
136
137    use super::*;
138
139    #[rstest]
140    fn returns_ok_on_success() {
141        let r = guard(|| Ok::<u32, PluginError>(7));
142        assert_eq!(r.into_result().unwrap(), 7);
143    }
144
145    #[rstest]
146    fn returns_err_on_returned_error() {
147        let r = guard(|| Err::<u32, _>(PluginError::generic("boom")));
148        let e = r.into_result().unwrap_err();
149        assert_eq!(e.code, PluginErrorCode::Generic);
150        assert_eq!(e.message_string(), "boom");
151    }
152
153    #[rstest]
154    fn returns_err_on_string_panic() {
155        let r = guard(|| -> Result<u32, PluginError> { panic!("oops") });
156        let e = r.into_result().unwrap_err();
157        assert_eq!(e.code, PluginErrorCode::Panic);
158        assert!(e.message_string().contains("oops"));
159    }
160
161    #[rstest]
162    fn returns_err_on_non_string_panic() {
163        let r = guard(|| -> Result<u32, PluginError> {
164            std::panic::panic_any(42_u32);
165        });
166        let e = r.into_result().unwrap_err();
167        assert_eq!(e.code, PluginErrorCode::Panic);
168        assert!(e.message_string().contains("non-string"));
169    }
170
171    #[rstest]
172    fn guard_infallible_returns_inner_on_success() {
173        let v = guard_infallible("test", || 42u64);
174        assert_eq!(v, 42);
175    }
176
177    #[rstest]
178    fn guard_or_null_returns_inner_on_success() {
179        let boxed = Box::into_raw(Box::new(7u32));
180        let v = guard_or_null("test", || boxed);
181        assert_eq!(v, boxed);
182        // SAFETY: pointer originates from Box::into_raw above.
183        unsafe { drop(Box::from_raw(boxed)) };
184    }
185
186    #[rstest]
187    fn guard_or_null_returns_null_on_panic() {
188        let v: *mut u32 = guard_or_null("test", || panic!("create panic"));
189        assert!(v.is_null());
190    }
191
192    #[rstest]
193    fn guard_drop_runs_inner_on_success() {
194        let mut ran = false;
195        guard_drop("test", || ran = true);
196        assert!(ran);
197    }
198
199    #[rstest]
200    fn guard_drop_swallows_panic() {
201        guard_drop("test", || panic!("drop panic"));
202    }
203
204    #[rstest]
205    fn drop_payload_swallows_panicking_drop() {
206        // `drop_payload` runs the payload Drop inside an inner `catch_unwind`
207        // so a panicking Drop does not propagate out of the function. This
208        // test asserts the call returns normally even when the payload
209        // panics on drop.
210        use std::{
211            any::Any,
212            sync::atomic::{AtomicUsize, Ordering},
213        };
214
215        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
216        struct Bomb;
217        impl Drop for Bomb {
218            fn drop(&mut self) {
219                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
220                panic!("drop panic");
221            }
222        }
223        DROPS_OBSERVED.store(0, Ordering::SeqCst);
224
225        let payload: Box<dyn Any + Send> = Box::new(Bomb);
226        drop_payload(payload);
227        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
228    }
229
230    #[rstest]
231    fn guard_survives_panic_any_with_panicking_drop() {
232        // Regression: a panic payload whose Drop also panics must not unwind
233        // past `catch_unwind`. `drop_payload` wraps the payload drop in a
234        // second `catch_unwind`; without it the second panic aborts the host
235        // or causes UB in the `extern "C"` thunk.
236        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
237        struct Bomb;
238        impl Drop for Bomb {
239            fn drop(&mut self) {
240                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
241                panic!("drop panic");
242            }
243        }
244
245        DROPS_OBSERVED.store(0, Ordering::SeqCst);
246        let r = guard(|| -> Result<u32, PluginError> {
247            std::panic::panic_any(Bomb);
248        });
249        let e = r.into_result().unwrap_err();
250        assert_eq!(e.code, PluginErrorCode::Panic);
251
252        // Drop ran inside the inner catch_unwind; observed exactly once
253        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
254    }
255}