Skip to content

feat: frankenphp_get_vars() and frankenphp_set_vars() - #2635

Open
nicolas-grekas wants to merge 18 commits into
php:mainfrom
nicolas-grekas:bgworker-vars
Open

nicolas-grekas wants to merge 18 commits into
php:mainfrom
nicolas-grekas:bgworker-vars

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2617: the first commit is that PR's, review the second one (48917b5).

The shared-state half of #2287. A background worker publishes a snapshot with frankenphp_set_vars(array $vars): void; requests, HTTP workers and other background workers read it with frankenphp_get_vars(string $name): array, by worker name resolved the way requests are (within the php_server, then among global workers). The persistent-zval toolkit from #2366 does the cross-thread copies; this adds the two functions and a per-worker slot, about 60 lines of C and 130 of Go.

Semantics: each set_vars() call replaces the whole snapshot, readers get a copy, and the slot belongs to the worker rather than a thread, so the last snapshot keeps being served while the script restarts and several threads of one worker publish last-writer-wins. Values must be null, scalars, arrays or enums.

get_vars() blocks until the worker reached its ready point once. Since activateServers() runs after initWorkers(), requests never wait: a blocked caller is another background worker still booting, so those waits form a graph and a cycle throws instead of deadlocking Init(). A ready worker that never published throws, as does an unknown name. Publishing before the first wait on the handle is therefore enough to have the snapshot in place before the server accepts requests, which replaces what ensure_background_worker() did for declared workers. Readiness itself is unchanged: set_vars() plays no part in it.

Also fixes the toolkit's immutable-array fast path in zval.h, found by being its first consumer to expose the same tree repeatedly: it referenced opcache-immutable arrays through refcounted zvals, and opcache only keeps their refcount at 2 as a safety net, so the second reader's release destroyed bucket memory in shared memory (ASan: bad free). Those zvals now carry no type flags, as php-src does for literals; the roundtrip test exposes the same literal three times. A second one, caught by the Windows job where the embed runs without opcache: the toolkit shared every interned string by pointer, but only permanent ones (opcache, startup) outlive the request that interned them, so a persistent tree built from request-interned literals dangled once that request ended. Sharing now requires IS_STR_PERMANENT.

Left out on purpose: the per-request cache with === identity and the unchanged-data skip in set_vars() (optimizations, #2287 has them when there are numbers to justify it), ensure_background_worker(), lazy and catch-all workers, and hiding the functions in CLI mode.

Background workers run a script in a loop outside the HTTP request
cycle, sharing the PHP runtime with the request threads. Rebuilt on
Server from php#2499: a background worker attaches to a php_server through
WithWorkerServerScope() like any other worker.

Declared with "background" in a worker block (php_server or global) or
WithWorkerBackground() in Go. name is required, match is rejected,
num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative
exit, restart with a capped quadratic backoff on a crash,
max_consecutive_failures
fails Init() during startup only. drain() runs on shutdown, reboot and
handler transitions so a parked script wakes up instead of waiting out
the force-kill grace period.

Their threads live outside the num_threads / max_threads budget, which
describes HTTP capacity: those settings size the pool background workers
never draw from, so calculateMaxThreads() resolves them against the HTTP
workers alone and returns the background threads separately, for Init()
to add to the totals. Nothing is subtracted back out.

Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'],
HTTP workers included: the documented contract is to test its presence,
not its value. Background workers also get
$_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles
can tell them apart with isset(). Both names are reserved: an env of the
worker or of its server never leaks either into a worker of the other
kind.

The script gets one handle, frankenphp_get_worker_handle(), a stream
that reaches EOF when the worker is drained, meant to carry control
messages later. It is backed by a socket pair, not a pipe: on Windows
PHP's php_select() only waits properly on sockets before 8.5. Streams
do not own the socket (php_sockop_close() would shutdown() it on
Windows), so a stream can be closed and fetched again without losing the
drain signal; the read timeout is infinite so a blocking read parks as
well as stream_select() does. Both ends are non-inheritable.

A worker counts as ready on its first wait on the handle (select cast
or read), the background analog of frankenphp_handle_request(): Init()
waits for it, ready_workers counts from it, and an exit before it is a
boot failure. The handle's stream ops, copied from the socket ops at
MINIT, report it once per run. A run gets one stream: every call returns
the same resource until the script closes it, so fetching the handle in a
loop does not grow the resource list of a request that never ends.

Worker names are scoped like paths: unique within a php_server or
among global workers. The script sees the declared name; metrics and
logs report a scoped worker as "<server name>:<name>", with a numeric
suffix on server names when two blocks resolve to the same one, never a
name another block configured. The collision-driven renaming in the Caddy
module is gone, and WithWorkerName() resolves within the request's server
first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a
php_server block were reported under their bare name unless it collided:
both changes are called out in the docs.

Two places absorb the new worker kind rather than growing a copy of what
exists. The states a worker thread walks through between two runs live in
workerLifecycle, embedded by both handlers, which supply only what
differs: how a run starts, and what a reboot resets. And a worker without
a scope now belongs to the fallback server, the one already serving the
requests that have no server either, so a lookup is always a lookup in a
server and the parallel registry of global workers is gone.

Supersedes php#2543 and php#2398.
stream_socket_recvfrom() and the other transport receives never reach the
read op: they go through the stream's transport API, which the handle
inherited unchanged from the socket ops. A script parking that way was
therefore never reported ready and Init() waited for it forever.

The set_option op is now wrapped too, reporting the wait on a receive.
The new fixture hangs Init() without it.
FRANKENPHP_WORKER and FRANKENPHP_WORKER_BACKGROUND say what is running the
script, so FrankenPHP owns them. Dropping the background flag from the
worker's own env was not enough: $_SERVER is built from the process
environment first, then the php_server env, then the worker's, so a value
from any layer below survived and an HTTP worker answered the documented
isset() check.

They are now removed after all the layers are merged, for the kind of
thread that must not carry them. The test declares the flag in the worker
env, the server env and the process environment.
…olds

Two small ones on the restart path. The quadratic backoff multiplied
before capping, which overflows a duration of nanoseconds past some 300k
consecutive failures and sleeps for a negative one; a background worker
crashing past its ready point counts without bound and reaches that in a
few days of retries. The cap now comes first, for the same schedule.

Closing the Go side of a handle only lands as EOF on the script's end
while no other process holds a copy, and a pcntl_fork() child inherits
every descriptor of the process, the pairs of the other threads included.
Shutting the write direction down first sends the FIN regardless, so a
parked script still wakes up instead of waiting out the force-kill.

Also drops a platform conditional: php_network.h maps closesocket to
close outside Windows.
An explicit max_threads gets the reservation added on top, so the HTTP
capacity it describes is preserved. The automatic limit did not: it
resolved from a num_threads that already included the background threads,
so declaring three of them turned a limit of 4 into 10, and a
memory-derived estimate could be swallowed whole, leaving no room for
HTTP autoscaling at all.

The main thread now knows what part of its count is reserved, resolves
the estimate on the rest, and adds the reservation back, like the
explicit path.
resetForReboot() was redundant, setupWorkerScript() already resets the
request count before every run, so the lifecycle interface is down to
the one step that differs. A worker always has a server now, so the
extension dispatch has a single path. zend_alter_ini_entry_chars() takes
the literal, and php_network.h maps closesocket to close outside Windows.

The ready_workers help text and docs said fetching the handle marks a
background worker ready; the first wait on its stream does, as the
validation test asserts. A parked fixture no longer disables
max_execution_time itself, which is how the engine disabling it went
untested, and a new test parks past a one-second limit with max_input_time
set, the case where php_execute_script() re-arms it.
Two paths the suite took for granted. A worker parked on its handle must
survive default_socket_timeout as well as max_execution_time, so the
fixture that disables neither now runs with both set to one second. And a
run gets one handle: the second fetch is the same stream, a fetch after
closing it is a fresh one, and the drain still reaches the script through
that one.
The directive, the Go option and the docs section carry the flag, the
function did not.
…d workers

Readiness rode on intercepting every PHP path that waits on the handle:
the read op, the select cast, the transport receive missed at first, and
whatever a future PHP adds. The contract was also read three different
ways during review. It is now a call the script makes, the background
analog of frankenphp_handle_request(): the first frankenphp_worker_tick()
of a run marks the worker ready, every call returns false once the worker
is drained, and the read, cast and transport hooks are gone.

The tick never blocks and never hands out work. It consumes whatever the
runtime wrote on the handle to wake the script up, so the script only
ever selects on the handle, alone or with its own streams, and the
protocol on it stays private.
…ched

FRANKENPHP_WORKER stays what it always was, "1" in HTTP workers, and is
not set in background workers, where FRANKENPHP_WORKER_BACKGROUND holds
the declared name instead. A script serving both roles tests which of
the two is set. The removal of inherited values is gone with the change
that motivated it: nothing about HTTP workers moves in this PR anymore.
A script that registers its handle with an event loop and runs it only
ticks when the handle is readable, so it never became ready before the
drain and Init() waited for it. One wake-up written at run setup makes
such a loop tick on its own: readiness then means the loop serviced the
handle once. The first frankenphp_worker_tick() consumes it, and a
script parked in a blocking read without ticking now fails its boot
fast instead of hanging the start.
The shared lifecycle keeps its struct, embedded by both worker handlers;
the interface that named the one step they supply is gone, that step is
a parameter.
The channel was set to nil once Init() had decided, read without
synchronization by the handlers, and the send blocked on a buffer sized
to the thread count. A background worker can tick, exit and fail its
next boot while Init() finishes, which an HTTP worker cannot since it
blocks once ready: that exit could race the nil write, or block its
thread on a full buffer. The channel now stays, an atomic startup flag
gates the sends, and the send never blocks.
Until its first frankenphp_worker_tick(), a run is under the limit like
any request: a setup that outlives it ends as a boot failure, with the
backoff and the cap. The first tick disarms the timer, and nothing
re-arms it past that point, so the loop has no time limit, like the
CLI. This replaces the per-request ini override, which exempted the
bootstrap too.
Cancelling the handle's watcher only removes that one callback, and
run() keeps going while any other referenced watcher exists. A drained
worker has to leave its loop, which is the driver's stop().
… fire

The limit is PHP's: on Windows CI the busy bootstrap ran its full five
seconds without the timer ending it, so the test now runs only with the
Zend max execution timers of ZTS builds on Linux, where it passes.
The shared-state half of php#2287, on top of the background workers: a
worker publishes a snapshot with frankenphp_set_vars(), requests and
other workers read it with frankenphp_get_vars(). The persistent-zval
toolkit from php#2366 does the cross-thread copies; this adds the two
functions and a per-worker slot.

set_vars() validates the tree, persists it and swaps it into the slot
under a write lock; readers copy it into request memory under the read
lock, so the previous table is only freed once no reader is on it. The
slot belongs to the worker rather than a thread: it survives script
restarts, serving the last snapshot meanwhile, and several threads of one
worker simply publish last-writer-wins. The tables are freed in
drainPHPThreads() once every PHP thread is gone and before the engine is,
since freeing walks string headers.

get_vars() resolves the name the way requests do, within the caller's
server then among global workers. It blocks until the worker reached its
ready point once: activateServers() runs after initWorkers(), so requests
never wait, and a blocked caller is another background worker still
booting. Those waits form a graph and a cycle is refused with an
exception instead of deadlocking Init(); the wait also aborts on
shutdown. A ready worker that never published throws. Publishing before
the first wait on the handle therefore guarantees the snapshot exists
before the server accepts requests.

Being the first consumer keeping persistent trees across requests and
exposing them repeatedly, this also fixes two fast paths of the toolkit:
opcache-immutable arrays were exposed through refcounted zvals, and
opcache only keeps their refcount at 2, so the second reader's release
destroyed shared memory; and every interned string was shared by
pointer, while only permanent ones (opcache, startup) outlive the
request that interned them, so trees built from request-interned
literals dangled once that request ended (the Windows job runs the embed
without opcache). Immutable arrays now go through zvals without type
flags, as php-src does for literals, and sharing a string requires
IS_STR_PERMANENT.

Left out on purpose, see php#2287: the per-request cache with === identity,
the unchanged-data skip in set_vars(), ensure_background_worker() and
lazy or catch-all workers, CLI hiding of the functions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant