Skip to main content

ext_php_rs/zend/
zend_extension.rs

1//! Zend Extension API bindings for low-level engine hooks.
2//!
3//! Enables building profilers, APMs, and code coverage tools by registering
4//! as a `zend_extension` alongside the regular PHP extension.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ext_php_rs::prelude::*;
10//! use ext_php_rs::ffi::zend_op_array;
11//!
12//! struct MyProfiler;
13//!
14//! impl ZendExtensionHandler for MyProfiler {
15//!     fn on_op_array_compiled(&self, _op_array: &mut zend_op_array) {}
16//!     fn on_statement(&self, _execute_data: &ExecuteData) {}
17//! }
18//! ```
19
20use std::ffi::{CString, c_void};
21use std::sync::OnceLock;
22
23use crate::ffi;
24use crate::zend::ExecuteData;
25
26/// Trait for handling low-level Zend Engine hooks via `zend_extension`.
27///
28/// All methods have default no-op implementations. Override only what you need.
29///
30/// # Thread Safety
31///
32/// Handler must be `Send + Sync`. Use thread-safe primitives for mutable state.
33pub trait ZendExtensionHandler: Send + Sync + 'static {
34    /// Called after compilation of each function/method `op_array`.
35    /// Enabled by [`ZendExtensionBuilder::hook_op_array_compile`].
36    fn on_op_array_compiled(&self, _op_array: &mut ffi::zend_op_array) {}
37
38    /// Called for each executed statement.
39    /// Enabled by [`ZendExtensionBuilder::hook_statements`].
40    fn on_statement(&self, _execute_data: &ExecuteData) {}
41
42    /// Called at the beginning of each function call (legacy hook).
43    /// Enabled by [`ZendExtensionBuilder::hook_fcalls`].
44    fn on_fcall_begin(&self, _execute_data: &ExecuteData) {}
45
46    /// Called at the end of each function call (legacy hook).
47    /// Enabled by [`ZendExtensionBuilder::hook_fcalls`].
48    fn on_fcall_end(&self, _execute_data: &ExecuteData) {}
49
50    /// Called when a new `op_array` is constructed.
51    fn on_op_array_ctor(&self, _op_array: &mut ffi::zend_op_array) {}
52
53    /// Called when an `op_array` is destroyed.
54    fn on_op_array_dtor(&self, _op_array: &mut ffi::zend_op_array) {}
55
56    /// Called when another `zend_extension` sends a message.
57    fn on_message(&self, _message: i32, _arg: *mut c_void) {}
58
59    /// Per-request activation (distinct from RINIT).
60    fn on_activate(&self) {}
61
62    /// Per-request deactivation (distinct from RSHUTDOWN).
63    fn on_deactivate(&self) {}
64}
65
66// ============================================================================
67// Config + statics
68// ============================================================================
69
70pub(crate) struct ZendExtensionConfig {
71    pub(crate) factory: Box<dyn Fn() -> Box<dyn ZendExtensionHandler> + Send + Sync>,
72    pub(crate) hook_op_array: bool,
73    pub(crate) hook_statements: bool,
74    pub(crate) hook_fcalls: bool,
75}
76
77static ZEND_EXT_CONFIG: OnceLock<ZendExtensionConfig> = OnceLock::new();
78static ZEND_EXT_INSTANCE: OnceLock<Box<dyn ZendExtensionHandler>> = OnceLock::new();
79static EXT_NAME: OnceLock<CString> = OnceLock::new();
80static EXT_VERSION: OnceLock<CString> = OnceLock::new();
81
82fn get_handler() -> Option<&'static dyn ZendExtensionHandler> {
83    ZEND_EXT_INSTANCE.get().map(std::convert::AsRef::as_ref)
84}
85
86// PHP compiler flags (from zend_compile.h) that cause the compiler to emit
87// the special opcodes consumed by zend_extension hooks.
88const ZEND_COMPILE_EXTENDED_STMT: u32 = 1 << 0;
89const ZEND_COMPILE_EXTENDED_FCALL: u32 = 1 << 1;
90const ZEND_COMPILE_HANDLE_OP_ARRAY: u32 = 1 << 2;
91
92fn compile_flags_for(cfg: &ZendExtensionConfig) -> u32 {
93    let mut flags = 0u32;
94    if cfg.hook_op_array {
95        flags |= ZEND_COMPILE_HANDLE_OP_ARRAY;
96    }
97    if cfg.hook_statements {
98        flags |= ZEND_COMPILE_EXTENDED_STMT;
99    }
100    if cfg.hook_fcalls {
101        flags |= ZEND_COMPILE_EXTENDED_FCALL;
102    }
103    flags
104}
105
106// ============================================================================
107// Builder
108// ============================================================================
109
110/// Builder for a `zend_extension` registration.
111///
112/// Returned by [`crate::builders::ModuleBuilder::zend_extension`]. Call
113/// [`Self::finish`] to return to the outer
114/// [`ModuleBuilder`](crate::builders::ModuleBuilder) after selecting opt-in
115/// hooks.
116///
117/// Each opt-in method enables one family of [`ZendExtensionHandler`] hooks.
118/// [`Self::hook_statements`] and [`Self::hook_fcalls`] also flip the matching
119/// `ZEND_COMPILE_*` flag so PHP emits the extra opcodes the hook depends on,
120/// which costs every compiled script. [`Self::hook_op_array_compile`] has no
121/// compile-time cost because its flag is already set by PHP's defaults; it
122/// only controls whether the dispatcher callback runs.
123#[must_use = "call .finish() to return to ModuleBuilder"]
124pub struct ZendExtensionBuilder<'a> {
125    module: Option<crate::builders::ModuleBuilder<'a>>,
126    factory: Box<dyn Fn() -> Box<dyn ZendExtensionHandler> + Send + Sync>,
127    hook_op_array: bool,
128    hook_statements: bool,
129    hook_fcalls: bool,
130}
131
132impl<'a> ZendExtensionBuilder<'a> {
133    pub(crate) fn new<F, H>(module: crate::builders::ModuleBuilder<'a>, factory: F) -> Self
134    where
135        F: Fn() -> H + Send + Sync + 'static,
136        H: ZendExtensionHandler,
137    {
138        Self {
139            module: Some(module),
140            factory: Box::new(move || Box::new(factory())),
141            hook_op_array: false,
142            hook_statements: false,
143            hook_fcalls: false,
144        }
145    }
146
147    /// Enable `on_op_array_compiled`, called after PHP finishes compiling
148    /// each function or method.
149    ///
150    /// Unlike [`Self::hook_statements`] and [`Self::hook_fcalls`], this
151    /// opt-in does not change the bytecode PHP emits: the matching flag
152    /// (`ZEND_COMPILE_HANDLE_OP_ARRAY`) is already part of PHP's default
153    /// `CG(compiler_options)`. The opt-in only controls whether the
154    /// dispatcher is registered, so the cost scales with the number of
155    /// compiled functions, not with every opcode.
156    pub fn hook_op_array_compile(mut self) -> Self {
157        self.hook_op_array = true;
158        self
159    }
160
161    /// Enable `on_statement` -- called for every executed statement.
162    ///
163    /// Flips `ZEND_COMPILE_EXTENDED_STMT`, which causes PHP to emit extra
164    /// `ZEND_EXT_STMT` opcodes in every compiled script. Opt in only if your
165    /// profiler actually needs per-statement granularity.
166    pub fn hook_statements(mut self) -> Self {
167        self.hook_statements = true;
168        self
169    }
170
171    /// Enable `on_fcall_begin` / `on_fcall_end` -- legacy per-call-site hooks.
172    ///
173    /// Flips `ZEND_COMPILE_EXTENDED_FCALL`, which causes PHP to emit
174    /// `ZEND_EXT_FCALL_BEGIN`/`END` opcodes around every call site.
175    pub fn hook_fcalls(mut self) -> Self {
176        self.hook_fcalls = true;
177        self
178    }
179
180    /// Consume the builder, register the extension, and return the outer
181    /// [`ModuleBuilder`](crate::builders::ModuleBuilder) so further module
182    /// config can be chained.
183    ///
184    /// # Panics
185    ///
186    /// Panics if a `ZendExtensionHandler` has already been registered on this
187    /// module. Each extension may register at most one handler.
188    #[expect(
189        clippy::expect_used,
190        reason = "the module is only absent on the test-only constructor"
191    )]
192    pub fn finish(self) -> crate::builders::ModuleBuilder<'a> {
193        register_config(ZendExtensionConfig {
194            factory: self.factory,
195            hook_op_array: self.hook_op_array,
196            hook_statements: self.hook_statements,
197            hook_fcalls: self.hook_fcalls,
198        });
199        self.module
200            .expect("ZendExtensionBuilder::finish called on test instance")
201    }
202
203    #[cfg(test)]
204    pub(crate) fn __for_tests<F, H>(factory: F) -> Self
205    where
206        F: Fn() -> H + Send + Sync + 'static,
207        H: ZendExtensionHandler,
208    {
209        Self {
210            module: None,
211            factory: Box::new(move || Box::new(factory())),
212            hook_op_array: false,
213            hook_statements: false,
214            hook_fcalls: false,
215        }
216    }
217
218    #[cfg(test)]
219    pub(crate) fn opts(&self) -> (bool, bool, bool) {
220        (self.hook_op_array, self.hook_statements, self.hook_fcalls)
221    }
222}
223
224pub(crate) fn register_config(config: ZendExtensionConfig) {
225    assert!(
226        ZEND_EXT_CONFIG.set(config).is_ok(),
227        "zend_extension can only be registered once per module",
228    );
229}
230
231// ============================================================================
232// extern "C" dispatchers
233// ============================================================================
234
235/// # Safety
236///
237/// Called from PHP's C code.
238unsafe extern "C" fn ext_op_array_handler(op_array: *mut ffi::zend_op_array) {
239    if let Some(handler) = get_handler()
240        && let Some(op) = unsafe { op_array.as_mut() }
241    {
242        handler.on_op_array_compiled(op);
243    }
244}
245
246/// # Safety
247///
248/// Called from PHP's C code.
249unsafe extern "C" fn ext_statement_handler(execute_data: *mut ffi::zend_execute_data) {
250    if let Some(handler) = get_handler()
251        && let Some(ex) = unsafe { execute_data.as_ref() }
252    {
253        handler.on_statement(ex);
254    }
255}
256
257/// # Safety
258///
259/// Called from PHP's C code.
260unsafe extern "C" fn ext_fcall_begin_handler(execute_data: *mut ffi::zend_execute_data) {
261    if let Some(handler) = get_handler()
262        && let Some(ex) = unsafe { execute_data.as_ref() }
263    {
264        handler.on_fcall_begin(ex);
265    }
266}
267
268/// # Safety
269///
270/// Called from PHP's C code.
271unsafe extern "C" fn ext_fcall_end_handler(execute_data: *mut ffi::zend_execute_data) {
272    if let Some(handler) = get_handler()
273        && let Some(ex) = unsafe { execute_data.as_ref() }
274    {
275        handler.on_fcall_end(ex);
276    }
277}
278
279/// # Safety
280///
281/// Called from PHP's C code.
282unsafe extern "C" fn ext_op_array_ctor(op_array: *mut ffi::zend_op_array) {
283    if let Some(handler) = get_handler()
284        && let Some(op) = unsafe { op_array.as_mut() }
285    {
286        handler.on_op_array_ctor(op);
287    }
288}
289
290/// # Safety
291///
292/// Called from PHP's C code.
293unsafe extern "C" fn ext_op_array_dtor(op_array: *mut ffi::zend_op_array) {
294    if let Some(handler) = get_handler()
295        && let Some(op) = unsafe { op_array.as_mut() }
296    {
297        handler.on_op_array_dtor(op);
298    }
299}
300
301/// # Safety
302///
303/// Called from PHP's C code.
304unsafe extern "C" fn ext_message_handler(message: i32, arg: *mut c_void) {
305    if let Some(handler) = get_handler() {
306        handler.on_message(message, arg);
307    }
308}
309
310/// # Safety
311///
312/// Called from PHP's C code.
313unsafe extern "C" fn ext_activate() {
314    if let Some(cfg) = ZEND_EXT_CONFIG.get() {
315        apply_compiler_flags(cfg);
316    }
317    if let Some(handler) = get_handler() {
318        handler.on_activate();
319    }
320}
321
322/// # Safety
323///
324/// Called from PHP's C code.
325unsafe extern "C" fn ext_deactivate() {
326    if let Some(handler) = get_handler() {
327        handler.on_deactivate();
328    }
329}
330
331// ============================================================================
332// Registration
333// ============================================================================
334
335/// # Safety
336///
337/// Must be called during MINIT phase only.
338#[expect(
339    clippy::expect_used,
340    reason = "the name and version come from the module builder, i.e. from Cargo metadata compiled into the extension"
341)]
342pub(crate) unsafe fn zend_extension_startup(name: &str, version: &str) {
343    let Some(cfg) = ZEND_EXT_CONFIG.get() else {
344        return;
345    };
346
347    let _ = ZEND_EXT_INSTANCE.set((cfg.factory)());
348
349    let c_name = CString::new(name).expect("zend extension name must not contain nul bytes");
350    let c_version =
351        CString::new(version).expect("zend extension version must not contain nul bytes");
352    let name_ptr = EXT_NAME.get_or_init(|| c_name).as_ptr();
353    let version_ptr = EXT_VERSION.get_or_init(|| c_version).as_ptr();
354
355    let mut ext: ffi::zend_extension = unsafe { std::mem::zeroed() };
356    ext.name = name_ptr;
357    ext.version = version_ptr;
358
359    // Always-on cold-path hooks.
360    ext.activate = Some(ext_activate);
361    ext.deactivate = Some(ext_deactivate);
362    ext.message_handler = Some(ext_message_handler);
363    ext.op_array_ctor = Some(ext_op_array_ctor);
364    ext.op_array_dtor = Some(ext_op_array_dtor);
365
366    // Opt-in hot-path hooks.
367    if cfg.hook_op_array {
368        ext.op_array_handler = Some(ext_op_array_handler);
369    }
370    if cfg.hook_statements {
371        ext.statement_handler = Some(ext_statement_handler);
372    }
373    if cfg.hook_fcalls {
374        ext.fcall_begin_handler = Some(ext_fcall_begin_handler);
375        ext.fcall_end_handler = Some(ext_fcall_end_handler);
376    }
377
378    unsafe {
379        crate::zend::register_extension(&raw mut ext);
380    }
381    apply_compiler_flags(cfg);
382}
383
384fn apply_compiler_flags(cfg: &ZendExtensionConfig) {
385    let flags = compile_flags_for(cfg);
386    if flags != 0 {
387        let mut cg = super::globals::CompilerGlobals::get_mut();
388        cg.compiler_options |= flags;
389    }
390}
391
392// ============================================================================
393// Tests
394// ============================================================================
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn cfg(stmts: bool, fcalls: bool, op_array: bool) -> ZendExtensionConfig {
401        ZendExtensionConfig {
402            factory: Box::new(|| Box::new(NoopHandler)),
403            hook_statements: stmts,
404            hook_fcalls: fcalls,
405            hook_op_array: op_array,
406        }
407    }
408
409    struct NoopHandler;
410    impl ZendExtensionHandler for NoopHandler {}
411
412    // -- compile_flags_for tests --
413
414    #[test]
415    fn compile_flags_for_none_is_zero() {
416        assert_eq!(compile_flags_for(&cfg(false, false, false)), 0);
417    }
418
419    #[test]
420    fn compile_flags_for_statements_only() {
421        assert_eq!(
422            compile_flags_for(&cfg(true, false, false)),
423            ZEND_COMPILE_EXTENDED_STMT,
424        );
425    }
426
427    #[test]
428    fn compile_flags_for_fcalls_only() {
429        assert_eq!(
430            compile_flags_for(&cfg(false, true, false)),
431            ZEND_COMPILE_EXTENDED_FCALL,
432        );
433    }
434
435    #[test]
436    fn compile_flags_for_op_array_only() {
437        assert_eq!(
438            compile_flags_for(&cfg(false, false, true)),
439            ZEND_COMPILE_HANDLE_OP_ARRAY,
440        );
441    }
442
443    #[test]
444    fn compile_flags_for_all_is_or() {
445        assert_eq!(
446            compile_flags_for(&cfg(true, true, true)),
447            ZEND_COMPILE_EXTENDED_STMT | ZEND_COMPILE_EXTENDED_FCALL | ZEND_COMPILE_HANDLE_OP_ARRAY,
448        );
449    }
450
451    // -- builder state tests --
452
453    #[test]
454    fn builder_starts_with_no_hooks() {
455        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler);
456        assert_eq!(b.opts(), (false, false, false));
457    }
458
459    #[test]
460    fn builder_hook_statements_flips_only_statements() {
461        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler).hook_statements();
462        assert_eq!(b.opts(), (false, true, false));
463    }
464
465    #[test]
466    fn builder_hook_fcalls_flips_only_fcalls() {
467        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler).hook_fcalls();
468        assert_eq!(b.opts(), (false, false, true));
469    }
470
471    #[test]
472    fn builder_hook_op_array_compile_flips_only_op_array() {
473        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler).hook_op_array_compile();
474        assert_eq!(b.opts(), (true, false, false));
475    }
476
477    #[test]
478    fn builder_hooks_compose() {
479        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler)
480            .hook_statements()
481            .hook_fcalls()
482            .hook_op_array_compile();
483        assert_eq!(b.opts(), (true, true, true));
484    }
485
486    #[test]
487    fn builder_hooks_idempotent() {
488        let b = ZendExtensionBuilder::__for_tests(|| NoopHandler)
489            .hook_statements()
490            .hook_statements();
491        assert_eq!(b.opts(), (false, true, false));
492    }
493
494    #[test]
495    fn trait_lifecycle_defaults_are_no_op() {
496        // The other six defaults (on_op_array_compiled, on_statement,
497        // on_fcall_begin/end, on_op_array_ctor/dtor) require a PHP runtime
498        // to construct their arguments; the integration test exercises
499        // them end-to-end.
500        struct Empty;
501        impl ZendExtensionHandler for Empty {}
502        let h = Empty;
503        h.on_activate();
504        h.on_deactivate();
505        h.on_message(0, std::ptr::null_mut());
506    }
507}