Skip to content

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

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

feat: frankenphp_get_vars() and frankenphp_set_vars()#2635
nicolas-grekas wants to merge 2 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 (dde2cd3).

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 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.

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().

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 every call returns a fresh stream and closing one never
affects another; 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.

Supersedes php#2543 and php#2398.
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