diff --git a/benches/benches/binary_bench.rs b/benches/benches/binary_bench.rs index 03296f0f4..123d821b7 100644 --- a/benches/benches/binary_bench.rs +++ b/benches/benches/binary_bench.rs @@ -126,6 +126,31 @@ binary_benchmark_group!( benchmarks = callback_calls ); +#[binary_benchmark] +#[bench::single_cached_callback_call(args = ("cached_callback_call.php", 1))] +#[bench::multiple_cached_callback_calls(args = ("cached_callback_call.php", 10))] +#[bench::lots_of_cached_callback_calls(args = ("cached_callback_call.php", 100_000))] +fn cached_callback_calls(script: &str, cnt: usize) -> gungraun::Command { + setup(); + + gungraun::Command::new("php") + .arg(format!("-dextension={}", *EXT_LIB)) + .arg(bench_script(script)) + .arg(cnt.to_string()) + .build() +} + +binary_benchmark_group!( + name = cached_callback; + config = BinaryBenchmarkConfig::default() + .tool(Callgrind::with_args([ + CACHE_SIM[0], CACHE_SIM[1], CACHE_SIM[2], + "--collect-atstart=no", + "--toggle-collect=*_internal_bench_cached_callback_function*handler*", + ]).flamegraph(FlamegraphConfig::default())); + benchmarks = cached_callback_calls +); + #[binary_benchmark] #[bench::single_method_call(args = ("method_call.php", 1))] #[bench::multiple_method_calls(args = ("method_call.php", 10))] @@ -177,5 +202,5 @@ binary_benchmark_group!( ); main!( - binary_benchmark_groups = function, callback, method, static_method + binary_benchmark_groups = function, callback, cached_callback, method, static_method ); diff --git a/benches/benches/cached_callback_call.php b/benches/benches/cached_callback_call.php new file mode 100644 index 000000000..d974bac68 --- /dev/null +++ b/benches/benches/cached_callback_call.php @@ -0,0 +1,5 @@ + $i * 2, (int) $argv[1]); diff --git a/benches/ext/src/lib.rs b/benches/ext/src/lib.rs index ebeb40062..86861d990 100644 --- a/benches/ext/src/lib.rs +++ b/benches/ext/src/lib.rs @@ -22,6 +22,16 @@ pub fn bench_callback_function(callback: ZendCallable, n: usize) { } } +#[php_function] +pub fn bench_cached_callback_function(callback: ZendCallable, n: usize) { + let cached = callback.cache().expect("Failed to cache callback"); + for i in 0..n { + cached + .try_call(vec![&i]) + .expect("Failed to call cached callback"); + } +} + #[php_class] pub struct BenchClass; @@ -45,5 +55,6 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { module .function(wrap_function!(bench_function)) .function(wrap_function!(bench_callback_function)) + .function(wrap_function!(bench_cached_callback_function)) .class::() } diff --git a/guide/src/types/functions.md b/guide/src/types/functions.md index 8a24a2be9..a09c88990 100644 --- a/guide/src/types/functions.md +++ b/guide/src/types/functions.md @@ -26,3 +26,88 @@ pub fn test_method() -> () { # fn main() {} ``` + +## Cached Callables + +When calling the same PHP function repeatedly from Rust, use `CachedCallable` +to avoid re-resolving the function on every call. The first resolution caches +the internal `zend_fcall_info_cache`, and subsequent calls skip all string +lookups and hash table searches. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::ZendCallable; + +#[php_function] +pub fn call_many_times(callback: ZendCallable) -> () { + let cached = callback.cache().expect("Failed to cache callable"); + + for i in 0..1000i64 { + let _ = cached.try_call(vec![&i]); + } +} +# fn main() {} +``` + +### When to use `CachedCallable` + +- **Use `CachedCallable`** when calling the same callable multiple times (loops, + event handlers, iterators like `array_map` patterns). +- **Use `ZendCallable`** for single-shot calls where caching overhead is wasted. + +### Error handling + +`CachedCallable` returns `CachedCallableError` which provides granular +error variants: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::error::CachedCallableError; +use ext_php_rs::types::ZendCallable; + +#[php_function] +pub fn resilient_caller(callback: ZendCallable) -> () { + let cached = callback.cache().expect("Failed to cache"); + + match cached.try_call(vec![&42i64]) { + Ok(result) => { /* use result */ }, + Err(CachedCallableError::PhpException(_)) => { + // PHP exception — callable is still valid, can retry + let _ = cached.try_call(vec![&0i64]); + }, + Err(CachedCallableError::Poisoned) => { + // Engine failure happened before — cannot reuse + }, + Err(e) => { /* other errors */ }, + } +} +# fn main() {} +``` + +### Named arguments + +`CachedCallable` supports the same named argument methods as `ZendCallable`: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::ZendCallable; + +#[php_function] +pub fn cached_with_named() -> () { + let func = ZendCallable::try_from_name("str_replace").unwrap(); + let cached = func.cache().unwrap(); + + let _ = cached.try_call_named(&[ + ("search", &"world"), + ("replace", &"PHP"), + ("subject", &"Hello world"), + ]); +} +# fn main() {} +``` diff --git a/src/error.rs b/src/error.rs index ab1345d29..2d99ef093 100644 --- a/src/error.rs +++ b/src/error.rs @@ -147,6 +147,54 @@ impl From for PhpException { } } +/// Error type for [`CachedCallable`](crate::types::CachedCallable) operations. +#[derive(Debug)] +pub enum CachedCallableError { + /// The callable could not be resolved at cache time. + ResolutionFailed, + /// The call mechanism itself failed (`zend_call_function` returned < 0). + /// The `CachedCallable` is now poisoned and cannot be reused. + CallFailed, + /// A PHP exception was thrown during execution. + /// The `CachedCallable` remains valid for subsequent calls. + PhpException(ZBox), + /// The `CachedCallable` was poisoned by a prior engine failure. + Poisoned, + /// Integer overflow when converting parameter count. + IntegerOverflow, + /// A parameter could not be converted to a Zval. + ParamConversion, +} + +impl Display for CachedCallableError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ResolutionFailed => write!(f, "Could not resolve callable for caching."), + Self::CallFailed => { + write!(f, "Cached callable call failed; callable is now poisoned.") + } + Self::PhpException(e) => write!(f, "PHP exception thrown during cached call: {e:?}"), + Self::Poisoned => write!(f, "Cached callable is poisoned by a prior engine failure."), + Self::IntegerOverflow => { + write!(f, "Converting integer arguments resulted in an overflow.") + } + Self::ParamConversion => write!(f, "A parameter could not be converted to a Zval."), + } + } +} + +impl ErrorTrait for CachedCallableError {} + +impl From for Error { + fn from(e: CachedCallableError) -> Self { + match e { + CachedCallableError::PhpException(e) => Error::Exception(e), + CachedCallableError::IntegerOverflow => Error::IntegerOverflow, + _ => Error::Callable, + } + } +} + /// Trigger an error that is reported in PHP the same way `trigger_error()` is. /// /// See specific error type descriptions at . diff --git a/src/ffi.rs b/src/ffi.rs index 566a03d80..7333495c0 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -49,6 +49,16 @@ unsafe extern "C" { filename: *const c_char, ) -> *mut zend_op_array; pub fn ext_php_rs_zend_execute(op_array: *mut zend_op_array); + + pub fn _ext_php_rs_zend_fcc_addref(fcc: *mut _zend_fcall_info_cache); + pub fn _ext_php_rs_zend_fcc_dtor(fcc: *mut _zend_fcall_info_cache); + pub fn _ext_php_rs_cached_call_function( + fcc: *mut _zend_fcall_info_cache, + retval: *mut zval, + param_count: u32, + params: *mut zval, + named_params: *mut HashTable, + ) -> ::std::os::raw::c_int; } include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/src/lib.rs b/src/lib.rs index 5e2a83eb0..189b8acd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,7 @@ pub mod prelude { pub use crate::php_print; pub use crate::php_println; pub use crate::php_write; + pub use crate::types::CachedCallable; pub use crate::types::ZendCallable; pub use crate::zend::BailoutGuard; #[cfg(feature = "observer")] diff --git a/src/types/callable.rs b/src/types/callable.rs index aae96bff9..b6c6b8d3e 100644 --- a/src/types/callable.rs +++ b/src/types/callable.rs @@ -1,11 +1,14 @@ //! Types related to callables in PHP (anonymous functions, functions, etc). -use std::{convert::TryFrom, ops::Deref, ptr}; +use std::{cell::Cell, convert::TryFrom, marker::PhantomData, mem::MaybeUninit, ops::Deref, ptr}; use crate::{ convert::{FromZval, IntoZvalDyn}, - error::{Error, Result}, - ffi::_call_user_function_impl, + error::{CachedCallableError, Error, Result}, + ffi::{ + _call_user_function_impl, _ext_php_rs_cached_call_function, _ext_php_rs_zend_fcc_addref, + _ext_php_rs_zend_fcc_dtor, zend_fcall_info_cache, zend_is_callable_ex, + }, flags::DataType, zend::ExecutorGlobals, }; @@ -279,6 +282,50 @@ impl<'a> ZendCallable<'a> { pub fn try_call_named(&self, named_params: &[(&str, &dyn IntoZvalDyn)]) -> Result { self.try_call_with_named(&[], named_params) } + + /// Caches the callable resolution for repeated calls. + /// + /// Resolves the callable once via `zend_is_callable_ex` and stores the + /// resulting `zend_fcall_info_cache`. Subsequent calls via the returned + /// [`CachedCallable`] skip all function resolution overhead. + /// + /// # Errors + /// + /// Returns [`CachedCallableError::ResolutionFailed`] if the callable + /// cannot be resolved. + pub fn cache(&self) -> std::result::Result, CachedCallableError> { + let callable_copy = self.0.as_ref().shallow_clone(); + let mut fcc = MaybeUninit::::zeroed(); + + let resolved = unsafe { + zend_is_callable_ex( + ptr::from_ref(&callable_copy).cast_mut(), + ptr::null_mut(), + 0, + ptr::null_mut(), + fcc.as_mut_ptr(), + ptr::null_mut(), + ) + }; + + if !resolved { + return Err(CachedCallableError::ResolutionFailed); + } + + let mut fcc = unsafe { fcc.assume_init() }; + + unsafe { + #[allow(clippy::used_underscore_items)] + _ext_php_rs_zend_fcc_addref(&raw mut fcc); + } + + Ok(CachedCallable { + callable: callable_copy, + fcc, + poisoned: Cell::new(false), + _lifetime: PhantomData, + }) + } } impl<'a> FromZval<'a> for ZendCallable<'a> { @@ -321,3 +368,149 @@ impl Deref for OwnedZval<'_> { self.as_ref() } } + +/// A cached callable that resolves the PHP function once and reuses the +/// resolution on subsequent calls. +/// +/// Created via [`ZendCallable::cache()`]. Caches the `zend_fcall_info_cache` +/// which contains the resolved `zend_function*` pointer, avoiding repeated +/// string lookups and hash table searches on each call. +/// +/// # Poisoning +/// +/// If `zend_call_function` returns an engine failure (return code < 0), +/// the callable is poisoned and subsequent calls return +/// [`CachedCallableError::Poisoned`]. PHP exceptions do NOT poison the +/// callable. +pub struct CachedCallable<'a> { + #[allow(dead_code)] + callable: Zval, + fcc: zend_fcall_info_cache, + poisoned: Cell, + _lifetime: PhantomData<&'a ()>, +} + +impl Drop for CachedCallable<'_> { + fn drop(&mut self) { + if self.fcc.function_handler.is_null() { + return; + } + unsafe { + #[allow(clippy::used_underscore_items)] + _ext_php_rs_zend_fcc_dtor(&raw mut self.fcc); + } + } +} + +impl CachedCallable<'_> { + /// Calls the cached callable with positional arguments. + /// + /// Uses the pre-resolved function cache, skipping all function + /// name resolution. + /// + /// # Errors + /// + /// * [`CachedCallableError::Poisoned`] if a prior engine failure poisoned this callable + /// * [`CachedCallableError::CallFailed`] on engine failure (poisons the callable) + /// * [`CachedCallableError::PhpException`] on PHP exception (callable stays valid) + /// * [`CachedCallableError::ParamConversion`] if a parameter conversion failed + /// * [`CachedCallableError::IntegerOverflow`] if too many parameters + #[allow(clippy::inline_always, clippy::needless_pass_by_value)] + #[inline(always)] + pub fn try_call( + &self, + params: Vec<&dyn IntoZvalDyn>, + ) -> std::result::Result { + self.try_call_with_named(params.as_slice(), &[]) + } + + /// Calls the cached callable with positional and named arguments. + /// + /// # Errors + /// + /// Same as [`try_call`](Self::try_call), plus conversion errors for + /// named parameter names or values. + #[allow(clippy::inline_always)] + #[inline(always)] + pub fn try_call_with_named( + &self, + params: &[&dyn IntoZvalDyn], + named_params: &[(&str, &dyn IntoZvalDyn)], + ) -> std::result::Result { + if self.poisoned.get() { + return Err(CachedCallableError::Poisoned); + } + + let mut packed: Vec = params + .iter() + .map(|val| val.as_zval(false)) + .collect::>() + .map_err(|_| CachedCallableError::ParamConversion)?; + + let named_ht = if named_params.is_empty() { + None + } else { + let mut ht = ZendHashTable::with_capacity( + named_params + .len() + .try_into() + .map_err(|_| CachedCallableError::IntegerOverflow)?, + ); + for &(name, val) in named_params { + let zval = val + .as_zval(false) + .map_err(|_| CachedCallableError::ParamConversion)?; + ht.insert(name, zval) + .map_err(|_| CachedCallableError::ParamConversion)?; + } + Some(ht) + }; + + let named_ptr = named_ht + .as_ref() + .map_or(ptr::null_mut(), |ht| ptr::from_ref(&**ht).cast_mut()); + + let mut retval = Zval::new(); + let len: u32 = packed + .len() + .try_into() + .map_err(|_| CachedCallableError::IntegerOverflow)?; + + let result = unsafe { + #[allow(clippy::used_underscore_items)] + _ext_php_rs_cached_call_function( + ptr::from_ref(&self.fcc).cast_mut(), + &raw mut retval, + len, + packed.as_mut_ptr(), + named_ptr, + ) + }; + + if result < 0 { + self.poisoned.set(true); + return Err(CachedCallableError::CallFailed); + } + + if let Some(e) = ExecutorGlobals::take_exception() { + return Err(CachedCallableError::PhpException(e)); + } + + Ok(retval) + } + + /// Calls the cached callable with only named arguments. + /// + /// Convenience method equivalent to `try_call_with_named(&[], named_params)`. + /// + /// # Errors + /// + /// Same as [`try_call`](Self::try_call). + #[inline] + pub fn try_call_named( + &self, + named_params: &[(&str, &dyn IntoZvalDyn)], + ) -> std::result::Result { + self.try_call_with_named(&[], named_params) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index e2b927e44..986e23f38 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -14,7 +14,7 @@ mod string; mod zval; pub use array::{ArrayKey, Entry, OccupiedEntry, VacantEntry, ZendEmptyArray, ZendHashTable}; -pub use callable::ZendCallable; +pub use callable::{CachedCallable, ZendCallable}; pub use class_object::ZendClassObject; pub use iterable::Iterable; pub use iterator::ZendIterator; diff --git a/src/wrapper.c b/src/wrapper.c index 1e1fd5c41..a7e40a4d0 100644 --- a/src/wrapper.c +++ b/src/wrapper.c @@ -139,3 +139,49 @@ void ext_php_rs_zend_execute(zend_op_array *op_array) { destroy_op_array(op_array); efree(op_array); } + +#if PHP_VERSION_ID >= 80300 +void _ext_php_rs_zend_fcc_addref(zend_fcall_info_cache *fcc) { + zend_fcc_addref(fcc); +} + +void _ext_php_rs_zend_fcc_dtor(zend_fcall_info_cache *fcc) { + zend_fcc_dtor(fcc); +} +#else +void _ext_php_rs_zend_fcc_addref(zend_fcall_info_cache *fcc) { + if (fcc->function_handler && + (fcc->function_handler->common.fn_flags & ZEND_ACC_CALL_VIA_TRAMPOLINE) && + fcc->function_handler == &EG(trampoline)) { + zend_function *copy = emalloc(sizeof(zend_function)); + memcpy(copy, fcc->function_handler, sizeof(zend_function)); + fcc->function_handler->common.function_name = NULL; + fcc->function_handler = copy; + } + if (fcc->object) { + GC_ADDREF(fcc->object); + } +} + +void _ext_php_rs_zend_fcc_dtor(zend_fcall_info_cache *fcc) { + if (fcc->object) { + OBJ_RELEASE(fcc->object); + } + zend_release_fcall_info_cache(fcc); + memset(fcc, 0, sizeof(*fcc)); +} +#endif + +int _ext_php_rs_cached_call_function(zend_fcall_info_cache *fcc, zval *retval, uint32_t param_count, zval *params, HashTable *named_params) { + zend_fcall_info fci; + + ZVAL_UNDEF(&fci.function_name); + fci.size = sizeof(fci); + fci.object = fcc->object; + fci.retval = retval; + fci.param_count = param_count; + fci.params = params; + fci.named_params = named_params; + + return zend_call_function(&fci, fcc); +} diff --git a/src/wrapper.h b/src/wrapper.h index f76b9687f..188c5e979 100644 --- a/src/wrapper.h +++ b/src/wrapper.h @@ -59,3 +59,7 @@ bool ext_php_rs_zend_first_try_catch(void* (*callback)(void *), void *ctx, void void ext_php_rs_zend_bailout(); zend_op_array *ext_php_rs_zend_compile_string(zend_string *source, const char *filename); void ext_php_rs_zend_execute(zend_op_array *op_array); + +void _ext_php_rs_zend_fcc_addref(zend_fcall_info_cache *fcc); +void _ext_php_rs_zend_fcc_dtor(zend_fcall_info_cache *fcc); +int _ext_php_rs_cached_call_function(zend_fcall_info_cache *fcc, zval *retval, uint32_t param_count, zval *params, HashTable *named_params); diff --git a/tests/src/integration/callable/callable.php b/tests/src/integration/callable/callable.php index da6d2112b..c0ba6ff36 100644 --- a/tests/src/integration/callable/callable.php +++ b/tests/src/integration/callable/callable.php @@ -30,3 +30,33 @@ // Duplicate named params - last value wins $dupResult = test_callable_duplicate_named(fn (string $a) => "val:$a"); assert($dupResult === 'val:overwritten', "Duplicate named args failed: expected 'val:overwritten', got '$dupResult'"); + +// === CachedCallable tests === + +// Basic cached callable +assert(test_cached_callable_basic(fn (string $a) => $a, 'cached') === 'cached'); + +// Repeated calls (sum of fn(i) => i*2 for i in 0..10 = 2*(0+1+...+9) = 90) +$repeatedResult = test_cached_callable_repeated(fn (int $i) => $i * 2); +assert($repeatedResult === 90, "Repeated cached calls failed: expected 90, got '$repeatedResult'"); + +// Cached callable with named arguments +$cachedNamedResult = test_cached_callable_named(fn (string $a, string $b) => "$a-$b"); +assert($cachedNamedResult === 'first-second', "Cached named args failed: expected 'first-second', got '$cachedNamedResult'"); + +// Cached callable with mixed arguments +$cachedMixedResult = test_cached_callable_mixed(fn (string $pos, string $named) => "$pos|$named"); +assert($cachedMixedResult === 'positional|named_value', "Cached mixed args failed: expected 'positional|named_value', got '$cachedMixedResult'"); + +// Exception recovery - cached callable stays valid after PHP exception +$exceptionResult = test_cached_callable_exception_recovery(function (bool $shouldThrow) { + if ($shouldThrow) { + throw new \RuntimeException('Test exception'); + } + return 'recovered'; +}); +assert($exceptionResult === 'recovered', "Exception recovery failed: expected 'recovered', got '$exceptionResult'"); + +// Built-in function caching +$builtinResult = test_cached_callable_builtin(); +assert($builtinResult === 5, "Cached builtin failed: expected 5, got '$builtinResult'"); diff --git a/tests/src/integration/callable/mod.rs b/tests/src/integration/callable/mod.rs index 3ee4818de..d05e1a066 100644 --- a/tests/src/integration/callable/mod.rs +++ b/tests/src/integration/callable/mod.rs @@ -1,4 +1,4 @@ -use ext_php_rs::{call_user_func_named, prelude::*, types::Zval}; +use ext_php_rs::{call_user_func_named, error::CachedCallableError, prelude::*, types::Zval}; #[php_function] pub fn test_callable(call: ZendCallable, a: String) -> Zval { @@ -61,6 +61,70 @@ pub fn test_callable_duplicate_named(call: ZendCallable) -> Zval { .expect("Failed to call function with duplicate named args") } +#[php_function] +pub fn test_cached_callable_basic(call: ZendCallable, a: String) -> Zval { + let cached = call.cache().expect("Failed to cache callable"); + cached + .try_call(vec![&a]) + .expect("Failed to call cached callable") +} + +#[php_function] +pub fn test_cached_callable_repeated(call: ZendCallable) -> Zval { + let cached = call.cache().expect("Failed to cache callable"); + let mut sum = 0i64; + for i in 0..10i64 { + let result = cached + .try_call(vec![&i]) + .expect("Failed to call cached callable"); + sum += result.long().unwrap_or(0); + } + let mut ret = Zval::new(); + ret.set_long(sum); + ret +} + +#[php_function] +pub fn test_cached_callable_named(call: ZendCallable) -> Zval { + let cached = call.cache().expect("Failed to cache callable"); + cached + .try_call_named(&[("b", &"second"), ("a", &"first")]) + .expect("Failed to call cached callable with named args") +} + +#[php_function] +pub fn test_cached_callable_mixed(call: ZendCallable) -> Zval { + let cached = call.cache().expect("Failed to cache callable"); + cached + .try_call_with_named(&[&"positional"], &[("named", &"named_value")]) + .expect("Failed to call cached callable with mixed args") +} + +#[php_function] +pub fn test_cached_callable_exception_recovery(call: ZendCallable) -> Zval { + let cached = call.cache().expect("Failed to cache callable"); + + let first = cached.try_call(vec![&true]); + assert!(first.is_err(), "First call should have thrown"); + assert!( + matches!(first, Err(CachedCallableError::PhpException(_))), + "Should be a PhpException" + ); + + cached + .try_call(vec![&false]) + .expect("Cached callable should recover after exception") +} + +#[php_function] +pub fn test_cached_callable_builtin() -> Zval { + let strlen = ZendCallable::try_from_name("strlen").expect("Failed to get strlen"); + let cached = strlen.cache().expect("Failed to cache strlen"); + cached + .try_call(vec![&"hello"]) + .expect("Failed to call cached strlen") +} + pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { builder .function(wrap_function!(test_callable)) @@ -71,6 +135,12 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { .function(wrap_function!(test_callable_empty_named)) .function(wrap_function!(test_callable_builtin_named)) .function(wrap_function!(test_callable_duplicate_named)) + .function(wrap_function!(test_cached_callable_basic)) + .function(wrap_function!(test_cached_callable_repeated)) + .function(wrap_function!(test_cached_callable_named)) + .function(wrap_function!(test_cached_callable_mixed)) + .function(wrap_function!(test_cached_callable_exception_recovery)) + .function(wrap_function!(test_cached_callable_builtin)) } #[cfg(test)]