ext-php-rs 0.16.3

Bindings for the Zend API to build PHP extensions natively in Rust.
Documentation
use super::bailout_guard::{cleanup_depth, run_cleanups_above};
use crate::exception::{PhpException, PhpResult};
use crate::ffi::{
    ext_php_rs_zend_bailout, ext_php_rs_zend_first_try_catch, ext_php_rs_zend_try_catch,
};
use crate::zend::ce;
use std::any::Any;
use std::ffi::c_void;
use std::mem::{ManuallyDrop, MaybeUninit};
use std::panic::{AssertUnwindSafe, UnwindSafe, catch_unwind};
use std::ptr::null_mut;

/// Error returned when the engine did not run the closure to completion.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CatchError {
    /// The engine bailed out, unwinding past the closure.
    #[error("the engine bailed out")]
    Bailout,
    /// The closure result was never written back.
    #[error("the closure result pointer was null")]
    NullPanicPtr,
    /// The closure panicked. Holds the panic message; the frames inside the
    /// closure have already been unwound and their destructors run.
    #[error("the closure panicked: {0}")]
    Panic(String),
}

fn panic_message(payload: &(dyn Any + Send)) -> String {
    if let Some(s) = payload.downcast_ref::<&str>() {
        (*s).to_owned()
    } else if let Some(s) = payload.downcast_ref::<String>() {
        s.clone()
    } else {
        "non-string panic payload".to_owned()
    }
}

fn panic_exception(message: &str) -> PhpException {
    PhpException::new(format!("Rust panic: {message}"), 0, ce::error())
}

/// Runs the body of an `extern "C"` handler and keeps every Rust failure on the
/// Rust side of the boundary.
///
/// A bailout inside `func` drops the [`BailoutGuard`](crate::zend::BailoutGuard)s
/// of this frame and is re-raised to the enclosing `zend_try`. A panic inside
/// `func` has already unwound and run the destructors of every frame inside the
/// handler; it is reported to PHP as an `Error` exception whose message starts
/// with `Rust panic: `, and the handler returns normally. An exception that was
/// already pending is kept, the panic message then only reaches the panic hook.
///
/// The handlers generated by the macros use this. Hand-written handlers passed
/// to [`FunctionBuilder::new`](crate::builders::FunctionBuilder::new) must call
/// it themselves, since a panic escaping an `extern "C"` function aborts the
/// process.
pub fn run_handler<F: FnOnce() + UnwindSafe>(func: F) {
    match try_catch(func) {
        Ok(()) => {}
        Err(CatchError::Bailout) => unsafe { bailout() },
        Err(CatchError::Panic(message)) => panic_exception(&message).throw(),
        Err(err @ CatchError::NullPanicPtr) => panic_exception(&err.to_string()).throw(),
    }
}

/// Runs `func` under `catch_unwind` only, with no `zend_try`, and turns a panic
/// into the same `Error` exception [`run_handler`] throws. Used by the object
/// handlers, where a `setjmp` per property access is not acceptable.
pub(crate) fn catch_panic<R>(func: impl FnOnce() -> PhpResult<R>) -> PhpResult<R> {
    match catch_unwind(AssertUnwindSafe(func)) {
        Ok(result) => result,
        Err(payload) => Err(panic_exception(&panic_message(payload.as_ref()))),
    }
}

/// On a bailout the closure is never dropped, so the values it captured leak.
pub(crate) struct CatchFrame<R, F> {
    func: ManuallyDrop<F>,
    result: MaybeUninit<std::thread::Result<R>>,
}

impl<R, F: FnOnce() -> R + UnwindSafe> CatchFrame<R, F> {
    pub(crate) fn new(func: F) -> Self {
        Self {
            func: ManuallyDrop::new(func),
            result: MaybeUninit::uninit(),
        }
    }

    /// # Safety
    ///
    /// [`panic_wrapper`] must have returned for this frame.
    pub(crate) unsafe fn into_result(self) -> std::thread::Result<R> {
        unsafe { self.result.assume_init() }
    }
}

/// # Safety
///
/// `ctx` must point to a live `CatchFrame<R, F>` whose closure was not taken.
pub(crate) unsafe extern "C" fn panic_wrapper<R, F: FnOnce() -> R + UnwindSafe>(
    ctx: *const c_void,
) -> *const c_void {
    // SAFETY: `ctx` comes from `&raw mut` on a live `CatchFrame<R, F>`, and the
    // engine calls this exactly once per frame, so `func` is taken only once.
    let frame = unsafe { &mut *ctx.cast_mut().cast::<CatchFrame<R, F>>() };
    let func = unsafe { ManuallyDrop::take(&mut frame.func) };
    frame.result.write(catch_unwind(func));
    ctx
}

/// PHP proposes a try catch mechanism in C using setjmp and longjmp (bailout)
/// It stores the arg of setjmp into the bailout field of the global executor
/// If a bailout is triggered, the executor will jump to the setjmp and restore
/// the previous setjmp
///
/// [`try_catch`] allows to use this mechanism
///
/// On bailout, this function drops every [`BailoutGuard`](crate::zend::BailoutGuard)
/// that was created inside `func`, newest first, before it returns. The guards that
/// were created before the call are not changed.
///
/// A panic inside `func` is caught and reported as [`CatchError::Panic`]; it is
/// not resumed. Callers that want the panic to propagate call
/// [`std::panic::resume_unwind`] with their own payload.
///
/// # Returns
///
/// * The result of the function
///
/// # Errors
///
/// * [`CatchError::Bailout`] - A bailout occurred during the execution
/// * [`CatchError::Panic`] - The closure panicked
pub fn try_catch<R, F: FnOnce() -> R + UnwindSafe>(func: F) -> Result<R, CatchError> {
    do_try_catch(func, false)
}

/// PHP proposes a try catch mechanism in C using setjmp and longjmp (bailout)
/// It stores the arg of setjmp into the bailout field of the global executor
/// If a bailout is triggered, the executor will jump to the setjmp and restore
/// the previous setjmp
///
/// [`try_catch_first`] allows to use this mechanism
///
/// This functions differs from [`try_catch`] as it also initialize the bailout
/// mechanism for the first time
///
/// # Returns
///
/// * The result of the function
///
/// # Errors
///
/// * [`CatchError::Bailout`] - A bailout occurred during the execution
/// * [`CatchError::Panic`] - The closure panicked
pub fn try_catch_first<R, F: FnOnce() -> R + UnwindSafe>(func: F) -> Result<R, CatchError> {
    do_try_catch(func, true)
}

fn do_try_catch<R, F: FnOnce() -> R + UnwindSafe>(func: F, first: bool) -> Result<R, CatchError> {
    let mut frame = CatchFrame::new(func);
    let ctx = (&raw mut frame).cast::<c_void>().cast_const();
    let mut done = null_mut();
    let depth = cleanup_depth();
    let has_bailout = unsafe {
        if first {
            ext_php_rs_zend_first_try_catch(panic_wrapper::<R, F>, ctx, &raw mut done)
        } else {
            ext_php_rs_zend_try_catch(panic_wrapper::<R, F>, ctx, &raw mut done)
        }
    };

    if has_bailout {
        run_cleanups_above(depth);
        return Err(CatchError::Bailout);
    }

    if done.is_null() {
        return Err(CatchError::NullPanicPtr);
    }

    // SAFETY: `done` is non-null, so `panic_wrapper` returned for this frame.
    match unsafe { frame.into_result() } {
        Ok(r) => Ok(r),
        Err(payload) => Err(CatchError::Panic(panic_message(payload.as_ref()))),
    }
}

/// Trigger a bailout
///
/// This function will stop the execution of the current script
/// and jump to the last try catch block
///
/// # Safety
///
/// This function is unsafe because it can cause memory leaks
/// Since it will jump to the last try catch block, it will not call the
/// destructor of the current scope
///
/// When using this function you should ensure that all the memory allocated in
/// the current scope is released
pub unsafe fn bailout() -> ! {
    unsafe { ext_php_rs_zend_bailout() };
}

#[cfg(feature = "embed")]
#[cfg(test)]
mod tests {
    use super::CatchError;
    use crate::embed::Embed;
    use crate::zend::{BailoutGuard, bailout, try_catch};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    struct DropCounter(Arc<AtomicUsize>);
    impl Drop for DropCounter {
        fn drop(&mut self) {
            self.0.fetch_add(1, Ordering::SeqCst);
        }
    }

    #[test]
    fn bailout_drops_inner_guard_and_spares_outer() {
        let outer_drops = Arc::new(AtomicUsize::new(0));
        let inner_drops = Arc::new(AtomicUsize::new(0));
        let (caught, outer_alive_after) = Embed::run(|| {
            let outer = BailoutGuard::new(DropCounter(Arc::clone(&outer_drops)));
            let inner_drops = Arc::clone(&inner_drops);
            let caught = try_catch(move || {
                let _inner = BailoutGuard::new(DropCounter(inner_drops));
                unsafe { bailout() }
            });
            let outer_alive_after = outer.get().0.load(Ordering::SeqCst) == 0;
            drop(outer);
            (caught.is_err(), outer_alive_after)
        });

        assert!(caught);
        assert!(outer_alive_after);
        assert_eq!(inner_drops.load(Ordering::SeqCst), 1);
        assert_eq!(outer_drops.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn normal_return_drops_guard_once() {
        let drops = Arc::new(AtomicUsize::new(0));
        let ok = Embed::run(|| {
            let drops = Arc::clone(&drops);
            try_catch(move || {
                let _guard = BailoutGuard::new(DropCounter(drops));
            })
            .is_ok()
        });

        assert!(ok);
        assert_eq!(drops.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn test_catch() {
        let caught = Embed::run(|| {
            let catch = try_catch(|| unsafe { bailout() });
            matches!(catch, Err(CatchError::Bailout))
        });

        assert!(caught);
    }

    #[test]
    fn test_no_catch() {
        let ok = Embed::run(|| try_catch(|| 1).is_ok());

        assert!(ok);
    }

    #[test]
    fn test_bailout() {
        Embed::run(|| {
            unsafe {
                bailout();
            }

            #[allow(unreachable_code)]
            #[allow(clippy::assertions_on_constants)]
            {
                assert!(false);
            }
        });
    }

    #[test]
    fn test_panic_is_reported_not_resumed() {
        let message = Embed::run(|| match try_catch(|| panic!("should panic")) {
            Err(CatchError::Panic(message)) => message,
            other => format!("unexpected: {other:?}"),
        });

        assert_eq!(message, "should panic");
    }

    #[test]
    fn test_panic_payload_fallback() {
        let message = Embed::run(|| match try_catch(|| std::panic::panic_any(42)) {
            Err(CatchError::Panic(message)) => message,
            other => format!("unexpected: {other:?}"),
        });

        assert_eq!(message, "non-string panic payload");
    }

    #[test]
    fn test_return() {
        let foo = Embed::run(|| {
            let result = try_catch(|| "foo");

            assert!(result.is_ok());

            #[allow(clippy::unwrap_used)]
            result.unwrap()
        });

        assert_eq!(foo, "foo");
    }

    #[test]
    fn test_memory_leak() {
        let drops = Arc::new(AtomicUsize::new(0));
        Embed::run(|| {
            let drops = Arc::clone(&drops);
            let _ = try_catch(move || {
                let _unguarded = DropCounter(drops);
                unsafe { bailout() }
            });
        });

        assert_eq!(drops.load(Ordering::SeqCst), 0);
    }
}