Skip to content

feat: frankenphp_get_vars() and WorkerHandle::setVars() - #2635

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

nicolas-grekas wants to merge 39 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, itself on #2664: review the last two commits, from 406f72b.

The shared-state half of #2287. A background worker publishes a snapshot with setVars(array $vars): void on its WorkerHandle; 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). Publishing takes a handle since a worker only publishes its own vars, reading takes a name since anyone reads anyone's. 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 setVars() 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 tick 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: setVars() 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 setVars() (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.

nicolas-grekas and others added 29 commits September 21, 2026 19:04
…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.
A background worker serves no requests, so it does not scale with the
CPUs like an HTTP one and nearly every declaration wrote "num 1". It is
now the default, and declaring "background" is enough; a pool still asks
for the threads it wants.
The missing stop socket of a background worker is an invariant, not a
runtime error: the pair is opened before the script starts and closed at
the next run setup, so the two guards are asserts now. A thread reaching
the ready callback without a background handler would wait out Init()
silently, so it panics instead. The context of a background run is not a
dummy request, and the field says so. The scope of a name collision is a
local variable rather than a method, and the Caddyfile reference keeps
the short version of the "background" line, the long one lives in the
worker documentation.
A loop selecting on the handle blocks rather than spinning, because the
tick consumed the wake-ups, the one sent at start included. The fixture
polls the handle before and after a tick and the docs say so.
Two workers on one script, told apart by a matcher, are the documented
way to give slow endpoints their own thread pool. The Caddy module used
to make their generated names unique, this PR moved the collision check
into the core and dropped that, so the configuration stopped booting.

A name generated from the script path is not a declaration: it gets a
numeric suffix, as before. A declared name still collides, which is what
a background worker needs to keep its identity.
Packing the server into the worker name changed every label value of a
php_server worker, which breaks the dashboards and alerts built on them.
The two are separate labels now, worker="<name>" and server="<name>",
empty for a global worker, so a query on the worker name alone selects
that worker in every server and the values are the ones FrankenPHP
always reported.
- pace a run that ends right after its ready point, clean or crashed, and cut the wait short on drain
- route extension SendRequest() to its worker directly and make SendMessage() fail after Shutdown()
- keep the declared path as the default name of a global Caddy worker
- guard the background run context with contextMu
- one drain owner on phpThread, one background TLS reset in C, the public read-timeout stream option
- docs: metric labels, the platform condition of the bootstrap bound, stream_select() and FD_SETSIZE
A worker without a name is reported under the path of its script, which
newWorker() resolved through symlinks. Deployments that publish releases
behind a symlink then move every worker label at each deploy, since the
resolved path names the release directory.

The default is now the path as declared, made absolute, for the Caddy
module and the Go API alike, so naming them in the module is no longer
needed; it would also make two workers sharing a script collide, where
an undeclared name gets a numeric suffix instead.
Every exit past the ready point was counted toward the restart backoff
unless the run outlived its one second cap, so a worker processing a
batch and returning, which is how a script keeps its memory fresh, was
throttled to one run per second after four of them.

A run now counts only when it ended too fast to have done anything, a
tenth of a second, which is what tells a spinning script from a working
one. A script returning at once is still paced the same way.
Splitting the identity of a worker into two labels changed the signature
of every worker method of the Metrics interface, which an implementation
living outside this repository has to follow.

Those methods keep the single identifier they always took. One method
carries the labels instead: DeclareWorker() names them once, before
anything else mentions the worker, and the Prometheus implementation
resolves the identifier through them. An outside implementation adds
that method, empty when it has no use for the labels, and keeps the rest
untouched.

The identifier is the qualified name again, so two workers that would
report under one are rejected at startup, as before.
… interface

DeclareWorker() restored the signatures of the worker methods but still added a method to Metrics, which an implementation outside this repository has to grow before it compiles again.

Metrics is now the interface it was, a worker scoped to a server being reported as "<server name>:<name>" as before. An implementation that also satisfies ServerMetrics receives the two names apart, which is what PrometheusMetrics does to label its series; the runtime picks the right shape once, in WithMetrics().
frankenphp_get_worker_handle() and frankenphp_worker_tick() are gone,
replaced by FrankenPHP\WorkerHandle: tick() is the ready point and the
liveness check, getStream() the stream to wait on, isValid() whether the
run still holds its socket.

One object instead of two global functions, and a place for the task API
to land. Only waiting on the stream is supported, what it carries is not
part of the contract and tick() consumes it, so the descriptor stays out
of the contract. On PHP 8.6 the class can implement Io\Poll\Handle
without moving anything else, which is what the polling API discussion
asked for.

A run still has one stream whatever the number of handles, so a script
may take one wherever it needs it.
The cache that keeps a loop from growing the resource list of a run moves
from a thread-local slot to the handle that hands the stream out, where
the rest of a handle's state already lives. A handle gives the same stream
every time, a fresh one once the script closed it, and another handle has
its own over the same socket, which is harmless since the stream does not
own it and the drain reaches every one of them.

Nothing of it survives the run any more: the handle takes its stream with
it, so the thread no longer carries one to reset between runs. Asking a
throwaway handle for a stream in a loop stays flat, the object frees its
stream as it goes.
…ake one

The waiting a script does is shown with an Io\Poll\Context first, which is
what a background worker should reach for on 8.6 and, through the polyfill,
below it. The stream keeps its paragraph, as what an event loop takes and
as the fallback for a script with no loop of its own, with the FD_SETSIZE
ceiling of stream_select() named there rather than in the middle of the
explanation.
unserialize('O:23:"FrankenPHP\WorkerHandle":0:{}') builds one on a request
thread without calling the constructor, and tick() on it then panics
go_frankenphp_background_worker_ready() from a cgo callback, taking the
process down: the ZEND_ASSERT that stood there is compiled out of release
builds. getStream() handed out a stream over fd -1 the same way.

The class is now @not-serializable, which refuses that reconstruction, and
being internal and final with a create_object handler it was already out
of newInstanceWithoutConstructor()'s reach. The methods no longer take the
constructor's word for it either: both check the thread they run on and
throw, so the Go side keeps its invariant with nothing able to break it.
crashCount was reset by any run longer than 100ms, so a script exiting
non-zero after, say, 150ms restarted with no backoff at all, some seven
times a second, each one logging a warning and counting a crash. Only a
clean exit resets it now: a worker processing a batch and returning still
starts fresh, a crashing one is paced by the backoff however long it took
to fail.
SendMessage() refused a server that is not registered while SendRequest()
left it to Server.ServeHTTP(). Same ErrNotRunning either way, one less
thing to wonder about when reading the two next to each other.
PHP's socketpair() emulation is not one: it binds a listener to
INADDR_ANY, so the port is reachable from off the machine while the pair
forms, and hands back whichever connection arrives first. The pair is
built here instead, the way libevent and Tor do it: the listener takes
the loopback address alone and SO_EXCLUSIVEADDRUSE, and a connection is
kept only when its peer is the socket we connected with. Another process
racing a connect is dropped and the next one accepted, where the check we
had before failed the whole pair and left the worker to retry.
Init() waits for a background worker to reach its ready point, its first
WorkerHandle::tick(). On a build with Zend max execution timers,
max_execution_time ends a bootstrap that overstays; without them
FrankenPHP disables that limit, so a script that parks before ticking, or
one whose wake-up at start is lost, kept the server start waiting for
ever with a warning as the only trace.

boot_timeout bounds that wait, 30 seconds by default, the same figure
PHP's own max_execution_time uses for the bootstrap it does bound. The
worker is then drained and stopped through the boot-failure path it
already has, and Init() returns the name of the worker that never ticked.
Zero waits for ever, for whoever wants the old behaviour, and HTTP
workers are untouched.
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.
The constructor is not a gate, see the handle's other methods: a caller
that got hold of one some other way would otherwise publish vars from a
thread that is not a background worker, where the Go side has no slot to
put them in.

This branch has not been deployed

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

2 participants