Skip to main content

ext_php_rs/zend/
exception_observer.rs

1//! Zend Exception Hook bindings for PHP exception observation.
2//!
3//! Enables creation of exception loggers, APM integrations, and monitoring tools.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use ext_php_rs::prelude::*;
9//!
10//! struct MyExceptionLogger;
11//!
12//! impl ExceptionObserver for MyExceptionLogger {
13//!     fn on_exception(&self, exception: &ExceptionInfo) {
14//!         eprintln!("[EXCEPTION] {}: {}",
15//!             exception.class_name,
16//!             exception.message.as_deref().unwrap_or("<no message>")
17//!         );
18//!     }
19//! }
20//! ```
21
22use std::sync::OnceLock;
23use std::sync::atomic::{AtomicPtr, Ordering};
24
25use crate::ffi;
26
27/// Information about a PHP exception passed to observers.
28#[derive(Debug)]
29pub struct ExceptionInfo {
30    /// The exception class name (e.g., "Exception", "`RuntimeException`").
31    pub class_name: String,
32    /// The exception message.
33    pub message: Option<String>,
34    /// The exception code.
35    pub code: i64,
36    /// Source filename where the exception was thrown.
37    pub file: Option<String>,
38    /// Line number where the exception was thrown.
39    pub line: u32,
40}
41
42impl ExceptionInfo {
43    /// # Safety
44    ///
45    /// `exception` must be a valid pointer to a `zend_object` representing a Throwable.
46    unsafe fn from_zend_object(exception: *mut ffi::zend_object) -> Option<Self> {
47        if exception.is_null() {
48            return None;
49        }
50
51        let ex = unsafe { &*exception };
52
53        let class_name = if ex.ce.is_null() {
54            "Unknown".to_string()
55        } else {
56            let ce = unsafe { &*ex.ce };
57            if ce.name.is_null() {
58                "Unknown".to_string()
59            } else {
60                unsafe { zend_string_to_string(ce.name) }.unwrap_or_else(|| "Unknown".to_string())
61            }
62        };
63
64        let message = unsafe { read_exception_property(exception, "message") };
65        let code = unsafe { read_exception_property_long(exception, "code") };
66        let file = unsafe { read_exception_property(exception, "file") };
67        let line = unsafe { read_exception_property_long(exception, "line") }
68            .try_into()
69            .unwrap_or(0);
70
71        Some(Self {
72            class_name,
73            message,
74            code,
75            file,
76            line,
77        })
78    }
79
80    /// Captures the current PHP call stack at exception throw time.
81    ///
82    /// This is lazy - only captures when called, zero cost if unused.
83    /// Returns `None` if no execution context is available.
84    ///
85    /// # Example
86    ///
87    /// ```ignore
88    /// fn on_exception(&self, exception: &ExceptionInfo) {
89    ///     if let Some(trace) = exception.backtrace() {
90    ///         for frame in trace {
91    ///             eprintln!("  at {}:{} in {}",
92    ///                 frame.file.as_deref().unwrap_or("<internal>"),
93    ///                 frame.line,
94    ///                 frame.function.as_deref().unwrap_or("<main>")
95    ///             );
96    ///         }
97    ///     }
98    /// }
99    /// ```
100    #[must_use]
101    pub fn backtrace(&self) -> Option<Vec<super::BacktraceFrame>> {
102        let eg = unsafe { crate::ffi::ext_php_rs_executor_globals().as_ref()? };
103        let mut execute_data = eg.current_execute_data;
104
105        let mut frames = Vec::new();
106        while !execute_data.is_null() {
107            if let Some(frame) = unsafe { super::BacktraceFrame::from_execute_data(execute_data) } {
108                frames.push(frame);
109            }
110            execute_data = unsafe { (*execute_data).prev_execute_data };
111        }
112
113        if frames.is_empty() {
114            None
115        } else {
116            Some(frames)
117        }
118    }
119}
120
121/// # Safety
122///
123/// `exception` must be a valid pointer to a `zend_object`.
124unsafe fn read_exception_property(exception: *mut ffi::zend_object, name: &str) -> Option<String> {
125    let ce = unsafe { (*exception).ce };
126    if ce.is_null() {
127        return None;
128    }
129
130    let name_cstr = std::ffi::CString::new(name).ok()?;
131    let mut rv = std::mem::MaybeUninit::<ffi::zval>::uninit();
132
133    let prop = unsafe {
134        ffi::zend_read_property(
135            ce,
136            exception,
137            name_cstr.as_ptr(),
138            name.len(),
139            true,
140            rv.as_mut_ptr(),
141        )
142    };
143
144    if prop.is_null() {
145        return None;
146    }
147
148    let prop_ref = unsafe { &*prop };
149    let type_info = unsafe { prop_ref.u1.type_info };
150
151    if (type_info & 0xFF) == ffi::IS_STRING {
152        let zs = unsafe { prop_ref.value.str_ };
153        unsafe { zend_string_to_string(zs) }
154    } else {
155        None
156    }
157}
158
159/// # Safety
160///
161/// `exception` must be a valid pointer to a `zend_object`.
162unsafe fn read_exception_property_long(exception: *mut ffi::zend_object, name: &str) -> i64 {
163    let ce = unsafe { (*exception).ce };
164    if ce.is_null() {
165        return 0;
166    }
167
168    let Ok(name_cstr) = std::ffi::CString::new(name) else {
169        return 0;
170    };
171    let mut rv = std::mem::MaybeUninit::<ffi::zval>::uninit();
172
173    let prop = unsafe {
174        ffi::zend_read_property(
175            ce,
176            exception,
177            name_cstr.as_ptr(),
178            name.len(),
179            true,
180            rv.as_mut_ptr(),
181        )
182    };
183
184    if prop.is_null() {
185        return 0;
186    }
187
188    let prop_ref = unsafe { &*prop };
189    let type_info = unsafe { prop_ref.u1.type_info };
190
191    if (type_info & 0xFF) == ffi::IS_LONG {
192        unsafe { prop_ref.value.lval }
193    } else {
194        0
195    }
196}
197
198/// # Safety
199///
200/// Pointer must be valid and string must be valid UTF-8.
201unsafe fn zend_string_to_string(zs: *mut ffi::zend_string) -> Option<String> {
202    if zs.is_null() {
203        return None;
204    }
205    let len = unsafe { (*zs).len };
206    let ptr = unsafe { (*zs).val.as_ptr() };
207    let slice = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), len) };
208    std::str::from_utf8(slice).ok().map(String::from)
209}
210
211/// Trait for observing PHP exceptions.
212///
213/// # Lifecycle
214///
215/// `on_exception` is called whenever an exception is thrown in PHP,
216/// before any catch blocks are evaluated.
217///
218/// # Thread Safety
219///
220/// Observer must be `Send + Sync`. Use thread-safe primitives for mutable state.
221///
222/// # Example
223///
224/// ```ignore
225/// use ext_php_rs::prelude::*;
226/// use std::sync::atomic::{AtomicU64, Ordering};
227///
228/// struct ExceptionCounter {
229///     count: AtomicU64,
230/// }
231///
232/// impl ExceptionObserver for ExceptionCounter {
233///     fn on_exception(&self, exception: &ExceptionInfo) {
234///         self.count.fetch_add(1, Ordering::Relaxed);
235///         eprintln!("Exception #{}: {}",
236///             self.count.load(Ordering::Relaxed),
237///             exception.class_name
238///         );
239///     }
240/// }
241/// ```
242pub trait ExceptionObserver: 'static {
243    /// Called when an exception is thrown.
244    ///
245    /// This is called at throw time, before any catch blocks are evaluated.
246    /// The exception may or may not be caught.
247    fn on_exception(&self, exception: &ExceptionInfo);
248}
249
250type ExceptionObserverFactory =
251    Box<dyn Fn() -> Box<dyn ExceptionObserver + Send + Sync> + Send + Sync>;
252
253static EXCEPTION_OBSERVER_FACTORY: OnceLock<ExceptionObserverFactory> = OnceLock::new();
254static EXCEPTION_OBSERVER_INSTANCE: OnceLock<Box<dyn ExceptionObserver + Send + Sync>> =
255    OnceLock::new();
256
257type ThrowHook = unsafe extern "C" fn(*mut ffi::zend_object);
258
259static PREVIOUS_HOOK: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());
260
261fn get_exception_observer() -> Option<&'static (dyn ExceptionObserver + Send + Sync)> {
262    EXCEPTION_OBSERVER_INSTANCE
263        .get()
264        .map(std::convert::AsRef::as_ref)
265}
266
267/// # Safety
268///
269/// Called from PHP's C code when an exception is thrown.
270unsafe extern "C" fn exception_observer_callback(exception: *mut ffi::zend_object) {
271    if let Some(observer) = get_exception_observer()
272        && let Some(info) = (unsafe { ExceptionInfo::from_zend_object(exception) })
273    {
274        observer.on_exception(&info);
275    }
276
277    let prev = PREVIOUS_HOOK.load(Ordering::Acquire);
278    if !prev.is_null() {
279        // SAFETY: `PREVIOUS_HOOK` only ever holds null or a `ThrowHook` stored by
280        // `exception_observer_startup`, and function pointers are pointer-sized.
281        let prev = unsafe { std::mem::transmute::<*mut (), ThrowHook>(prev) };
282        unsafe { prev(exception) };
283    }
284}
285
286/// # Panics
287///
288/// Panics if called more than once.
289pub(crate) fn register_exception_observer_factory(factory: ExceptionObserverFactory) {
290    assert!(
291        EXCEPTION_OBSERVER_FACTORY.set(factory).is_ok(),
292        "exception_observer can only be registered once per extension"
293    );
294}
295
296/// # Safety
297///
298/// Must be called during MINIT phase only.
299pub(crate) unsafe fn exception_observer_startup() {
300    let Some(factory) = EXCEPTION_OBSERVER_FACTORY.get() else {
301        return;
302    };
303    EXCEPTION_OBSERVER_INSTANCE.get_or_init(factory);
304
305    let ours: ThrowHook = exception_observer_callback;
306    let prev = unsafe { ffi::zend_throw_exception_hook }
307        .filter(|&prev| !std::ptr::fn_addr_eq(prev, ours))
308        .map_or(std::ptr::null_mut(), |prev| prev as *mut ());
309    PREVIOUS_HOOK.store(prev, Ordering::Release);
310
311    unsafe {
312        ffi::zend_throw_exception_hook = Some(ours);
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    struct TestExceptionObserver;
321
322    unsafe impl Send for TestExceptionObserver {}
323    unsafe impl Sync for TestExceptionObserver {}
324
325    impl ExceptionObserver for TestExceptionObserver {
326        fn on_exception(&self, _exception: &ExceptionInfo) {}
327    }
328
329    #[test]
330    fn test_exception_info_fields() {
331        let info = ExceptionInfo {
332            class_name: "RuntimeException".to_string(),
333            message: Some("Test error".to_string()),
334            code: 42,
335            file: Some("/path/to/file.php".to_string()),
336            line: 100,
337        };
338
339        assert_eq!(info.class_name, "RuntimeException");
340        assert_eq!(info.message, Some("Test error".to_string()));
341        assert_eq!(info.code, 42);
342        assert_eq!(info.file, Some("/path/to/file.php".to_string()));
343        assert_eq!(info.line, 100);
344    }
345
346    #[test]
347    fn test_observer_trait_impl() {
348        let observer = TestExceptionObserver;
349        let info = ExceptionInfo {
350            class_name: "Exception".to_string(),
351            message: None,
352            code: 0,
353            file: None,
354            line: 0,
355        };
356        observer.on_exception(&info);
357    }
358}