After an off-list discussion with Tim Düsterhus, I'm retracting some
of the examples from my first email: http_get_last_response_headers(),
the libxml errors, error_get_last() and strtok(). They don't depend on
the incoming HTTP request, so a worker SAPI shouldn't reset them
between "logical" requests. Like any other state shared in worker
mode, userland can clear them when needed with the existing functions
(http_clear_last_response_headers(), libxml_clear_errors(),
error_clear_last()).

The rule I'm proposing is narrower: reset only the state built from
the incoming request or tied to its response. The session module and
output_add_rewrite_var() still fall under it.

The main point of the proposal stands: hooks for "logical" requests
give extensions one abstraction that works with every SAPI that has a
worker mode, instead of special-casing each one.

FrankenPHP will follow the same rule in its next release.

Tim suggested a short RFC. I'll write it soon.

On Thu, Oct 29, 2026 at 6:47 PM Kévin Dunglas <[email protected]> wrote:
>
> 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

Reply via email to