Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions allowed_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions crates/macros/src/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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> =
Expand Down
2 changes: 2 additions & 0 deletions docsrs_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions guide/src/advanced/worker_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
52 changes: 49 additions & 3 deletions src/builders/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -60,6 +64,7 @@ pub struct ModuleBuilder<'a> {
request_startup_func: Option<StartupShutdownFunc>,
request_shutdown_func: Option<StartupShutdownFunc>,
post_deactivate_func: Option<unsafe extern "C" fn() -> i32>,
sapi_activate: Option<ActivateCallback>,
info_func: Option<InfoFunc>,
globals_size: usize,
#[cfg(php_zts)]
Expand All @@ -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)]
Expand Down Expand Up @@ -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<F>(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
Expand Down Expand Up @@ -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)?;
}
Expand All @@ -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) }
}
}

Expand Down Expand Up @@ -725,6 +769,8 @@ impl TryFrom<ModuleBuilder<'_>> 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();
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/zend/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/zend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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};
Expand Down
Loading
Loading