diff --git a/Cargo.toml b/Cargo.toml index efc4a5641..852308523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,11 @@ name = "observer" crate-type = ["cdylib"] required-features = ["observer"] +[[example]] +name = "zend_extension" +crate-type = ["cdylib"] +required-features = ["observer"] + [[test]] name = "guide_tests" path = "tests/guide.rs" diff --git a/Dockerfile b/Dockerfile index c0bb4c7f5..316ecc0f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN rustup component add rustfmt RUN --mount=type=bind,target=/src,rw < Result<()> { let mut defines = provider.get_defines()?; add_php_version_defines(&mut defines, &info)?; + #[cfg(feature = "observer")] + defines.push(("EXT_PHP_RS_OBSERVER", "1")); + check_php_version(&info)?; build_wrapper(&defines, &includes)?; diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index dc18f2f02..1e2116f24 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -3853,3 +3853,94 @@ pub type zend_observer_error_cb = ::std::option::Option< unsafe extern "C" { pub fn zend_observer_error_register(callback: zend_observer_error_cb); } +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct _zend_extension_version_info { + pub zend_extension_api_no: ::std::os::raw::c_int, + pub build_id: *const ::std::os::raw::c_char, +} +pub type zend_extension_version_info = _zend_extension_version_info; +pub type zend_extension = _zend_extension; +pub type startup_func_t = ::std::option::Option< + unsafe extern "C" fn(extension: *mut zend_extension) -> ::std::os::raw::c_int, +>; +pub type shutdown_func_t = + ::std::option::Option; +pub type activate_func_t = ::std::option::Option; +pub type deactivate_func_t = ::std::option::Option; +pub type message_handler_func_t = ::std::option::Option< + unsafe extern "C" fn(message: ::std::os::raw::c_int, arg: *mut ::std::os::raw::c_void), +>; +pub type op_array_handler_func_t = + ::std::option::Option; +pub type statement_handler_func_t = + ::std::option::Option; +pub type fcall_begin_handler_func_t = + ::std::option::Option; +pub type fcall_end_handler_func_t = + ::std::option::Option; +pub type op_array_ctor_func_t = + ::std::option::Option; +pub type op_array_dtor_func_t = + ::std::option::Option; +pub type op_array_persist_calc_func_t = + ::std::option::Option usize>; +pub type op_array_persist_func_t = ::std::option::Option< + unsafe extern "C" fn(op_array: *mut zend_op_array, mem: *mut ::std::os::raw::c_void) -> usize, +>; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct _zend_extension { + pub name: *const ::std::os::raw::c_char, + pub version: *const ::std::os::raw::c_char, + pub author: *const ::std::os::raw::c_char, + pub URL: *const ::std::os::raw::c_char, + pub copyright: *const ::std::os::raw::c_char, + pub startup: startup_func_t, + pub shutdown: shutdown_func_t, + pub activate: activate_func_t, + pub deactivate: deactivate_func_t, + pub message_handler: message_handler_func_t, + pub op_array_handler: op_array_handler_func_t, + pub statement_handler: statement_handler_func_t, + pub fcall_begin_handler: fcall_begin_handler_func_t, + pub fcall_end_handler: fcall_end_handler_func_t, + pub op_array_ctor: op_array_ctor_func_t, + pub op_array_dtor: op_array_dtor_func_t, + pub api_no_check: ::std::option::Option< + unsafe extern "C" fn(api_no: ::std::os::raw::c_int) -> ::std::os::raw::c_int, + >, + pub build_id_check: ::std::option::Option< + unsafe extern "C" fn(build_id: *const ::std::os::raw::c_char) -> ::std::os::raw::c_int, + >, + pub op_array_persist_calc: op_array_persist_calc_func_t, + pub op_array_persist: op_array_persist_func_t, + pub reserved5: *mut ::std::os::raw::c_void, + pub reserved6: *mut ::std::os::raw::c_void, + pub reserved7: *mut ::std::os::raw::c_void, + pub reserved8: *mut ::std::os::raw::c_void, + pub handle: *mut ::std::os::raw::c_void, + pub resource_number: ::std::os::raw::c_int, +} +unsafe extern "C" { + pub fn zend_get_resource_handle( + module_name: *const ::std::os::raw::c_char, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn zend_get_op_array_extension_handle( + module_name: *const ::std::os::raw::c_char, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn zend_get_op_array_extension_handles( + module_name: *const ::std::os::raw::c_char, + handles: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn zend_register_extension( + new_extension: *mut zend_extension, + handle: *mut ::std::os::raw::c_void, + ); +} diff --git a/examples/zend_extension.rs b/examples/zend_extension.rs new file mode 100644 index 000000000..92ac71ac4 --- /dev/null +++ b/examples/zend_extension.rs @@ -0,0 +1,51 @@ +//! Example: Zend Extension hooks for low-level profiling. +//! +//! Build: `cargo build --example zend_extension --features observer` + +#![allow(missing_docs, clippy::must_use_candidate)] +#![cfg_attr(windows, feature(abi_vectorcall))] + +use ext_php_rs::ffi::zend_op_array; +use ext_php_rs::prelude::*; +use ext_php_rs::zend::ExecuteData; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub struct StatementProfiler { + compiled_functions: AtomicU64, + executed_statements: AtomicU64, +} + +impl StatementProfiler { + fn new() -> Self { + Self { + compiled_functions: AtomicU64::new(0), + executed_statements: AtomicU64::new(0), + } + } +} + +impl ZendExtensionHandler for StatementProfiler { + fn op_array_handler(&self, _op_array: &mut zend_op_array) { + self.compiled_functions.fetch_add(1, Ordering::Relaxed); + } + + fn statement_handler(&self, _execute_data: &ExecuteData) { + self.executed_statements.fetch_add(1, Ordering::Relaxed); + } + + fn activate(&self) { + self.executed_statements.store(0, Ordering::Relaxed); + } +} + +#[php_function] +pub fn zend_ext_compiled_count() -> u64 { + 0 +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .zend_extension_handler(StatementProfiler::new) + .function(wrap_function!(zend_ext_compiled_count)) +} diff --git a/guide/src/advanced/observer.md b/guide/src/advanced/observer.md index 5028be232..be5f3fe25 100644 --- a/guide/src/advanced/observer.md +++ b/guide/src/advanced/observer.md @@ -279,6 +279,84 @@ The backtrace is lazy - only captured when called, so there's zero cost if unuse | `file` | `Option` | Source file | | `line` | `u32` | Line number | +## Zend Extension Handler + +For low-level engine hooks beyond the Observer API (e.g., per-statement profiling, +bytecode instrumentation, or `op_array` lifecycle tracking), you can register a +`ZendExtensionHandler`. This registers your extension as a `zend_extension` alongside +the regular PHP extension — the same mechanism used by OPcache, Xdebug, and +dd-trace-php. + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::ffi::zend_op_array; +use ext_php_rs::zend::ExecuteData; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct StatementProfiler { + statement_count: AtomicU64, +} + +impl StatementProfiler { + fn new() -> Self { + Self { + statement_count: AtomicU64::new(0), + } + } +} + +impl ZendExtensionHandler for StatementProfiler { + fn op_array_handler(&self, _op_array: &mut zend_op_array) { + // Called after each function/method is compiled. + // Use to instrument bytecode or attach per-function metadata. + } + + fn statement_handler(&self, _execute_data: &ExecuteData) { + // Called for each executed statement. + self.statement_count.fetch_add(1, Ordering::Relaxed); + } + + fn activate(&self) { + // Per-request activation, distinct from RINIT. + self.statement_count.store(0, Ordering::Relaxed); + } +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.zend_extension_handler(StatementProfiler::new) +} +``` + +### The `ZendExtensionHandler` Trait + +All methods have default no-op implementations. Override only what you need. + +| Method | Description | +|--------|-------------| +| `op_array_handler(&self, op_array: &mut zend_op_array)` | Called after compilation of each function/method. Use to instrument bytecode. | +| `statement_handler(&self, execute_data: &ExecuteData)` | Called for each executed statement. Use for line-level profiling or code coverage. | +| `fcall_begin_handler(&self, execute_data: &ExecuteData)` | Called at the beginning of each function call (legacy hook). | +| `fcall_end_handler(&self, execute_data: &ExecuteData)` | Called at the end of each function call (legacy hook). | +| `op_array_ctor(&self, op_array: &mut zend_op_array)` | Called when a new `op_array` is constructed. Use to attach per-function data. | +| `op_array_dtor(&self, op_array: &mut zend_op_array)` | Called when an `op_array` is destroyed. Use to clean up per-function data. | +| `message_handler(&self, message: i32, arg: *mut c_void)` | Called when another `zend_extension` sends a message. | +| `activate(&self)` | Per-request activation (distinct from RINIT). | +| `deactivate(&self)` | Per-request deactivation (distinct from RSHUTDOWN). | + +### Zend Extension vs Observer API + +| Feature | Observer API (`FcallObserver`) | Zend Extension (`ZendExtensionHandler`) | +|---------|------|------| +| Function call hooks | `begin` / `end` with return value | `fcall_begin_handler` / `fcall_end_handler` (legacy) | +| Filtering | `should_observe` (cached per function) | No built-in filtering | +| Statement-level hooks | Not available | `statement_handler` | +| Bytecode access | Not available | `op_array_handler`, `op_array_ctor`, `op_array_dtor` | +| Request lifecycle | Not available | `activate` / `deactivate` | +| Best for | Function-level profiling, tracing | Statement-level profiling, code coverage, bytecode instrumentation | + +Both can be registered on the same module simultaneously. + ## Using All Observers You can register all observers on the same module: @@ -290,6 +368,7 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { .fcall_observer(MyProfiler::new) .error_observer(MyErrorTracker::new) .exception_observer(MyExceptionTracker::new) + .zend_extension_handler(MyStatementProfiler::new) } ``` @@ -324,4 +403,5 @@ Use thread-safe primitives like `AtomicU64`, `Mutex`, or `RwLock` for mutable st - Only one fcall observer can be registered per extension - Only one error observer can be registered per extension - Only one exception observer can be registered per extension -- Observers are registered globally for the entire PHP process +- Only one zend extension handler can be registered per extension +- Observers and handlers are registered globally for the entire PHP process diff --git a/src/builders/module.rs b/src/builders/module.rs index 2f7a6f9e0..b9ec5782c 100644 --- a/src/builders/module.rs +++ b/src/builders/module.rs @@ -318,6 +318,55 @@ impl ModuleBuilder<'_> { self } + /// Registers a zend extension handler for low-level engine hooks. + /// + /// Enables dual registration as both a regular PHP extension and a + /// `zend_extension`, giving access to hooks like `op_array_handler` + /// and `statement_handler` for building profilers and APMs. + /// + /// The factory function is called once during MINIT to create + /// a singleton handler instance. The handler must be `Send + Sync` + /// for ZTS builds. + /// + /// # Arguments + /// + /// * `factory` - A function that creates a handler instance + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::prelude::*; + /// use ext_php_rs::ffi::zend_op_array; + /// + /// struct MyProfiler; + /// + /// impl ZendExtensionHandler for MyProfiler { + /// fn op_array_handler(&self, _op_array: &mut zend_op_array) {} + /// fn statement_handler(&self, _execute_data: &ExecuteData) {} + /// } + /// + /// #[php_module] + /// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + /// module.zend_extension_handler(|| MyProfiler) + /// } + /// ``` + /// + /// # Panics + /// + /// Panics if called more than once on the same module. + #[cfg(feature = "observer")] + pub fn zend_extension_handler(self, factory: F) -> Self + where + F: Fn() -> H + Send + Sync + 'static, + H: crate::zend::ZendExtensionHandler, + { + let boxed_factory: Box< + dyn Fn() -> Box + Send + Sync, + > = Box::new(move || Box::new(factory())); + crate::zend::zend_extension::register_zend_extension_factory(boxed_factory); + self + } + /// Adds a function to the extension. /// /// # Arguments @@ -471,6 +520,10 @@ impl ModuleBuilder<'_> { /// Artifacts from the [`ModuleBuilder`] that should be revisited inside the /// extension startup function. pub struct ModuleStartup { + #[cfg(feature = "observer")] + name: String, + #[cfg(feature = "observer")] + version: String, constants: Vec<(String, Box)>, classes: Vec ClassBuilder>, interfaces: Vec ClassBuilder>, @@ -518,6 +571,7 @@ impl ModuleStartup { crate::zend::observer::observer_startup(); crate::zend::error_observer::error_observer_startup(); crate::zend::exception_observer::exception_observer_startup(); + crate::zend::zend_extension::zend_extension_startup(&self.name, &self.version); } Ok(()) @@ -544,10 +598,19 @@ impl TryFrom> for (ModuleEntry, ModuleStartup) { functions.push(FunctionEntry::end()); let functions = Box::into_raw(functions.into_boxed_slice()) as *const FunctionEntry; + #[cfg(feature = "observer")] + let ext_name = builder.name.clone(); + #[cfg(feature = "observer")] + let ext_version = builder.version.clone(); + let name = CString::new(builder.name)?.into_raw(); let version = CString::new(builder.version)?.into_raw(); let startup = ModuleStartup { + #[cfg(feature = "observer")] + name: ext_name, + #[cfg(feature = "observer")] + version: ext_version, constants: builder .constants .into_iter() diff --git a/src/lib.rs b/src/lib.rs index 5e2a83eb0..741dbba59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ pub mod observer { pub use crate::zend::error_observer::{BacktraceFrame, ErrorInfo, ErrorObserver, ErrorType}; pub use crate::zend::exception_observer::{ExceptionInfo, ExceptionObserver}; pub use crate::zend::observer::{FcallInfo, FcallObserver}; + pub use crate::zend::zend_extension::ZendExtensionHandler; } #[doc(hidden)] pub mod internal; @@ -73,7 +74,7 @@ pub mod prelude { #[cfg(feature = "observer")] pub use crate::zend::{ BacktraceFrame, ErrorInfo, ErrorObserver, ErrorType, ExceptionInfo, ExceptionObserver, - FcallInfo, FcallObserver, + FcallInfo, FcallObserver, ZendExtensionHandler, }; pub use crate::{ ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface, diff --git a/src/wrapper.h b/src/wrapper.h index f76b9687f..c0cb0771c 100644 --- a/src/wrapper.h +++ b/src/wrapper.h @@ -38,7 +38,10 @@ #include "zend_interfaces.h" #include "php_variables.h" #include "zend_ini.h" +#ifdef EXT_PHP_RS_OBSERVER #include "zend_observer.h" +#include "zend_extensions.h" +#endif #include "main/SAPI.h" zend_string *ext_php_rs_zend_string_init(const char *str, size_t len, bool persistent); @@ -50,6 +53,7 @@ const char *ext_php_rs_php_build_id(); void *ext_php_rs_zend_object_alloc(size_t obj_size, zend_class_entry *ce); void ext_php_rs_zend_object_release(zend_object *obj); zend_executor_globals *ext_php_rs_executor_globals(); +zend_compiler_globals *ext_php_rs_compiler_globals(); php_core_globals *ext_php_rs_process_globals(); sapi_globals_struct *ext_php_rs_sapi_globals(); php_file_globals *ext_php_rs_file_globals(); diff --git a/src/zend/mod.rs b/src/zend/mod.rs index 7e840a1af..ad8ead583 100644 --- a/src/zend/mod.rs +++ b/src/zend/mod.rs @@ -19,6 +19,8 @@ mod module; pub(crate) mod observer; mod streams; mod try_catch; +#[cfg(feature = "observer")] +pub(crate) mod zend_extension; use crate::{ error::Result, @@ -55,6 +57,8 @@ pub use streams::*; #[cfg(feature = "embed")] pub(crate) use try_catch::panic_wrapper; pub use try_catch::{CatchError, bailout, try_catch, try_catch_first}; +#[cfg(feature = "observer")] +pub use zend_extension::ZendExtensionHandler; // Used as the format string for `php_printf`. const FORMAT_STR: &[u8] = b"%s\0"; diff --git a/src/zend/zend_extension.rs b/src/zend/zend_extension.rs new file mode 100644 index 000000000..4c0e92941 --- /dev/null +++ b/src/zend/zend_extension.rs @@ -0,0 +1,269 @@ +//! Zend Extension API bindings for low-level engine hooks. +//! +//! Enables building profilers, APMs, and code coverage tools by registering +//! as a `zend_extension` alongside the regular PHP extension. +//! +//! # Example +//! +//! ```ignore +//! use ext_php_rs::prelude::*; +//! use ext_php_rs::ffi::zend_op_array; +//! +//! struct MyProfiler; +//! +//! impl ZendExtensionHandler for MyProfiler { +//! fn op_array_handler(&self, _op_array: &mut zend_op_array) {} +//! fn statement_handler(&self, _execute_data: &ExecuteData) {} +//! } +//! ``` + +use std::ffi::{CString, c_void}; +use std::ptr; +use std::sync::OnceLock; + +use crate::ffi; +use crate::zend::ExecuteData; + +/// Trait for handling low-level Zend Engine hooks via `zend_extension`. +/// +/// All methods have default no-op implementations. Override only what you need. +/// +/// # Thread Safety +/// +/// Handler must be `Send + Sync`. Use thread-safe primitives for mutable state. +pub trait ZendExtensionHandler: Send + Sync + 'static { + /// Called after compilation of each function/method `op_array`. + /// Use to instrument compiled bytecode. + fn op_array_handler(&self, _op_array: &mut ffi::zend_op_array) {} + + /// Called for each executed statement. + /// Use for line-level profiling or code coverage. + fn statement_handler(&self, _execute_data: &ExecuteData) {} + + /// Called at the beginning of each function call (legacy hook). + fn fcall_begin_handler(&self, _execute_data: &ExecuteData) {} + + /// Called at the end of each function call (legacy hook). + fn fcall_end_handler(&self, _execute_data: &ExecuteData) {} + + /// Called when a new `op_array` is constructed. + /// Use to attach per-function profiling data. + fn op_array_ctor(&self, _op_array: &mut ffi::zend_op_array) {} + + /// Called when an `op_array` is destroyed. + /// Use to clean up per-function data. + fn op_array_dtor(&self, _op_array: &mut ffi::zend_op_array) {} + + /// Called when another `zend_extension` is loaded or sends a message. + fn message_handler(&self, _message: i32, _arg: *mut c_void) {} + + /// Per-request activation (distinct from RINIT). + fn activate(&self) {} + + /// Per-request deactivation (distinct from RSHUTDOWN). + fn deactivate(&self) {} +} + +type ZendExtHandlerFactory = Box Box + Send + Sync>; + +static ZEND_EXT_FACTORY: OnceLock = OnceLock::new(); +static ZEND_EXT_INSTANCE: OnceLock> = OnceLock::new(); + +fn get_handler() -> Option<&'static dyn ZendExtensionHandler> { + ZEND_EXT_INSTANCE.get().map(std::convert::AsRef::as_ref) +} + +// ============================================================================ +// extern "C" dispatchers +// ============================================================================ + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_op_array_handler(op_array: *mut ffi::zend_op_array) { + if let Some(handler) = get_handler() + && let Some(op) = unsafe { op_array.as_mut() } + { + handler.op_array_handler(op); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_statement_handler(execute_data: *mut ffi::zend_execute_data) { + if let Some(handler) = get_handler() + && let Some(ex) = unsafe { execute_data.as_ref() } + { + handler.statement_handler(ex); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_fcall_begin_handler(execute_data: *mut ffi::zend_execute_data) { + if let Some(handler) = get_handler() + && let Some(ex) = unsafe { execute_data.as_ref() } + { + handler.fcall_begin_handler(ex); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_fcall_end_handler(execute_data: *mut ffi::zend_execute_data) { + if let Some(handler) = get_handler() + && let Some(ex) = unsafe { execute_data.as_ref() } + { + handler.fcall_end_handler(ex); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_op_array_ctor(op_array: *mut ffi::zend_op_array) { + if let Some(handler) = get_handler() + && let Some(op) = unsafe { op_array.as_mut() } + { + handler.op_array_ctor(op); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_op_array_dtor(op_array: *mut ffi::zend_op_array) { + if let Some(handler) = get_handler() + && let Some(op) = unsafe { op_array.as_mut() } + { + handler.op_array_dtor(op); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_message_handler(message: i32, arg: *mut c_void) { + if let Some(handler) = get_handler() { + handler.message_handler(message, arg); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_activate() { + if let Some(handler) = get_handler() { + handler.activate(); + } +} + +/// # Safety +/// +/// Called from PHP's C code. +unsafe extern "C" fn ext_deactivate() { + if let Some(handler) = get_handler() { + handler.deactivate(); + } +} + +// ============================================================================ +// Registration +// ============================================================================ + +// PHP compiler flags (from zend_compile.h) that cause the compiler to emit +// the special opcodes consumed by zend_extension hooks. +const ZEND_COMPILE_EXTENDED_STMT: u32 = 1 << 0; +const ZEND_COMPILE_EXTENDED_FCALL: u32 = 1 << 1; +const ZEND_COMPILE_HANDLE_OP_ARRAY: u32 = 1 << 2; + +/// # Panics +/// +/// Panics if called more than once. +pub(crate) fn register_zend_extension_factory(factory: ZendExtHandlerFactory) { + assert!( + ZEND_EXT_FACTORY.set(factory).is_ok(), + "zend_extension_handler can only be registered once per extension" + ); +} + +/// # Safety +/// +/// Must be called during MINIT phase only. +pub(crate) unsafe fn zend_extension_startup(name: &str, version: &str) { + let Some(factory) = ZEND_EXT_FACTORY.get() else { + return; + }; + + if ZEND_EXT_INSTANCE.set(factory()).is_err() { + return; + } + + let c_name = CString::new(name).unwrap_or_default(); + let c_version = CString::new(version).unwrap_or_default(); + + let mut ext: ffi::zend_extension = unsafe { std::mem::zeroed() }; + ext.name = c_name.into_raw(); + ext.version = c_version.into_raw(); + ext.author = ptr::null(); + ext.URL = ptr::null(); + ext.copyright = ptr::null(); + ext.startup = None; + ext.shutdown = None; + ext.activate = Some(ext_activate); + ext.deactivate = Some(ext_deactivate); + ext.message_handler = Some(ext_message_handler); + ext.op_array_handler = Some(ext_op_array_handler); + ext.statement_handler = Some(ext_statement_handler); + ext.fcall_begin_handler = Some(ext_fcall_begin_handler); + ext.fcall_end_handler = Some(ext_fcall_end_handler); + ext.op_array_ctor = Some(ext_op_array_ctor); + ext.op_array_dtor = Some(ext_op_array_dtor); + + unsafe { + ffi::zend_register_extension(&raw mut ext, ptr::null_mut()); + + // zend_startup_extensions_mechanism() runs before MINIT, so it won't + // have seen our extension. Manually set the compiler flags so PHP + // emits the opcodes that drive statement/fcall hooks. + let cg = ffi::ext_php_rs_compiler_globals(); + (*cg).compiler_options |= + ZEND_COMPILE_EXTENDED_STMT | ZEND_COMPILE_EXTENDED_FCALL | ZEND_COMPILE_HANDLE_OP_ARRAY; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestHandler { + handle_ops: bool, + } + + unsafe impl Send for TestHandler {} + unsafe impl Sync for TestHandler {} + + impl ZendExtensionHandler for TestHandler { + fn op_array_handler(&self, _op_array: &mut ffi::zend_op_array) {} + + fn statement_handler(&self, _execute_data: &ExecuteData) {} + } + + #[test] + fn test_trait_default_methods_compile() { + let handler = TestHandler { handle_ops: true }; + assert!(handler.handle_ops); + } + + #[test] + fn test_trait_impl() { + let handler = TestHandler { handle_ops: true }; + assert!(handler.handle_ops); + + let handler = TestHandler { handle_ops: false }; + assert!(!handler.handle_ops); + } +} diff --git a/tests/src/integration/observer/mod.rs b/tests/src/integration/observer/mod.rs index 87e0520ab..10bf137ec 100644 --- a/tests/src/integration/observer/mod.rs +++ b/tests/src/integration/observer/mod.rs @@ -1,5 +1,7 @@ -//! Integration tests for the Observer API (fcall, error, and exception observers). +//! Integration tests for the Observer API (fcall, error, exception observers, +//! and zend_extension handler). +use ext_php_rs::ffi; use ext_php_rs::prelude::*; use ext_php_rs::types::Zval; use ext_php_rs::zend::ExecuteData; @@ -396,6 +398,121 @@ impl ExceptionObserver for TestExceptionObserverWrapper { } } +// ============================================================================ +// Zend Extension Handler Tests +// ============================================================================ + +/// Shared state for the zend extension test handler. +struct ZendExtTestState { + activate_count: AtomicU64, + op_array_handler_count: AtomicU64, + statement_count: AtomicU64, + fcall_begin_count: AtomicU64, + fcall_end_count: AtomicU64, +} + +impl ZendExtTestState { + fn new() -> Self { + Self { + activate_count: AtomicU64::new(0), + op_array_handler_count: AtomicU64::new(0), + statement_count: AtomicU64::new(0), + fcall_begin_count: AtomicU64::new(0), + fcall_end_count: AtomicU64::new(0), + } + } +} + +static ZEND_EXT_STATE: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn get_or_init_zend_ext_state() -> &'static ZendExtTestState { + ZEND_EXT_STATE.get_or_init(ZendExtTestState::new) +} + +/// Test handler that counts hook invocations via static state. +struct TestZendExtHandler; + +impl ZendExtensionHandler for TestZendExtHandler { + fn activate(&self) { + get_or_init_zend_ext_state() + .activate_count + .fetch_add(1, Ordering::Relaxed); + } + + fn op_array_handler(&self, _op_array: &mut ffi::zend_op_array) { + get_or_init_zend_ext_state() + .op_array_handler_count + .fetch_add(1, Ordering::Relaxed); + } + + fn statement_handler(&self, _execute_data: &ExecuteData) { + get_or_init_zend_ext_state() + .statement_count + .fetch_add(1, Ordering::Relaxed); + } + + fn fcall_begin_handler(&self, _execute_data: &ExecuteData) { + get_or_init_zend_ext_state() + .fcall_begin_count + .fetch_add(1, Ordering::Relaxed); + } + + fn fcall_end_handler(&self, _execute_data: &ExecuteData) { + get_or_init_zend_ext_state() + .fcall_end_count + .fetch_add(1, Ordering::Relaxed); + } +} + +#[php_function] +pub fn zend_ext_test_get_activate_count() -> u64 { + get_or_init_zend_ext_state() + .activate_count + .load(Ordering::Relaxed) +} + +#[php_function] +pub fn zend_ext_test_get_op_array_handler_count() -> u64 { + get_or_init_zend_ext_state() + .op_array_handler_count + .load(Ordering::Relaxed) +} + +#[php_function] +pub fn zend_ext_test_get_statement_count() -> u64 { + get_or_init_zend_ext_state() + .statement_count + .load(Ordering::Relaxed) +} + +#[php_function] +pub fn zend_ext_test_get_fcall_begin_count() -> u64 { + get_or_init_zend_ext_state() + .fcall_begin_count + .load(Ordering::Relaxed) +} + +#[php_function] +pub fn zend_ext_test_get_fcall_end_count() -> u64 { + get_or_init_zend_ext_state() + .fcall_end_count + .load(Ordering::Relaxed) +} + +#[php_function] +pub fn zend_ext_test_reset_statement_count() { + get_or_init_zend_ext_state() + .statement_count + .store(0, Ordering::Relaxed); +} + +#[php_function] +pub fn zend_ext_test_reset_fcall_counts() { + let state = get_or_init_zend_ext_state(); + state.fcall_begin_count.store(0, Ordering::Relaxed); + state.fcall_end_count.store(0, Ordering::Relaxed); +} + // ============================================================================ // Module Builder // ============================================================================ @@ -410,6 +527,9 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { // Register the exception observer factory let builder = builder.exception_observer(|| TestExceptionObserverWrapper); + // Register the zend extension handler factory + let builder = builder.zend_extension_handler(|| TestZendExtHandler); + builder // Fcall observer functions .function(wrap_function!(observer_test_get_call_count)) @@ -434,6 +554,14 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { .function(wrap_function!( exception_observer_test_get_backtrace_functions )) + // Zend extension handler functions + .function(wrap_function!(zend_ext_test_get_activate_count)) + .function(wrap_function!(zend_ext_test_get_op_array_handler_count)) + .function(wrap_function!(zend_ext_test_get_statement_count)) + .function(wrap_function!(zend_ext_test_get_fcall_begin_count)) + .function(wrap_function!(zend_ext_test_get_fcall_end_count)) + .function(wrap_function!(zend_ext_test_reset_statement_count)) + .function(wrap_function!(zend_ext_test_reset_fcall_counts)) } #[cfg(test)] @@ -456,4 +584,11 @@ mod tests { "observer/exception_observer.php" )); } + + #[test] + fn zend_extension_handler_works() { + assert!(crate::integration::test::run_php( + "observer/zend_extension.php" + )); + } } diff --git a/tests/src/integration/observer/zend_extension.php b/tests/src/integration/observer/zend_extension.php new file mode 100644 index 000000000..d99fbd3c4 --- /dev/null +++ b/tests/src/integration/observer/zend_extension.php @@ -0,0 +1,99 @@ += 1, "Expected activate() to be called at least once, got: " . $activate_count); + +// ============================================================================ +// Test 2: op_array_handler() fires after compilation +// ============================================================================ +// In CLI mode the entire file is compiled before execution, so op_array_handler +// has already been called for the main script and any functions defined above. + +$op_count = zend_ext_test_get_op_array_handler_count(); +assert($op_count > 0, "Expected op_array_handler to be called at least once, got: " . $op_count); + +// ============================================================================ +// Test 3: statement_handler() fires for executed statements +// ============================================================================ + +zend_ext_test_reset_statement_count(); +$a = 1; +$b = 2; +$c = $a + $b; +$stmt_count = zend_ext_test_get_statement_count(); +// The reset call, three assignments, and the get call each produce +// ZEND_EXT_STMT opcodes, so the count must be greater than zero. +assert($stmt_count > 0, "Expected statement_handler to be called, got: " . $stmt_count); + +// ============================================================================ +// Test 4: fcall_begin_handler() / fcall_end_handler() fire around calls +// ============================================================================ +// ZEND_EXT_FCALL_BEGIN/END opcodes are emitted at call-sites in the calling +// code. The reset function's own EXT_FCALL_BEGIN fires *before* the reset +// while its EXT_FCALL_END fires *after*, so begin and end may differ by one. +// We therefore only assert a minimum count for each. + +function zend_ext_test_user_fn(): int +{ + return 42; +} + +zend_ext_test_reset_fcall_counts(); +zend_ext_test_user_fn(); +zend_ext_test_user_fn(); +zend_ext_test_user_fn(); +$begin_count = zend_ext_test_get_fcall_begin_count(); +$end_count = zend_ext_test_get_fcall_end_count(); +assert($begin_count >= 3, "Expected at least 3 fcall_begin, got: " . $begin_count); +assert($end_count >= 3, "Expected at least 3 fcall_end, got: " . $end_count); + +// ============================================================================ +// Test 5: Nested user function calls are tracked +// ============================================================================ + +function zend_ext_outer(): int +{ + return zend_ext_inner(); +} + +function zend_ext_inner(): int +{ + return 99; +} + +zend_ext_test_reset_fcall_counts(); +$result = zend_ext_outer(); +assert($result === 99, "Nested call should return 99, got: " . $result); + +$nested_begin = zend_ext_test_get_fcall_begin_count(); +$nested_end = zend_ext_test_get_fcall_end_count(); +// outer() calls inner(), each with EXT_FCALL_BEGIN/END at the call-site. +// The call to outer() in the main script also generates EXT_FCALL, and +// inner()'s call-site inside outer() generates another pair. +assert($nested_begin >= 2, "Expected at least 2 nested fcall_begin, got: " . $nested_begin); +assert($nested_end >= 2, "Expected at least 2 nested fcall_end, got: " . $nested_end); + +// ============================================================================ +// Test 6: statement_handler counts increase with more statements +// ============================================================================ + +zend_ext_test_reset_statement_count(); +$x = 1; +$y = 2; +$first_batch = zend_ext_test_get_statement_count(); + +zend_ext_test_reset_statement_count(); +$x = 1; +$y = 2; +$z = 3; +$w = 4; +$second_batch = zend_ext_test_get_statement_count(); + +assert( + $second_batch > $first_batch, + "More statements should produce a higher count: first=$first_batch, second=$second_batch", +);