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