Skip to main content

ext_php_rs/zend/
try_catch.rs

1use super::bailout_guard::{cleanup_depth, run_cleanups_above};
2use crate::exception::{PhpException, PhpResult};
3use crate::ffi::{
4    ext_php_rs_zend_bailout, ext_php_rs_zend_first_try_catch, ext_php_rs_zend_try_catch,
5};
6use crate::zend::ce;
7use std::any::Any;
8use std::ffi::c_void;
9use std::panic::{AssertUnwindSafe, UnwindSafe, catch_unwind};
10use std::ptr::null_mut;
11
12/// Error returned when the engine did not run the closure to completion.
13#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum CatchError {
16    /// The engine bailed out, unwinding past the closure.
17    #[error("the engine bailed out")]
18    Bailout,
19    /// The closure result was never written back.
20    #[error("the closure result pointer was null")]
21    NullPanicPtr,
22    /// The closure panicked. Holds the panic message; the frames inside the
23    /// closure have already been unwound and their destructors run.
24    #[error("the closure panicked: {0}")]
25    Panic(String),
26}
27
28fn panic_message(payload: &(dyn Any + Send)) -> String {
29    if let Some(s) = payload.downcast_ref::<&str>() {
30        (*s).to_owned()
31    } else if let Some(s) = payload.downcast_ref::<String>() {
32        s.clone()
33    } else {
34        "non-string panic payload".to_owned()
35    }
36}
37
38fn panic_exception(message: &str) -> PhpException {
39    PhpException::new(format!("Rust panic: {message}"), 0, ce::error())
40}
41
42/// Runs the body of an `extern "C"` handler and keeps every Rust failure on the
43/// Rust side of the boundary.
44///
45/// A bailout inside `func` drops the [`BailoutGuard`](crate::zend::BailoutGuard)s
46/// of this frame and is re-raised to the enclosing `zend_try`. A panic inside
47/// `func` has already unwound and run the destructors of every frame inside the
48/// handler; it is reported to PHP as an `Error` exception whose message starts
49/// with `Rust panic: `, and the handler returns normally. An exception that was
50/// already pending is kept, the panic message then only reaches the panic hook.
51///
52/// The handlers generated by the macros use this. Hand-written handlers passed
53/// to [`FunctionBuilder::new`](crate::builders::FunctionBuilder::new) must call
54/// it themselves, since a panic escaping an `extern "C"` function aborts the
55/// process.
56pub fn run_handler<F: FnOnce() + UnwindSafe>(func: F) {
57    match try_catch(func) {
58        Ok(()) => {}
59        Err(CatchError::Bailout) => unsafe { bailout() },
60        Err(CatchError::Panic(message)) => panic_exception(&message).throw(),
61        Err(err @ CatchError::NullPanicPtr) => panic_exception(&err.to_string()).throw(),
62    }
63}
64
65/// Runs `func` under `catch_unwind` only, with no `zend_try`, and turns a panic
66/// into the same `Error` exception [`run_handler`] throws. Used by the object
67/// handlers, where a `setjmp` per property access is not acceptable.
68pub(crate) fn catch_panic<R>(func: impl FnOnce() -> PhpResult<R>) -> PhpResult<R> {
69    match catch_unwind(AssertUnwindSafe(func)) {
70        Ok(result) => result,
71        Err(payload) => Err(panic_exception(&panic_message(payload.as_ref()))),
72    }
73}
74
75pub(crate) unsafe extern "C" fn panic_wrapper<R, F: FnOnce() -> R + UnwindSafe>(
76    ctx: *const c_void,
77) -> *const c_void {
78    // we try to catch panic here so we correctly shutdown php if it happens
79    // mandatory when we do assert on test as other test would not run correctly
80    // SAFETY: We read the closure from the pointer and consume it. This is safe because
81    // the closure is only called once.
82    let func = unsafe { std::ptr::read(ctx.cast::<F>()) };
83    let panic = catch_unwind(func);
84
85    Box::into_raw(Box::new(panic)).cast::<c_void>()
86}
87
88/// PHP proposes a try catch mechanism in C using setjmp and longjmp (bailout)
89/// It stores the arg of setjmp into the bailout field of the global executor
90/// If a bailout is triggered, the executor will jump to the setjmp and restore
91/// the previous setjmp
92///
93/// [`try_catch`] allows to use this mechanism
94///
95/// On bailout, this function drops every [`BailoutGuard`](crate::zend::BailoutGuard)
96/// that was created inside `func`, newest first, before it returns. The guards that
97/// were created before the call are not changed.
98///
99/// A panic inside `func` is caught and reported as [`CatchError::Panic`]; it is
100/// not resumed. Callers that want the panic to propagate call
101/// [`std::panic::resume_unwind`] with their own payload.
102///
103/// # Returns
104///
105/// * The result of the function
106///
107/// # Errors
108///
109/// * [`CatchError::Bailout`] - A bailout occurred during the execution
110/// * [`CatchError::Panic`] - The closure panicked
111pub fn try_catch<R, F: FnOnce() -> R + UnwindSafe>(func: F) -> Result<R, CatchError> {
112    do_try_catch(func, false)
113}
114
115/// PHP proposes a try catch mechanism in C using setjmp and longjmp (bailout)
116/// It stores the arg of setjmp into the bailout field of the global executor
117/// If a bailout is triggered, the executor will jump to the setjmp and restore
118/// the previous setjmp
119///
120/// [`try_catch_first`] allows to use this mechanism
121///
122/// This functions differs from [`try_catch`] as it also initialize the bailout
123/// mechanism for the first time
124///
125/// # Returns
126///
127/// * The result of the function
128///
129/// # Errors
130///
131/// * [`CatchError::Bailout`] - A bailout occurred during the execution
132/// * [`CatchError::Panic`] - The closure panicked
133pub fn try_catch_first<R, F: FnOnce() -> R + UnwindSafe>(func: F) -> Result<R, CatchError> {
134    do_try_catch(func, true)
135}
136
137fn do_try_catch<R, F: FnOnce() -> R + UnwindSafe>(func: F, first: bool) -> Result<R, CatchError> {
138    let mut panic_ptr = null_mut();
139    let depth = cleanup_depth();
140    let has_bailout = unsafe {
141        if first {
142            ext_php_rs_zend_first_try_catch(
143                panic_wrapper::<R, F>,
144                (&raw const func).cast::<c_void>(),
145                &raw mut panic_ptr,
146            )
147        } else {
148            ext_php_rs_zend_try_catch(
149                panic_wrapper::<R, F>,
150                (&raw const func).cast::<c_void>(),
151                &raw mut panic_ptr,
152            )
153        }
154    };
155
156    // Prevent the closure from being dropped here since it was consumed in panic_wrapper
157    std::mem::forget(func);
158
159    let panic = panic_ptr.cast::<std::thread::Result<R>>();
160
161    if has_bailout {
162        run_cleanups_above(depth);
163        return Err(CatchError::Bailout);
164    }
165
166    if panic.is_null() {
167        return Err(CatchError::NullPanicPtr);
168    }
169
170    match unsafe { *Box::from_raw(panic.cast::<std::thread::Result<R>>()) } {
171        Ok(r) => Ok(r),
172        Err(payload) => Err(CatchError::Panic(panic_message(payload.as_ref()))),
173    }
174}
175
176/// Trigger a bailout
177///
178/// This function will stop the execution of the current script
179/// and jump to the last try catch block
180///
181/// # Safety
182///
183/// This function is unsafe because it can cause memory leaks
184/// Since it will jump to the last try catch block, it will not call the
185/// destructor of the current scope
186///
187/// When using this function you should ensure that all the memory allocated in
188/// the current scope is released
189pub unsafe fn bailout() -> ! {
190    unsafe { ext_php_rs_zend_bailout() };
191}
192
193#[cfg(feature = "embed")]
194#[cfg(test)]
195mod tests {
196    use super::CatchError;
197    use crate::embed::Embed;
198    use crate::zend::{BailoutGuard, bailout, try_catch};
199    use std::sync::Arc;
200    use std::sync::atomic::{AtomicUsize, Ordering};
201
202    struct DropCounter(Arc<AtomicUsize>);
203    impl Drop for DropCounter {
204        fn drop(&mut self) {
205            self.0.fetch_add(1, Ordering::SeqCst);
206        }
207    }
208
209    #[test]
210    fn bailout_drops_inner_guard_and_spares_outer() {
211        let outer_drops = Arc::new(AtomicUsize::new(0));
212        let inner_drops = Arc::new(AtomicUsize::new(0));
213        let (caught, outer_alive_after) = Embed::run(|| {
214            let outer = BailoutGuard::new(DropCounter(Arc::clone(&outer_drops)));
215            let inner_drops = Arc::clone(&inner_drops);
216            let caught = try_catch(move || {
217                let _inner = BailoutGuard::new(DropCounter(inner_drops));
218                unsafe { bailout() }
219            });
220            let outer_alive_after = outer.get().0.load(Ordering::SeqCst) == 0;
221            drop(outer);
222            (caught.is_err(), outer_alive_after)
223        });
224
225        assert!(caught);
226        assert!(outer_alive_after);
227        assert_eq!(inner_drops.load(Ordering::SeqCst), 1);
228        assert_eq!(outer_drops.load(Ordering::SeqCst), 1);
229    }
230
231    #[test]
232    fn normal_return_drops_guard_once() {
233        let drops = Arc::new(AtomicUsize::new(0));
234        let ok = Embed::run(|| {
235            let drops = Arc::clone(&drops);
236            try_catch(move || {
237                let _guard = BailoutGuard::new(DropCounter(drops));
238            })
239            .is_ok()
240        });
241
242        assert!(ok);
243        assert_eq!(drops.load(Ordering::SeqCst), 1);
244    }
245
246    #[test]
247    fn test_catch() {
248        let caught = Embed::run(|| {
249            let catch = try_catch(|| unsafe { bailout() });
250            matches!(catch, Err(CatchError::Bailout))
251        });
252
253        assert!(caught);
254    }
255
256    #[test]
257    fn test_no_catch() {
258        let ok = Embed::run(|| try_catch(|| 1).is_ok());
259
260        assert!(ok);
261    }
262
263    #[test]
264    fn test_bailout() {
265        Embed::run(|| {
266            unsafe {
267                bailout();
268            }
269
270            #[allow(unreachable_code)]
271            #[allow(clippy::assertions_on_constants)]
272            {
273                assert!(false);
274            }
275        });
276    }
277
278    #[test]
279    fn test_panic_is_reported_not_resumed() {
280        let message = Embed::run(|| match try_catch(|| panic!("should panic")) {
281            Err(CatchError::Panic(message)) => message,
282            other => format!("unexpected: {other:?}"),
283        });
284
285        assert_eq!(message, "should panic");
286    }
287
288    #[test]
289    fn test_panic_payload_fallback() {
290        let message = Embed::run(|| match try_catch(|| std::panic::panic_any(42)) {
291            Err(CatchError::Panic(message)) => message,
292            other => format!("unexpected: {other:?}"),
293        });
294
295        assert_eq!(message, "non-string panic payload");
296    }
297
298    #[test]
299    fn test_return() {
300        let foo = Embed::run(|| {
301            let result = try_catch(|| "foo");
302
303            assert!(result.is_ok());
304
305            #[allow(clippy::unwrap_used)]
306            result.unwrap()
307        });
308
309        assert_eq!(foo, "foo");
310    }
311
312    #[test]
313    fn test_memory_leak() {
314        let drops = Arc::new(AtomicUsize::new(0));
315        Embed::run(|| {
316            let drops = Arc::clone(&drops);
317            let _ = try_catch(move || {
318                let _unguarded = DropCounter(drops);
319                unsafe { bailout() }
320            });
321        });
322
323        assert_eq!(drops.load(Ordering::SeqCst), 0);
324    }
325}