feat: frankenphp_get_vars() and frankenphp_set_vars() - #2635
Open
nicolas-grekas wants to merge 2 commits into
Open
feat: frankenphp_get_vars() and frankenphp_set_vars()#2635nicolas-grekas wants to merge 2 commits into
nicolas-grekas wants to merge 2 commits into
Conversation
nicolas-grekas
force-pushed
the
bgworker-vars
branch
2 times, most recently
from
September 6, 2026 20:38
8803c92 to
bddf0bc
Compare
4 tasks
nicolas-grekas
force-pushed
the
bgworker-vars
branch
from
September 7, 2026 06:07
bddf0bc to
6970bf8
Compare
This was referenced Sep 7, 2026
nicolas-grekas
force-pushed
the
bgworker-vars
branch
from
September 7, 2026 13:54
6970bf8 to
d267dac
Compare
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.
nicolas-grekas
force-pushed
the
bgworker-vars
branch
from
September 7, 2026 14:11
d267dac to
dde2cd3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withfrankenphp_get_vars(string $name): array, by worker name resolved the way requests are (within thephp_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. SinceactivateServers()runs afterinitWorkers(), requests never wait: a blocked caller is another background worker still booting, so those waits form a graph and a cycle throws instead of deadlockingInit(). 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 whatensure_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 requiresIS_STR_PERMANENT.Left out on purpose: the per-request cache with
===identity and the unchanged-data skip inset_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.