diff --git a/Cargo.toml b/Cargo.toml index 626e02145..575872294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,11 @@ name = "observer_restart" path = "tests/observer_restart.rs" required-features = ["embed", "observer"] +[[test]] +name = "sapi_activate" +path = "tests/sapi_activate.rs" +required-features = ["embed"] + [[test]] name = "sapi_header_safety" path = "tests/sapi_header_safety.rs" diff --git a/allowed_bindings.rs b/allowed_bindings.rs index 884f3f5d2..7d3c02be8 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -350,6 +350,8 @@ bind! { sapi_header_line, sapi_header_op, sapi_header_op_enum, + MODULE_PERSISTENT, + MODULE_TEMPORARY, zend_activate_auto_globals, zend_is_auto_global, zend_llist_get_next_ex, diff --git a/crates/macros/src/module.rs b/crates/macros/src/module.rs index c6ae971a3..e975b8cb4 100644 --- a/crates/macros/src/module.rs +++ b/crates/macros/src/module.rs @@ -86,7 +86,10 @@ fn parser_impl(input: ItemFn, crate_name: Option<&str>, static_ext: bool) -> Res let user = __EXT_PHP_RS_BUILDER_STARTUP .get() .map_or(0, |startup| unsafe { startup(ty, mod_num) }); - let b = ::ext_php_rs::internal::startup_guard(|| { + if a | user != 0 { + return a | user; + } + ::ext_php_rs::internal::startup_guard(|| { // The startup is kept, not taken: a SAPI can shut the module down and // start it again in the same process (FrankenPHP worker restarts), and // every MINIT must register the classes, interfaces, enums and constants. @@ -95,8 +98,7 @@ fn parser_impl(input: ItemFn, crate_name: Option<&str>, static_ext: bool) -> Res Some(startup) => startup.startup(ty, mod_num), None => Ok(()), } - }); - a | user | b + }) } static __EXT_PHP_RS_BUILD_ERROR: ::std::sync::OnceLock<::std::string::String> = diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 3ee58d6f6..e5d59e2c1 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -612,6 +612,8 @@ pub const _ZEND_SEND_MODE_SHIFT: u32 = 25; pub const _ZEND_IS_VARIADIC_BIT: u32 = 134217728; pub const ZEND_MODULE_API_NO: u32 = 20250925; pub const USING_ZTS: u32 = 0; +pub const MODULE_PERSISTENT: u32 = 1; +pub const MODULE_TEMPORARY: u32 = 2; pub const CONST_CS: u32 = 0; pub const CONST_PERSISTENT: u32 = 1; pub const CONST_NO_FILE_CACHE: u32 = 2; diff --git a/guide/src/advanced/worker_mode.md b/guide/src/advanced/worker_mode.md index cdd49f193..48de3a04e 100644 --- a/guide/src/advanced/worker_mode.md +++ b/guide/src/advanced/worker_mode.md @@ -66,3 +66,44 @@ The guard must be dropped before `php_module_shutdown()` is called. Worker mode pairs naturally with a custom `Sapi` implementation. Build the SAPI module once, start it, then use worker mode to cycle between requests without tearing down the full engine. + +## Run code at the start of each request + +In worker mode, PHP does not call the request startup function of an +extension for each request. This is also true for a `FrankenPHP` worker. To +run code for each request, use `ModuleBuilder::sapi_activate_function`. + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::zend::{SapiRequestInfo, set_header, set_response_code}; + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.sapi_activate_function(|info: &SapiRequestInfo| { + if info.request_uri() == Some("/old") { + let _ = set_header("Location: /new"); + let _ = set_response_code(301); + } + }) +} +``` + +PHP calls the closure from `sapi_module.activate` at the start of each +request. This includes each request of a `FrankenPHP` worker, and the first +request that starts the worker script. + +The closure gets the request data from the SAPI: the method, the URI, the +query string and the cookies. The response headers are empty, so +`set_header` and `set_response_code` work. `$_SERVER` does not exist yet, and +PHP code cannot run. + +Obey these rules: + +- Do not load the extension with `dl()`. PHP removes the extension at the end + of the request, but the SAPI keeps the hook. The extension refuses to start. +- Do not use a panic for control. PHP prints the panic message and continues + the request. + +A redirect in the closure does not stop the application. The script, or the +worker callback, runs after the closure. It can replace the headers and write +a body. diff --git a/src/builders/module.rs b/src/builders/module.rs index 0941410d6..54312c8ae 100644 --- a/src/builders/module.rs +++ b/src/builders/module.rs @@ -9,7 +9,11 @@ use crate::{ error::Result, ffi::{ZEND_MODULE_API_NO, ext_php_rs_php_build_id}, flags::ClassFlags, - zend::{FunctionEntry, ModuleAllocations, ModuleEntry, ModuleGlobal, ModuleGlobals}, + zend::{ + FunctionEntry, ModuleAllocations, ModuleEntry, ModuleGlobal, ModuleGlobals, + SapiRequestInfo, + sapi_activate::{self, ActivateCallback}, + }, }; #[cfg(feature = "enum")] use crate::{builders::enum_builder::EnumBuilder, enum_::RegisteredEnum}; @@ -60,6 +64,7 @@ pub struct ModuleBuilder<'a> { request_startup_func: Option, request_shutdown_func: Option, post_deactivate_func: Option i32>, + sapi_activate: Option, info_func: Option, globals_size: usize, #[cfg(php_zts)] @@ -86,6 +91,7 @@ impl Default for ModuleBuilder<'_> { request_startup_func: None, request_shutdown_func: None, post_deactivate_func: None, + sapi_activate: None, info_func: None, globals_size: 0, #[cfg(php_zts)] @@ -187,6 +193,42 @@ impl ModuleBuilder<'_> { self } + /// Runs `callback` at the start of every request, with the request + /// information that the SAPI gives. + /// + /// The callback runs from `sapi_module.activate`, after the SAPI handler. + /// Unlike the request startup function, it also runs for each request of a + /// `FrankenPHP` worker. At this point the response headers are empty, so + /// [`set_header`](crate::zend::set_header) and + /// [`set_response_code`](crate::zend::set_response_code) work. `$_SERVER` + /// does not exist yet and no PHP code can run. + /// + /// A panic in `callback` is printed and ignored. A module loaded with + /// `dl()` refuses to start when it has this callback. Calling this method + /// again replaces the callback. + /// + /// ```ignore + /// use ext_php_rs::prelude::*; + /// use ext_php_rs::zend::{SapiRequestInfo, set_header, set_response_code}; + /// + /// #[php_module] + /// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + /// module.sapi_activate_function(|info: &SapiRequestInfo| { + /// if info.request_uri() == Some("/old") { + /// let _ = set_header("Location: /new"); + /// let _ = set_response_code(301); + /// } + /// }) + /// } + /// ``` + pub fn sapi_activate_function(mut self, callback: F) -> Self + where + F: Fn(&SapiRequestInfo) + Send + Sync + 'static, + { + self.sapi_activate = Some(ActivateCallback::new(callback)); + self + } + /// Sets the extension information function for the extension. /// /// # Arguments @@ -654,7 +696,7 @@ impl ModuleStartup { /// * Returns an error if a constant, interface, class or enum could not be /// registered. The generated MINIT then returns `FAILURE` and PHP refuses /// to start the module. - pub fn startup(&self, _ty: i32, mod_num: i32) -> Result<()> { + pub fn startup(&self, ty: i32, mod_num: i32) -> Result<()> { for (name, val) in &self.constants { val.register_constant(name, mod_num)?; } @@ -679,7 +721,9 @@ impl ModuleStartup { crate::zend::zend_extension::zend_extension_startup(&self.name, &self.version); } - Ok(()) + // SAFETY: this runs in MINIT, and the hook goes in last so that no + // failed step leaves it installed. + unsafe { sapi_activate::install(ty) } } } @@ -725,6 +769,8 @@ impl TryFrom> for (ModuleEntry, ModuleStartup, ModuleAllocatio #[cfg(feature = "observer")] let ext_version = builder.version.clone(); + sapi_activate::set_callback(builder.sapi_activate); + let owned_name = CString::new(builder.name)?; let owned_version = CString::new(builder.version)?; let name = owned_name.as_ptr(); diff --git a/src/error.rs b/src/error.rs index 7227edce2..095788894 100644 --- a/src/error.rs +++ b/src/error.rs @@ -171,6 +171,10 @@ pub enum Error { /// comes after the headers were sent. #[error("the engine refused to change the response headers")] ResponseHeaderFailed, + /// A module loaded with `dl()` has a `sapi_activate_function`. PHP unloads + /// such a module at the end of the request, while the SAPI keeps the hook. + #[error("a module loaded with dl() cannot have a sapi_activate_function")] + SapiActivateUnderDl, /// Failed to make an object lazy (PHP 8.4+) #[error("failed to make the object lazy")] LazyObjectFailed, diff --git a/src/zend/globals.rs b/src/zend/globals.rs index 0d92acd3d..5c119d294 100644 --- a/src/zend/globals.rs +++ b/src/zend/globals.rs @@ -660,6 +660,7 @@ impl<'a> SapiHeader { } } +/// Information about the current request, as the SAPI gives it. pub type SapiRequestInfo = sapi_request_info; impl SapiRequestInfo { diff --git a/src/zend/mod.rs b/src/zend/mod.rs index da49a445a..40882fd8d 100644 --- a/src/zend/mod.rs +++ b/src/zend/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod module_globals; #[cfg(feature = "observer")] pub(crate) mod observer; mod output; +pub(crate) mod sapi_activate; mod streams; mod try_catch; #[cfg(feature = "observer")] @@ -50,6 +51,7 @@ pub use globals::SapiGlobals; pub use globals::SapiHeader; pub use globals::SapiHeaders; pub use globals::SapiModule; +pub use globals::SapiRequestInfo; pub use handlers::ZendObjectHandlers; pub use headers::{add_header, remove_all_headers, remove_header, set_header, set_response_code}; pub use ini_entry_def::{IniEntryDef, IniEntryDefs}; diff --git a/src/zend/sapi_activate.rs b/src/zend/sapi_activate.rs new file mode 100644 index 000000000..d0eefadfa --- /dev/null +++ b/src/zend/sapi_activate.rs @@ -0,0 +1,224 @@ +//! A hook that runs at the start of every request, through +//! `sapi_module.activate`. + +use std::{ + ffi::c_int, + fmt, mem, + panic::AssertUnwindSafe, + ptr, + sync::{ + OnceLock, + atomic::{AtomicPtr, Ordering}, + }, +}; + +use crate::{ + error::{Error, Result}, + ffi::{MODULE_TEMPORARY, ZEND_RESULT_CODE_SUCCESS, ext_php_rs_sapi_globals}, + zend::{ + CatchError, ExecutorGlobals, SapiModule, SapiRequestInfo, bailout, globals::lock, try_catch, + }, +}; + +type Activate = unsafe extern "C" fn() -> c_int; +type Shutdown = unsafe extern "C" fn(c_int, c_int) -> c_int; + +/// The closure given to +/// [`ModuleBuilder::sapi_activate_function`](crate::builders::ModuleBuilder::sapi_activate_function). +pub(crate) struct ActivateCallback(Box); + +impl ActivateCallback { + pub(crate) fn new(callback: F) -> Self + where + F: Fn(&SapiRequestInfo) + Send + Sync + 'static, + { + Self(Box::new(callback)) + } +} + +impl fmt::Debug for ActivateCallback { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ActivateCallback") + } +} + +static CALLBACK: OnceLock = OnceLock::new(); +static USER_SHUTDOWN: OnceLock = OnceLock::new(); +static PREVIOUS: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); + +pub(crate) fn set_callback(callback: Option) { + if let Some(callback) = callback { + let _ = CALLBACK.set(callback); + } +} + +/// Puts the hook in `sapi_module.activate` and keeps the handler it replaces. +/// It also puts [`module_shutdown`] in the module entry, so that MSHUTDOWN +/// removes the hook. +/// +/// # Safety +/// +/// Call it only from MINIT, before any request runs on another thread. +/// +/// # Panics +/// +/// If `EG(current_module)` is null, that is when the caller is not the MINIT +/// that `zend_startup_module_ex` runs. +pub(crate) unsafe fn install(ty: c_int) -> Result<()> { + if CALLBACK.get().is_none() { + return Ok(()); + } + if ty == MODULE_TEMPORARY.cast_signed() { + return Err(Error::SapiActivateUnderDl); + } + + let entry = ExecutorGlobals::get().current_module; + assert!( + !entry.is_null(), + "sapi_activate_function needs EG(current_module): start the module with zend_startup_module_ex" + ); + let ours_shutdown: Shutdown = module_shutdown; + // SAFETY: during MINIT, `EG(current_module)` is the entry that the engine + // calls at MSHUTDOWN, and only this thread uses it. + unsafe { + if let Some(user) = (*entry) + .module_shutdown_func + .filter(|&user| !ptr::fn_addr_eq(user, ours_shutdown)) + { + let _ = USER_SHUTDOWN.set(user); + } + (*entry).module_shutdown_func = Some(ours_shutdown); + } + + let ours: Activate = activate; + let mut module = SapiModule::get_mut(); + let previous = module + .activate + .filter(|&previous| !ptr::fn_addr_eq(previous, ours)) + .map_or(ptr::null_mut(), |previous| previous as *mut ()); + PREVIOUS.store(previous, Ordering::Release); + module.activate = Some(ours); + Ok(()) +} + +/// Puts back the handler that [`install`] replaced, if the hook is still the +/// current one. When another extension put its own hook over this one, both +/// stay, so that its chain still reaches the SAPI handler. +/// +/// # Safety +/// +/// Call it only from MSHUTDOWN, after the last request. +unsafe fn uninstall() { + let ours: Activate = activate; + let mut module = SapiModule::get_mut(); + if module + .activate + .is_some_and(|current| ptr::fn_addr_eq(current, ours)) + { + module.activate = previous(); + PREVIOUS.store(ptr::null_mut(), Ordering::Release); + } +} + +fn previous() -> Option { + let previous = PREVIOUS.load(Ordering::Acquire); + // SAFETY: `PREVIOUS` only holds null or an `Activate` stored by `install`, + // and function pointers are pointer-sized. + (!previous.is_null()).then(|| unsafe { mem::transmute::<*mut (), Activate>(previous) }) +} + +fn sapi_globals_borrowed_mut() -> bool { + cfg_if::cfg_if! { + if #[cfg(php_zts)] { + lock::SAPI_GLOBALS_LOCK.with(|lock| lock.is_locked_exclusive()) + } else { + lock::SAPI_GLOBALS_LOCK.is_locked_exclusive() + } + } +} + +unsafe extern "C" fn activate() -> c_int { + // SAFETY: `sapi_activate` would call the previous handler the same way. + let result = previous().map_or(ZEND_RESULT_CODE_SUCCESS, |previous| unsafe { previous() }); + let Some(callback) = CALLBACK.get() else { + return result; + }; + if sapi_globals_borrowed_mut() { + return result; + } + + // SAFETY: the SAPI globals of this thread are live during `sapi_activate`, + // and no Rust `&mut` to them is alive. The copy only holds pointers to + // strings that the SAPI keeps until the request ends. + let info = unsafe { (*ext_php_rs_sapi_globals()).request_info }; + if let Err(CatchError::Bailout) = try_catch(AssertUnwindSafe(|| (callback.0)(&info))) { + // SAFETY: this frame holds only `Copy` values, so the jump skips no + // destructor. The request startup of PHP and FrankenPHP calls + // `sapi_activate` inside a `zend_try`. Without one, the engine ends + // the process, as for any other bailout at this point. + unsafe { bailout() } + } + result +} + +/// MSHUTDOWN of a module that has the hook. It removes the hook, then runs the +/// shutdown function of the user. +unsafe extern "C" fn module_shutdown(ty: c_int, module_number: c_int) -> c_int { + // SAFETY: the engine runs MSHUTDOWN after the last request. + unsafe { uninstall() }; + USER_SHUTDOWN + .get() + .map_or(ZEND_RESULT_CODE_SUCCESS, |shutdown| { + // SAFETY: the engine would call the user function with these arguments. + unsafe { shutdown(ty, module_number) } + }) +} + +#[cfg(all(test, feature = "embed"))] +mod tests { + use super::*; + use crate::{ + embed::Embed, + ffi::MODULE_PERSISTENT, + zend::{ExecutorGlobals, ModuleEntry}, + }; + + #[test] + fn second_install_does_not_chain_to_itself() { + let chained_to_itself = Embed::run(|| { + set_callback(Some(ActivateCallback::new(|_| {}))); + // SAFETY: all-zero is an entry without functions. + let mut entry: ModuleEntry = unsafe { mem::zeroed() }; + let current = mem::replace( + &mut ExecutorGlobals::get_mut().current_module, + &raw mut entry, + ); + // SAFETY: `Embed::run` holds the lock that every embed test takes, + // so no other request starts until `uninstall`. + unsafe { + install(MODULE_PERSISTENT.cast_signed()).expect("first install failed"); + install(MODULE_PERSISTENT.cast_signed()).expect("second install failed"); + } + ExecutorGlobals::get_mut().current_module = current; + let ours: Activate = activate; + let chained = previous().is_some_and(|previous| ptr::fn_addr_eq(previous, ours)); + // SAFETY: see above. + unsafe { uninstall() }; + chained + }); + + assert!(!chained_to_itself); + } + + #[test] + fn install_refuses_dl_module() { + let refused = Embed::run(|| { + set_callback(Some(ActivateCallback::new(|_| {}))); + // SAFETY: the refusal returns before it writes the SAPI module. + let result = unsafe { install(MODULE_TEMPORARY.cast_signed()) }; + matches!(result, Err(Error::SapiActivateUnderDl)) + }); + + assert!(refused); + } +} diff --git a/tests/frankenphp/run.sh b/tests/frankenphp/run.sh index 2c63ea450..80329405e 100755 --- a/tests/frankenphp/run.sh +++ b/tests/frankenphp/run.sh @@ -10,6 +10,15 @@ readonly URL=http://localhost:9080/worker.php _tmpdir="" _server="" +check_activate_hook() { + local headers + headers="$(curl -fsS --max-time 10 -o /dev/null -D - "${URL}")" + if ! grep -qi '^x-ext-php-rs-activate: /worker.php' <<<"${headers}"; then + printf 'the sapi activate hook did not set its header:\n%s\n' "${headers}" >&2 + return 1 + fi +} + cleanup() { if [[ -n "${_server}" ]]; then pkill -TERM -P "${_server}" -x frankenphp 2>/dev/null || kill "${_server}" 2>/dev/null || true @@ -52,12 +61,14 @@ main() { _server=$! curl -fsS --max-time 10 --retry 30 --retry-connrefused --retry-delay 1 "${URL}" + check_activate_hook local restart for ((restart = 1; restart <= RESTARTS; restart++)); do printf 'restart %d\n' "${restart}" curl -fsS --max-time 60 -X POST http://localhost:9019/frankenphp/workers/restart curl -fsS --max-time 10 "${URL}" + check_activate_hook done } diff --git a/tests/sapi_activate.rs b/tests/sapi_activate.rs new file mode 100644 index 000000000..5a9fcd7ae --- /dev/null +++ b/tests/sapi_activate.rs @@ -0,0 +1,200 @@ +#![cfg_attr(windows, feature(abi_vectorcall))] +#![cfg(feature = "embed")] +#![allow(missing_docs)] +extern crate ext_php_rs; + +use std::{ + ffi::{CString, c_int}, + sync::{ + Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, +}; + +use ext_php_rs::builders::SapiBuilder; +use ext_php_rs::embed::{ + Embed, cleanup_sapi_allocations, ext_php_rs_sapi_shutdown, ext_php_rs_sapi_startup, +}; +use ext_php_rs::ffi::{ + MODULE_PERSISTENT, ZEND_RESULT_CODE_FAILURE, ZEND_RESULT_CODE_SUCCESS, php_module_shutdown, + php_module_startup, php_request_shutdown, php_request_startup, sapi_shutdown, sapi_startup, +}; +use ext_php_rs::prelude::*; +use ext_php_rs::zend::{SapiGlobals, SapiModule, SapiRequestInfo, set_header, try_catch_first}; + +static SAPI_ACTIVATIONS: AtomicUsize = AtomicUsize::new(0); +static SEEN_URIS: Mutex> = Mutex::new(Vec::new()); +static FAIL_STARTUP: AtomicBool = AtomicBool::new(false); + +extern "C" fn user_startup(_ty: i32, _module_number: i32) -> i32 { + if FAIL_STARTUP.load(Ordering::Relaxed) { + ZEND_RESULT_CODE_FAILURE + } else { + ZEND_RESULT_CODE_SUCCESS + } +} + +extern "C" fn counting_activate() -> c_int { + SAPI_ACTIVATIONS.fetch_add(1, Ordering::Relaxed); + ZEND_RESULT_CODE_SUCCESS +} + +fn on_activate(info: &SapiRequestInfo) { + let uri = info.request_uri().unwrap_or_default().to_owned(); + SEEN_URIS + .lock() + .expect("the URI list is poisoned") + .push(uri.clone()); + assert_ne!(uri, "/panic", "the callback panics on purpose"); + set_header(&format!("X-Activate: {uri}")).expect("set_header failed in the hook"); +} + +#[php_module] +pub fn module(module: ModuleBuilder) -> ModuleBuilder { + module + .startup_function(user_startup) + .sapi_activate_function(on_activate) +} + +struct Lifecycle { + sapi_activations: usize, + seen_uris: Vec, + headers: String, + sapi_handler_restored: bool, +} + +fn start_request(uri: &CString) { + SapiGlobals::get_mut().request_info.request_uri = uri.as_ptr().cast_mut(); + assert_eq!(unsafe { php_request_startup() }, ZEND_RESULT_CODE_SUCCESS); +} + +fn end_request() { + unsafe { php_request_shutdown(std::ptr::null_mut()) }; + SapiGlobals::get_mut().request_info.request_uri = std::ptr::null_mut(); +} + +fn run_module_once() -> Lifecycle { + let mut sapi = SapiBuilder::new("sapi-activate", "SAPI activate") + .activate_function(counting_activate) + .build() + .unwrap(); + let sapi = &raw mut sapi; + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + assert_eq!( + php_module_startup(sapi, get_module()), + ZEND_RESULT_CODE_SUCCESS + ); + } + + SAPI_ACTIVATIONS.store(0, Ordering::Relaxed); + SEEN_URIS.lock().expect("the URI list is poisoned").clear(); + + let page = CString::new("/page").unwrap(); + start_request(&page); + let headers = try_catch_first(|| { + Embed::eval("implode('|', preg_grep('/^X-Activate/', headers_list()));") + .ok() + .and_then(|headers| headers.string()) + .unwrap_or_default() + }) + .expect("PHP bailed out"); + end_request(); + + let panic = CString::new("/panic").unwrap(); + start_request(&panic); + end_request(); + + let sapi_activations = SAPI_ACTIVATIONS.load(Ordering::Relaxed); + let seen_uris = SEEN_URIS.lock().expect("the URI list is poisoned").clone(); + + unsafe { php_module_shutdown() }; + let sapi_handler_restored = is_counting_activate(&SapiModule::get()); + + unsafe { + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + cleanup_sapi_allocations(sapi); + } + + Lifecycle { + sapi_activations, + seen_uris, + headers, + sapi_handler_restored, + } +} + +fn is_counting_activate(module: &SapiModule) -> bool { + let expected: unsafe extern "C" fn() -> c_int = counting_activate; + module + .activate + .is_some_and(|current| std::ptr::fn_addr_eq(current, expected)) +} + +fn failed_startup_leaves_sapi_handler() -> bool { + let mut sapi = SapiBuilder::new("sapi-activate", "SAPI activate") + .activate_function(counting_activate) + .build() + .unwrap(); + let sapi = &raw mut sapi; + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + assert_eq!( + php_module_startup(sapi, std::ptr::null_mut()), + ZEND_RESULT_CODE_SUCCESS + ); + } + + FAIL_STARTUP.store(true, Ordering::Relaxed); + let startup = unsafe { (*get_module()).module_startup_func }.expect("no MINIT"); + let result = unsafe { startup(MODULE_PERSISTENT.cast_signed(), 0) }; + FAIL_STARTUP.store(false, Ordering::Relaxed); + assert_ne!(result, ZEND_RESULT_CODE_SUCCESS); + let untouched = is_counting_activate(&SapiModule::get()); + + unsafe { + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + cleanup_sapi_allocations(sapi); + } + + untouched +} + +#[test] +fn hook_runs_once_per_request_after_every_module_restart() { + for lifecycle in 1..=3 { + let seen = std::thread::spawn(run_module_once) + .join() + .expect("the module should start, serve two requests and stop"); + + assert_eq!( + seen.sapi_activations, 2, + "SAPI handler in lifecycle {lifecycle}" + ); + assert_eq!( + seen.seen_uris, + ["/page", "/panic"], + "hook calls in lifecycle {lifecycle}" + ); + assert_eq!( + seen.headers, "X-Activate: /page", + "headers in lifecycle {lifecycle}" + ); + assert!( + seen.sapi_handler_restored, + "SAPI handler restored in lifecycle {lifecycle}" + ); + } + + let untouched = std::thread::spawn(failed_startup_leaves_sapi_handler) + .join() + .expect("the engine should start and stop without the module"); + assert!(untouched, "a failed MINIT installed the hook"); +} diff --git a/tests/src/integration/mod.rs b/tests/src/integration/mod.rs index d23cf4eaf..99358dee0 100644 --- a/tests/src/integration/mod.rs +++ b/tests/src/integration/mod.rs @@ -26,6 +26,7 @@ pub mod observer; pub mod panic; pub mod persistent_string; pub mod reference; +pub mod sapi_activate; pub mod separated; pub mod string; pub mod types; diff --git a/tests/src/integration/sapi_activate/mod.rs b/tests/src/integration/sapi_activate/mod.rs new file mode 100644 index 000000000..df3d0f700 --- /dev/null +++ b/tests/src/integration/sapi_activate/mod.rs @@ -0,0 +1,12 @@ +use ext_php_rs::prelude::*; +use ext_php_rs::zend::{SapiRequestInfo, set_header}; + +fn mark_request(info: &SapiRequestInfo) { + if let Some(uri) = info.request_uri() { + let _ = set_header(&format!("X-Ext-Php-Rs-Activate: {uri}")); + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + builder.sapi_activate_function(mark_request) +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 348e6cb11..97952cee7 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -42,6 +42,7 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { } module = integration::persistent_string::build_module(module); module = integration::reference::build_module(module); + module = integration::sapi_activate::build_module(module); module = integration::separated::build_module(module); module = integration::string::build_module(module); module = integration::variadic_args::build_module(module);