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/// Drops a panic payload while suppressing any unwind from its `Drop` impl.
66///
67/// `std::panic::catch_unwind` catches the original panic, but if the payload
68/// itself panics on drop the second panic unwinds the caller. For an
69/// `extern "C"` thunk that is undefined behaviour. Wrapping the drop in
70/// another `catch_unwind` keeps the surface around the FFI boundary
71/// unwind-free even with adversarial payloads (e.g. `panic_any(T)` where
72/// `T: Drop` panics).
73pub fn drop_payload(payload: Box<dyn std::any::Any + Send>) {
74    let _ = catch_unwind(AssertUnwindSafe(move || drop(payload)));
75}
76
77fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
78    if let Some(s) = payload.downcast_ref::<&'static str>() {
79        (*s).to_string()
80    } else if let Some(s) = payload.downcast_ref::<String>() {
81        s.clone()
82    } else {
83        "plug-in panicked with non-string payload".to_string()
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use std::sync::atomic::{AtomicUsize, Ordering};
90
91    use rstest::rstest;
92
93    use super::*;
94
95    #[rstest]
96    fn returns_ok_on_success() {
97        let r = guard(|| Ok::<u32, PluginError>(7));
98        assert_eq!(r.into_result().unwrap(), 7);
99    }
100
101    #[rstest]
102    fn returns_err_on_returned_error() {
103        let r = guard(|| Err::<u32, _>(PluginError::generic("boom")));
104        let e = r.into_result().unwrap_err();
105        assert_eq!(e.code, PluginErrorCode::Generic);
106        assert_eq!(e.message_string(), "boom");
107    }
108
109    #[rstest]
110    fn returns_err_on_string_panic() {
111        let r = guard(|| -> Result<u32, PluginError> { panic!("oops") });
112        let e = r.into_result().unwrap_err();
113        assert_eq!(e.code, PluginErrorCode::Panic);
114        assert!(e.message_string().contains("oops"));
115    }
116
117    #[rstest]
118    fn returns_err_on_non_string_panic() {
119        let r = guard(|| -> Result<u32, PluginError> {
120            std::panic::panic_any(42_u32);
121        });
122        let e = r.into_result().unwrap_err();
123        assert_eq!(e.code, PluginErrorCode::Panic);
124        assert!(e.message_string().contains("non-string"));
125    }
126
127    #[rstest]
128    fn guard_infallible_returns_inner_on_success() {
129        let v = guard_infallible("test", || 42u64);
130        assert_eq!(v, 42);
131    }
132
133    #[rstest]
134    fn drop_payload_swallows_panicking_drop() {
135        // `drop_payload` runs the payload Drop inside an inner `catch_unwind`
136        // so a panicking Drop does not propagate out of the function. This
137        // test asserts the call returns normally even when the payload
138        // panics on drop.
139        use std::{
140            any::Any,
141            sync::atomic::{AtomicUsize, Ordering},
142        };
143
144        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
145        struct Bomb;
146        impl Drop for Bomb {
147            fn drop(&mut self) {
148                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
149                panic!("drop panic");
150            }
151        }
152        DROPS_OBSERVED.store(0, Ordering::SeqCst);
153
154        let payload: Box<dyn Any + Send> = Box::new(Bomb);
155        drop_payload(payload);
156        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
157    }
158
159    #[rstest]
160    fn guard_survives_panic_any_with_panicking_drop() {
161        // Regression: a panic payload whose Drop also panics must not unwind
162        // past `catch_unwind`. `drop_payload` wraps the payload drop in a
163        // second `catch_unwind`; without it the second panic aborts the host
164        // or causes UB in the `extern "C"` thunk.
165        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
166        struct Bomb;
167        impl Drop for Bomb {
168            fn drop(&mut self) {
169                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
170                panic!("drop panic");
171            }
172        }
173
174        DROPS_OBSERVED.store(0, Ordering::SeqCst);
175        let r = guard(|| -> Result<u32, PluginError> {
176            std::panic::panic_any(Bomb);
177        });
178        let e = r.into_result().unwrap_err();
179        assert_eq!(e.code, PluginErrorCode::Panic);
180
181        // Drop ran inside the inner catch_unwind; observed exactly once
182        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
183    }
184}