Hello internals, As you may know, FrankenPHP and some other SAPIs (like the experimental ones written in Rust) 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 Symfony and Laravel 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. FrankenPHP (and other SAPIs with a similar mode) reset "real HTTP request" related data by poking into the engine and first-party extensions (https://github.com/php/frankenphp/blob/7f50be9d86261bf7cff9b051ab4fb572e95020ed/frankenphp.c#L400-L646). 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: https://github.com/php/frankenphp/security/advisories/GHSA-v3ph-cgqh-r8p5 - 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: https://github.com/php/frankenphp/security/advisories/GHSA-cm3q-jw2q-9rh5 - a double free because sapi_deactivate() frees SG(request_info).content_type_dup and current_user without clearing them (fixed for PHP 8.6: https://github.com/php/php-src/pull/22974): https://github.com/php/frankenphp/security/advisories/GHSA-h764-4wh7-m7j2 - http_get_last_response_headers() returning headers fetched by a previous request: https://github.com/php/frankenphp/security/advisories/GHSA-pmvg-8483-mw84 - libxml_get_errors() returning errors of a previous request, with parse-error fragments and server paths: https://github.com/php/frankenphp/security/advisories/GHSA-4gfw-9v29-gp9h Third-party extensions detect the boundaries themselves, each in its own way. Xdebug and the Datadog profiler replace sapi_module.activate and sapi_module.deactivate when the SAPI is FrankenPHP. Datadog's tracer and AppSec, and New Relic in an open pull request, hook frankenphp_handle_request(). Blackfire's Symfony bridge, Tideways and Sentry wrap each request with start and stop calls in userland. 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 releases the strtok() state, 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. The libxml extension's RSHUTDOWN drops the external entity loader. Only each extension's maintainers know which of their globals belong to which kind. I'd like to add two optional module hooks, called by the SAPI between two logical requests of the same script: - request_end: release the state tied to the finished request, but keep everything still reachable from userland; - request_begin: set up the next request once its headers and superglobals are available (session.auto_start, Xdebug's triggers). Two engine functions, php_request_end() and php_request_begin(), would 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 (except $_ENV, which comes from the process), set the execution timer, and call the hooks. 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. Core extensions would implement the hooks first; third-party extensions could adopt them at their pace. SAPIs that don't call these functions see no behavior change. I drafted a working patch for master, with the standard and session hooks: https://github.com/dunglas/php-src/tree/request-boundary-hooks. FrankenPHP doesn't build against master yet, so I tested it with a patched version of FrankenPHP and it works. As this is an internal API with no userland or BC impact, an RFC isn't strictly needed. I made a similar but broader proposal almost 3 years ago (https://externals.io/message/122027). I think time has shown that worker modes are here to stay, and this less intrusive proposal could fix several maintenance issues affecting the whole ecosystem. So, I would like your opinion on this before working more on it. Best, -- Kévin Dunglas https://dunglas.dev
