rfc:request_lifecycle_hooks

PHP RFC: Request lifecycle hooks for SAPIs with a worker mode

Introduction

FrankenPHP and some other SAPIs (like Turbine and bext) have optional support for a "worker mode". The main idea behind these worker modes is to use long-running scripts, started when the web server starts, to handle multiple HTTP requests in a loop.

This allows keeping states in memory across requests, such as the Composer autoloader or framework services, instead of re-computing them at the beginning of each new request. This also allows maintaining persistent connections to remote services across requests.

Worker mode dramatically improves performance, at the cost of no longer isolating state between requests and losing the memory leak tolerance provided by the traditional “fire and forget” model. This mode has become increasingly popular since the release of FrankenPHP, and is now natively supported by a large part of the ecosystem (Symfony, Laravel, Yii...).

Currently, the engine and extensions have no knowledge of this, and have no way to detect when a “real” HTTP request starts or ends. As expected, they are only aware of the start (and end) of the main, long-running script. RINIT and RSHUTDOWN run once per script execution, so they only run when the worker script starts and ends.

FrankenPHP (and other SAPIs with a similar mode) reset “logical HTTP request” related data by poking into the engine and first-party extensions. This approach is fragile, and the internal code resetting these states can change without warning in a new PHP version. Each omission results in a cross-request leak or a memory safety bug. FrankenPHP fixed several of them this year:

  • a session ID leaking to other clients when ext/session is a shared module, because FrankenPHP can only reset the session globals when it is built against the session headers;
  • a use-after-free of php://temp streams, because nothing releases SG(request_info).request_body between worker requests and FrankenPHP's own cleanup freed streams it didn't own;
  • a double free because sapi_deactivate() frees SG(request_info).content_type_dup and current_user without clearing them (fixed for PHP 8.6).

Every worker SAPI copies its own subset of php_request_shutdown() and php_request_startup(), and each subset misses different things:

  • the worker API of ext-php-rs, which Rust SAPIs build on, ends and restarts the output layer and the SAPI, and arms the superglobals again;
  • Turbine does the same and writes the session;
  • bext only rebuilds the superglobals and resets the response status.

Third-party extensions detect the boundaries themselves, each in its own way. Xdebug (debugger, base) and the Datadog profiler replace sapi_module.activate and sapi_module.deactivate when the SAPI is FrankenPHP. Datadog's tracer and AppSec, Tideways, and New Relic in open pull requests (#1306, #1328), hook frankenphp_handle_request(). Blackfire's Symfony bridge and Sentry wrap each request with start and stop calls in userland. None of them supports the other worker SAPIs.

Calling RSHUTDOWN and RINIT between worker requests doesn't work: these handlers mix state that needs resetting with state the script keeps. For instance, the standard extension's RSHUTDOWN resets the URL rewriter, but also destroys register_tick_function() callbacks and user stream filters. The session extension's RSHUTDOWN writes and closes the session, but also frees the save handlers registered at boot. Only each extension's maintainers know which of their globals belong to which kind.

Proposal

This RFC adds two optional module hooks, called by the SAPI between two logical requests of the same script, and the engine functions SAPIs use at these boundaries. Core extensions implement the hooks first; third-party extensions can adopt them at their pace. SAPIs that don't call these functions see no behavior change.

What gets reset

When opting into worker mode, users opt into shared state. Not sharing some state unrelated to the HTTP request would be surprising, so the rule for using these hooks is narrow: reset only the state built from the incoming request or tied to its response. The superglobals (except $_ENV), the request body, the session, and the output state, including the URL rewriter used by output_add_rewrite_var(), fall under it.

Other state doesn't depend on the incoming HTTP request, so it's shared between “logical” requests, as in a long-running CLI script: mt_rand(), strtok(), http_get_last_response_headers(), the libxml errors, error_get_last(), putenv() changes, the stat cache. Like any other state shared in worker mode, userland can clear it when needed with the existing functions (http_clear_last_response_headers(), libxml_clear_errors(), error_clear_last()...).

New code should avoid adding global state. When it holds state built from the request, it should release it in request_end.

The hooks must not free anything still reachable from userland: objects, resources, registered callbacks, user stream wrappers and filters, autoloaders. Settings changed with ini_set() and the default timezone are kept, as worker runtimes document today. Process-wide state (umask(), setlocale()) is out of scope: it can't be made per-request in threaded SAPIs.

Module hooks

  • request_end: release the state tied to the finished request, but keep everything still reachable from userland. Output buffers are flushed and headers are sent when it runs.
  • request_begin: set up the next request once its headers and superglobals are available (session.auto_start, Xdebug's triggers).

zend_module_entry gets two trailing fields, request_begin_func (INIT_FUNC_ARGS) and request_end_func (SHUTDOWN_FUNC_ARGS). Extensions declare them with PHP_RBEGIN_FUNCTION() and PHP_REND_FUNCTION(), and set them with STANDARD_MODULE_PROPERTIES_REQUEST_HOOKS(begin, end). STANDARD_MODULE_PROPERTIES_EX sets both to NULL, so existing extensions compile unchanged. Like RINIT and RSHUTDOWN, the hooks live in the module entry: zend_extensions that need them, like Xdebug, already register one.

With these hooks, debuggers, profilers and APMs can tell the “main” request (the worker script, from RINIT to RSHUTDOWN) apart from the logical requests it serves. Xdebug, for instance, could offer both: debug the code that boots the application and runs between logical requests, or debug a single logical request, started by the triggers of that request. It's already possible today, but hacky. SAPIs without a worker mode don't call the hooks: there, RINIT and RSHUTDOWN still mark both the main and the logical request.

PHP_RINIT_FUNCTION(myprof)
{
	/* the main request starts: in worker mode, the worker script boots */
	return SUCCESS;
}
 
PHP_RBEGIN_FUNCTION(myprof)
{
	/* a logical request starts: read its triggers from $_GET or $_COOKIE */
	return SUCCESS;
}
 
PHP_REND_FUNCTION(myprof)
{
	/* the response is sent: send the profile of this logical request */
	return SUCCESS;
}
PHP_REND_FUNCTION(myext)
{
	/* flush and release the data collected for the finished request */
	return SUCCESS;
}
 
zend_module_entry myext_module_entry = {
	STANDARD_MODULE_HEADER,
	"myext",
	NULL,
	PHP_MINIT(myext),
	NULL,
	NULL,
	NULL,
	NULL,
	PHP_MYEXT_VERSION,
	NO_MODULE_GLOBALS,
	NULL,
	STANDARD_MODULE_PROPERTIES_REQUEST_HOOKS(NULL, PHP_REND(myext))
};

Engine functions

php_request_end() and php_request_begin() run the output and SAPI steps that worker SAPIs copy today from php_request_shutdown() and php_request_startup(), import the superglobals of the new request, set the execution timer, and call the hooks.

  • php_request_end() follows the order of php_request_shutdown() without ending the script: flush the output buffers, stop the execution timer, call request_end_func in reverse module order, deactivate the output layer, run sapi_deactivate() and release the request body.
  • php_request_begin() releases the superglobals of the finished request, activates the output layer and the SAPI, starts the execution timer and the default output handler, imports the superglobals of the new request, then calls request_begin_func in module order. The SAPI fills SG(request_info) and SG(server_context) before calling it.

php_request_begin() imports the superglobals itself: every worker SAPI does this step today, and the request_begin hooks need them. $_ENV comes from the process, not the request, so the script keeps it and its changes, as FrankenPHP does today. The other superglobals the script already uses are imported again; the others are armed for just-in-time creation.

The script still reads the superglobals of the finished request after php_request_end(), as worker runtimes document today: code running between two requests, like a framework's terminate step, often needs them. php_request_begin() releases them, $_SESSION included, before the output of the next request is active, so the destructors of the objects stored in them never write to the next client's response. Data a destructor puts back into a superglobal is released too. SAPIs whose script loops in userland, like FrankenPHP, can call php_release_request_globals() when the script asks for the next request, before waiting for it. The destructors then run while the worker is idle instead of delaying the next request, the resources held by the finished request are freed right away instead of when the next request arrives, and an exception thrown by a destructor reaches the script before a request is accepted.

Releasing the request body takes two small changes: php://input streams hold a reference to the body, and freeing the body, for instance when the script closes it, clears SG(request_info).request_body.

A worker SAPI then looks like this:

while (next_request()) {
	/* fill SG(request_info) and SG(server_context) */
	php_request_begin();
	/* call the userland handler */
	php_request_end();
}

Core extensions

Extension request_end request_begin
standard resets the URL rewriter
session writes and closes the session, resets its id, data and status; keeps the save handlers session.auto_start
filter frees its copies of the request input

Without the URL rewriter reset, output_add_rewrite_var() never restarts its output handler after the first request, and its variables accumulate. Without the filter reset, its copies of the request input leak on each request: sapi_activate() drops them without freeing them.

Interaction with the Scope RFC

The Scope RFC and this proposal solve different problems.

The Scope RFC gives an identity to requests running concurrently in the same process. It doesn't provide a point where a request ends: a scope lives as long as something references it, which can be long after the request is finished. It also only covers the superglobals, the session, the default locale and the default timezone.

This proposal is a small internal refactoring that lets each extension release its own request state. It has no impact on userland code and requires no change to it. Worker modes are already widely used and will remain even if the Scope RFC passes, at least for backward compatibility.

Scope will also have to deal with what this proposal solves: letting extensions detect when the response has been sent, and resetting the state tied to the request (sessions, as explained in the Scope RFC, output buffers, the URL rewriter...).

The two can be combined: once Scope is accepted and implemented, php_request_begin() will create a new scope, and SAPIs will get it for free. When the HTTP response is finished, SAPIs call php_request_end(), so extensions have a hook to do what they want when a request terminates, even if the scope survives because it is still referenced.

For instance, with FrankenPHP:

<?php
$scope = null;
while (frankenphp_handle_request(function () use (&$scope) {
    // php_request_begin() has been called by the SAPI: this callback runs in a new scope
    $scope = Scope::getCurrent(); // leaked on purpose
})) {
    // php_request_end() has been called by the SAPI: the session is written
    // and extensions flushed their data, even though $scope is still alive
}

The userland and engine lifetimes stay in sync because the SAPI drives both. Both proposals work well together, and each can ship at its own pace, regardless of the result of the other's vote.

Which SAPIs can use it

  • SAPIs that serve the requests of a worker one after the other (FrankenPHP, Turbine, bext, the ext-php-rs worker API) can replace their copies with php_request_end() and php_request_begin(). As with php_request_startup(), they fill SG(request_info) and set SG(server_context) before calling it.
  • Runtimes that run a userland loop on the CLI SAPI (RoadRunner, Swoole, Workerman) don't own the SAPI, so they can't call these functions.
  • SAPIs that run one script per request (FPM, Apache, NGINX Unit) don't need the hooks and see no change.

Backward Incompatible Changes

None. SAPIs that don't call the new functions see no behavior change, and existing extensions compile unchanged.

Proposed PHP Version(s)

PHP 8.7.

RFC Impact

To SAPIs

See above. In FrankenPHP, this removes the copy of the session reset code, the URL rewriter reset, the RSHUTDOWN/RINIT allow-list, and most of the copies of php_request_shutdown() and php_request_startup().

To Existing Extensions

Opt-in. Extensions that handle worker modes today (Xdebug, Datadog, Tideways, New Relic...) get one abstraction that works with every SAPI that has a worker mode, instead of special-casing FrankenPHP. Moving to it also gives them support for the other worker SAPIs, like the Rust ones, at the same time.

To the Ecosystem

None for userland code.

Open Issues

None.

Future Scope

None.

Voting Choices

Add the request lifecycle hooks and the related engine functions: yes/no, 2/3 majority.

Patches and Tests

References

rfc/request_lifecycle_hooks.txt · Last modified: by dunglas