feat: declared background workers + frankenphp_get_worker_handle() - #2617
feat: declared background workers + frankenphp_get_worker_handle()#2617nicolas-grekas wants to merge 7 commits into
Conversation
|
Please rewrite the PR description to not be LLM slop reasoning with itself about what it did and why. I've tried reading this three times and I just can't. |
|
Sure, I'll let you know when I'm done, for now I just let it do the rebase 😅 |
henderkes
left a comment
There was a problem hiding this comment.
What happens here when a global background worker and a php_server scoped background worker share the same name and are both eligible for the same source file?
henderkes
left a comment
There was a problem hiding this comment.
found another one, anyway, have you tested this on windows?
There was a problem hiding this comment.
Pull request overview
Adds declared background PHP workers with graceful stop-stream handling and Caddy configuration support.
Changes:
- Adds background-worker lifecycle, validation, and thread allocation.
- Exposes
frankenphp_get_worker_handle(). - Adds Caddy integration, documentation, fixtures, and tests.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
worker.go |
Registers and validates background workers. |
threadbackgroundworker.go |
Implements background-worker lifecycle. |
requestoptions.go |
Rejects background workers for HTTP requests. |
phpthread.go |
Drains handlers during shutdown and transitions. |
phpmainthread.go |
Drains handlers during reboot. |
options.go |
Adds WithWorkerBackground(). |
frankenphp.go |
Reserves background-worker threads. |
frankenphp.c |
Implements stop pipes and PHP API. |
frankenphp.h |
Declares C primitives. |
frankenphp.stub.php |
Declares the PHP function. |
frankenphp_arginfo.h |
Registers generated arginfo. |
docs/config.md |
Documents background configuration. |
caddy/workerconfig.go |
Parses background worker blocks. |
caddy/config_test.go |
Tests Caddy parsing and validation. |
bgworker_test.go |
Tests lifecycle, restart, scope, and validation. |
testdata/bgworker/basic.php |
Provides lifecycle fixture. |
testdata/bgworker/crash.php |
Provides restart fixture. |
testdata/bgworker/early-return.php |
Provides startup-failure fixture. |
testdata/bgworker/named.php |
Provides named-worker fixture. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Hmm I don't remember the specific reason why background workers are treated separately here. Wouldn't it make sense to just do this and count it into the general pool, like other workers:
if w.num <= 0 {
if w.isBackgroundWorker {
opt.workers[i].num = 1
} else {
opt.workers[i].num = maxProcs
}
}Worker thread count is already added on top of the general thread count. It would only overflow in case someone sets a general cap on global threads, in which case it should probably still honor that cap.
There was a problem hiding this comment.
The num_threads / max_threads budget exists for autoscaling HTTP workers, and background workers take no part in that: they don't scale, don't queue requests and never compete for a free thread. Counting them into the pool would change what the budget means depending on how many background workers a config declares: declare five and you silently get five fewer HTTP threads, so people would have to bump the budget just to keep the capacity they had, and the setting stops describing HTTP capacity. That's why they're reserved on top: the HTTP admission math is untouched and the totals are bumped afterwards (reservedThreads). Requiring an explicit num keeps that reservation visible in the config rather than defaulted.
There was a problem hiding this comment.
The reservation is right for num_threads, but max_threads auto is memory-derived and then floored back up to numThreads, so background workers silently push past that ceiling. Failing Init() on the total, instead of bumping max_threads, would keep it.
There was a problem hiding this comment.
auto is a memory heuristic that num_threads already overrides through the same floor; background threads are fixed threads and get the same treatment. Failing Init() would let a heuristic reject an explicit config, so they stay on top.
There was a problem hiding this comment.
IMO it's a bit unfortunate that the ceiling exists in the first place, it would be better to have something like num_regular_threads and max_regular_threads, so people don't have to do maths with worker thread count.
But with the current logic all workers count toward that ceiling (they take away threads from the regular threads), so I think it makes more sense to be consistent when it comes to background workers, or we'll just make it even more confusing.
There was a problem hiding this comment.
Agreed on num_regular_threads / max_regular_threads being the shape that avoids the maths; that would be a separate change to the existing settings.
On consistency: HTTP workers count against the ceiling because they draw from the same pool, autoscale into it and compete with regular threads for it. Background threads never enter that pool: fixed count, no scaling, no requests. Counting them would make num_threads mean a different HTTP capacity depending on how many background workers a config declares.
What the review did change is the plumbing. calculateMaxThreads() used to bump the totals and subtract them back later; it now resolves num_threads / max_threads against the HTTP workers alone and returns the background threads separately, for Init() to add where a real total is needed. No addition-then-subtraction anywhere.
| $stream = frankenphp_get_worker_handle(); | ||
| $read = [$stream]; | ||
| $write = null; | ||
| $except = null; | ||
| stream_select($read, $write, $except, null); |
There was a problem hiding this comment.
It looks like currently the handle is only used for shutdown. IIRC in the future you'd also want to use the handle to send messages or even requests.
Would it maybe be cleaner to have a separate handle for each? Makes the api look more like we're selecting over different channels, in other words:
frankenphp_get_shutdown_handle(); # instead of frankenphp_get_worker_handle
frankenphp_get_message_handle(); # future scope: can return a dedicated message
frankenphp_get_request_handle(); # future scope: can return a dedicated request objectThere was a problem hiding this comment.
I'd rather keep one handle. In the prototype built on this primitive, shutdown, messages and requests all arrive on the same stream as typed messages, and the worker loop is a single stream_select() plus a dispatch on what was read; that worked well in practice. One handle per kind means selecting over N streams, N functions to document and keep in sync, and ordering questions between them (a message landing after shutdown was signalled on another stream). Fewer functions is also less API to get wrong. This PR only uses the EOF-on-drain part, but the handle is meant to carry the rest.
There was a problem hiding this comment.
Hmm I think you're right since streams can only send strings.
What do you think about something like this? Abstracting things a bit allows us to do more in the future without BC breaks.
$worker = new \FrankenPHP\Worker(
onMessage: fn(\FrankenPHP\Message $message) => ...,
onRequest: fn(\FrankenPHP\Request $request) => ...,
onShutdown: fn() => ...
);
$handle = $worker->getHandle();
while ($message = fgets($handle)) { # or the equivalent with stream_select
$worker->handle($message);
}The message can literally be "1", "2", "3", it will be handled internally and forwarded to onMessage() or onShutdown()
There was a problem hiding this comment.
That's pure PHP over the primitive, so it can ship as a package or a docs example and evolve without a FrankenPHP release; in the engine it freezes the callback signatures and the Message / Request shapes before anything uses them. A class can be added later, not removed.
BC-wise the primitive is the smaller surface: "lines, then EOF on drain", where a new kind of message is a new prefix. Three callback signatures and two classes are more to keep stable, not less.
Callbacks also take ownership of the loop, so anything a handler waits on has to be routed back through the dispatcher. The stream composes with Revolt, amphp, ReactPHP or a plain blocking read, none of which have to know about each other.
The tasks of #2636 are the first real message type here and they wanted functions: the loop drains the queue on each wake-up, since a line is a wake-up and not a count, and inside a task the worker does a stream_select() on the task's own stream to notice the sender giving up. A dispatcher handing out one message at a time makes both awkward. frankenphp_handle_request() is callback-shaped because a request has a beginning and an end; a background worker's loop owns the process lifetime.
Hand-rolled dispatch being easy to get wrong is fair, so I'd answer it with a documented loop, and a package if it earns its place.
There was a problem hiding this comment.
The tasks of #2636 are the first real message type here and they wanted functions: the loop drains the queue on each wake-up, since a line is a wake-up and not a count, and inside a task the worker does a stream_select() on the task's own stream to notice the sender giving up. A dispatcher handing out one message at a time makes both awkward. frankenphp_handle_request() is callback-shaped because a request has a beginning and an end; a background worker's loop owns the process lifetime.
That's kind of the point, we're locking ourselves out of any changes/extensions to the api by requiring very specific steps to be followed (receiving literal "task" -> checking frankenphp_receive_task() -> receiving a stream -> passing the stream to frankenphp_update_task -> fclose)
$handle = frankenphp_get_worker_handle();
while ($message = fgets($handle)) {
if ($message === "task") {
while ($task = frankenphp_receive_task()) {
[$stream, $payload] = $task;
frankenphp_update_task($stream, ['progress' => 50]);
frankenphp_update_task($stream, ['result' => process($payload)]);
fclose($stream);
}
}
}It's not just about making the API less awkward, it's also about keeping control over how we handle what is sent in the streams. Doing it somehow like this still allows integrating the handle into amphp/react/etc with minimal surface.
$worker = new \FrankenPHP\Worker(onMessage: function(\FrankenPHP\Message $message){
$message->respond(['result' => process($message->payload)]);
});
$handle = $worker->getHandle();
while ($message = fgets($handle)) {
$worker->handle($message);
}Also allows us to just call exit() directly on shutdown if we want to.
There was a problem hiding this comment.
You're right that "task\n" is a bad idea, my mistake, and the pool case rules it out: a line is a wake-up, not a description. A plain "\n" would be enough, content unspecified.
That leaves the kind to the receive side, which may be where an object fits: frankenphp_receive([Task::class]) returning a Task. The list is the opt-in, so a script only ever sees kinds it knows, the runtime keeps the wire format and can answer for scripts that don't ask, and a new kind is a new class instead of a new function. Does that give you the room you're after? About callback-based approaches, they're doomed to fail: a handler that runs to completion holds one task at a time, so anything that keeps several open and selects across them has to escape it, which is the case multiplexing needs.
Mostly a #2636 discussion, but it belongs here too, since it confirms the shape of this PR: one handle, an opaque wake-up, EOF on drain, nothing parsed.
On exit(): EOF already ends the loop, so a script can return or exit on shutdown with no API for it.
There was a problem hiding this comment.
Yes on one hand a Task class would be much cleaner, on the other hand, forwarding the message on the handler back to us leaves us in control of what to do.
For example when we get back "1", we poll for a message and call the onMessage handler with the message as argument. When we receive "2", we call the onShutdown handler and exit out of the script.
In the future we might want to do something like send "3" when there is a "tick" or "request" event. Gives us more control, eg. we can add a custom \FrankenPHP\Tick and throw an Exception if the handler is not implemented. The user does not need to worry about what "1", "2", "3" means.
Doing this via callback doesn't stop us from handling multiple tasks at a time, $worker->handle($message) just calls another function immediately. The event loop can call $worker->handle($message) before the callback is finished again no problem (unless I'm missing something)
There was a problem hiding this comment.
Looking at how react/revolt incorporate handles, maybe we could even do something like this:
$worker = new \FrankenPHP\Worker(onTask: function(\FrankenPHP\Message $message){
$message->respond(process($message->payload));
});
$handle = $worker->getHandle();
// --- ReactPHP ---
Loop::addReadStream($handle, function () use ($worker) {
$worker->tick(); // handle just is a wakeup mechanism, we can decide what to do here non-blocking
});ac0896d to
3e93a24
Compare
|
Two review-level items. Name collision between a global and a Windows: the Windows workflow runs the full suite on PRs and it passes here on 8.5.10, background worker tests included. It also surfaced that The branch is squashed to 3e93a24; sha references in earlier replies predate the squash. |
3e93a24 to
2f9c5b6
Compare
|
Since the replies above, a self-review pass amended into the single commit (2f9c5b6):
CI: all test jobs pass. The Windows job's caddy-suite timeout ( |
2f9c5b6 to
86bd9af
Compare
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() after reading a "task\n" line on its handle: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. The line is a wake-up, not a count: every thread of a pool gets one per task, the first one back in its loop takes the task and the others get null. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() after reading a "task\n" line on its handle: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. The line is a wake-up, not a count: every thread of a pool gets one per task, the first one back in its loop takes the task and the others get null. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
There was a problem hiding this comment.
🟡 Changes recommended
Handle aliasing, unbounded crash loops, ambiguous metric identities, and extension-worker regressions remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 45/45 changed files
- Comments generated: 5
- Review effort level: Balanced
5bfe3ce to
d020ecf
Compare
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() after reading a line on its handle: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. That line is a wake-up rather than a description, so its content is unspecified and must not be inspected: it says something may be pending, the script finds out what by polling. It is not a count either, since a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The thread taking the task writes that byte, a watcher goroutine does when the wait must end without a pickup. The queue mutex is never held across a syscall and taken once per wake-up, as a thread inside a cgo callback that loses it parks the same expensive way. In the Docker builder image this takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to 320us, and 8 senders on 8 threads from 2k to 32k tasks/s. Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
There was a problem hiding this comment.
🟡 Changes recommended
Scoped extension dispatch drops request options, and background-worker configuration has inconsistent global and environment behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 47/47 changed files
- Comments generated: 3
- Review effort level: Balanced
d020ecf to
73cba0e
Compare
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() after reading a line on its handle: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. That line is a wake-up rather than a description, so its content is unspecified and must not be inspected: it says something may be pending, the script finds out what by polling. It is not a count either, since a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The thread taking the task writes that byte, a watcher goroutine does when the wait must end without a pickup. The queue mutex is never held across a syscall and taken once per wake-up, as a thread inside a cgo callback that loses it parks the same expensive way. In the Docker builder image this takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to 320us, and 8 senders on 8 threads from 2k to 32k tasks/s. Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
There was a problem hiding this comment.
I'm largely happy with the API decisions.
Mostly architectural pointers, I'm a bit concerned with the amount of code duplication and extra code debt.
Also raising this from above here again: #2617 (comment)
| // documented to test its presence, not its value, so HTTP workers moving | ||
| // from "1" to their name breaks nothing. FRANKENPHP_WORKER_BACKGROUND is | ||
| // the presence-only flag telling a script it runs as a background worker | ||
| o.env["FRANKENPHP_WORKER\x00"] = o.name |
There was a problem hiding this comment.
I'd be okay with this, if we weren't immediately introducing another implicit contract by setting FRANKENPHP_WORKER_BACKGROUND to literal 1.
Don't have a solution, but I suspect @AlliBalliBaba may have a smart idea
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.
73cba0e to
ec3892c
Compare
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() after reading a line on its handle: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. That line is a wake-up rather than a description, so its content is unspecified and must not be inspected: it says something may be pending, the script finds out what by polling. It is not a count either, since a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The thread taking the task writes that byte, a watcher goroutine does when the wait must end without a pickup. The queue mutex is never held across a syscall and taken once per wake-up, as a thread inside a cgo callback that loses it parks the same expensive way. In the Docker builder image this takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to 320us, and 8 senders on 8 threads from 2k to 32k tasks/s. Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
|
Thanks, all items should be addressed now! |
I'm not that fast in reviewing 😆. I hope to look again tomorrow. |
| func (s *Server) addWorker(w *worker) error { | ||
| s.workers = append(s.workers, w) | ||
| if s.workersByName[w.name] != nil { | ||
| return fmt.Errorf("%s cannot have the same name: %q", s.scope(), w.name) |
There was a problem hiding this comment.
LLM: [P1] Validate newly rejected duplicate names before stopping the active runtime. This check runs inside Init(), but caddy/app.go:145–149 calls Shutdown() before Init(). Reloading with two distinct existing scripts sharing a name therefore stops the working runtime and only then rejects the configuration.
Reproduced with an actual Caddy server: PHP returned 200 before reload; POST /load returned 400 for the collision; the stored configuration remained the original one, but subsequent PHP requests returned 500 (server is not registered), while static requests still returned 200. Caddy rolls back configuration state, not the stopped PHP runtime.
The destructive ordering predates this PR, but duplicate names are a new trigger: Caddy previously renamed these collisions. A shared, side-effect-free declaration preflight before Shutdown() would preserve the active site and consolidate the rules currently split between Caddyfile parsing, thread calculation, newWorker(), and addWorker(). Include effective names/scopes and background constraints so JSON and Go declarations follow the same rules. Please add a rejected-reload regression that verifies the old PHP site still serves afterward.
There was a problem hiding this comment.
The destructive ordering is real but it is not specific to duplicate names: Start() calls Init() after the old app stopped, so any declaration error, a missing file, a bad num, an invalid ini, already leaves the runtime down while Caddy rolls the configuration back. Duplicate names being an error rather than an auto-rename adds one more trigger, it does not create the failure mode.
The fix belongs in its own change: a Validate() on the app, which Caddy runs before stopping the previous one, over a side-effect-free preflight shared with Init(). That is worth doing, and worth doing for every declaration rule at once rather than for this one.
|
|
||
| static int frankenphp_worker_handle_cast(php_stream *stream, int castas, | ||
| void **ret) { | ||
| if (castas == PHP_STREAM_AS_FD_FOR_SELECT) { |
There was a problem hiding this comment.
LLM: Readiness-contract clarification: PHP_STREAM_AS_FD_FOR_SELECT is also used by introspection, not only stream_select(). Reproduced that stream_isatty($handle) returns false but still releases Init() before any read or select; a fetch-only control at the same bootstrap gate kept Init blocked.
This is consistent with the commit's literal 'select cast' mechanism, so I would treat it as a design/documentation clarification rather than an unequivocal correctness regression. However, user-facing 'first wait' wording suggests stronger semantics than this hook can guarantee, and an introspection call before later bootstrap work changes failure classification. Please settle and document the supported readiness boundary before adding more per-function detection. Checking ret != NULL alone would not exclude isatty, because it also performs a real cast.
There was a problem hiding this comment.
Accepted as the boundary: readiness is the first operation that waits or prepares to wait on the stream, and a cast for select is that. stream_isatty() also casts, so it marks the worker ready early; harmless, since the only effect is that startup stops waiting for a script that already has its handle.
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.
|
Posting it here again for visiblity, since the end goal is to be able to integrate into a PHP event loop I'd much prefer an API that looks somewhat like this since it gives us more control to optimize and extend WDYT @henderkes @alexandre-daubois @dunglas @nicolas-grekas $worker = new \FrankenPHP\Worker(
onTask: function(\FrankenPHP\Task $task){
$task->respond(process($task->payload));
}.
onShutdown: function(){
// exit afterwards
}
);
$handle = $worker->getHandle();
// event loop example (this is how Revolt adds streams)
Loop::addReadStream($handle, function () use ($worker) {
$worker->tick(); // calls onTask if there is a task, calls onShutdown if shutting down, nonblocking
}); |
This comment was marked as resolved.
This comment was marked as resolved.
|
Isn't this one PR early to focus on this? The only contract here is a stream select + EOF to close, which wouldn't prevent a userland wrapper around it. I think I'd rather avoid an OOP API that suggests users can programmatically control the server like in Swoole, until that's actually true for more than just stream creation. |
|
We kind of need to look ahead, frankenphp_worker_on_task(function($task){
$task->respond(process($task->payload));
});
frankenphp_worker_on_shutdown(function($task){
//...
});
$handle = frankenphp_worker_get_handle();
Loop::addReadStream($handle, function () {
frankenphp_worker_tick();
});Allows us to keep in control over what happens on wakeup. Also allows us to cleanup after the callback (not possible in the current api). Also allows us to send an initial readiness check as requirement to reach |
|
The current contract requires both get_worker_handle and a stream read/select, but you're right that an explicit readiness is better. Why do you want to turn worker_on_task into a callback that's invoked by worker_tick? Or what's the point of the latter? I think for now I'd favour the global function variant with a potential oop wrapper left to userland. |
|
2 main reasons looking at the example here. If the response stream of the task is not closed, it will lead to endless hanging/stream leak. Having a callback allows us to close the stream automatically, making those bugs not possible to begin with. Extending the api is more awkward since you leave it up to the user to type check whatever comes back from |
|
Oh... the new proposal went a lot further than the original draft PR. I don't like that at all. For a dispatch-and-receive Task worker implementation we need better than manually selecting and writing over streams in userland. Cleanup concern aside, it's too much of an implicit contract that will be gotten wrong. You're right that, at the very least, we need a Task abstraction. Operations like But I'm not even sure I want all that in FrankenPHP right now. If we, as in @php/frankenphp-collaborators do, then we actually need to think over changing the surface this PR introduces too. Perhaps good to talk this over in person at API Con... are you coming @AlliBalliBaba ? |
|
The task PR is really to be considered & discussed in depth after the first two. This very one on its own is great already as it allows eg running a messenger consumer in the same process. The next one is the whiteboard idea and is really this first innovative step. Achieving that alone would be a big win for the ecosystem already. Then the task API can be shipped on it's own. Happy to discuss it later. |
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. This is the smallest useful slice of #2398, rebuilt on the
Serverof #2499: the parallelScopemachinery is gone, a background worker attaches to aphp_serverthroughWithWorkerServerScope()like any other worker.Declared with
backgroundin a worker block (php_serveror global) orWithWorkerBackground()in Go.nameis required, it is the script's identity;matchis rejected;num >= 1, no lazy start here.$_SERVER['FRANKENPHP_WORKER']now carries the name for every worker, HTTP ones included (the documented contract is to test its presence, not its value, as suggested on #2393), and$_SERVER['FRANKENPHP_WORKER_BACKGROUND']is set in background workers so a script serving both roles can tell them apart withisset(). The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with a capped quadratic backoff on a crash,max_consecutive_failuresfailsInit()during startup only. A crash past the ready point is paced by that backoff but never counted toward the cap, which is about a script that never boots: unlike an HTTP worker, paced by the traffic it needs before it can crash, a background worker reaches its ready point on its own and would otherwise spin.drain()now runs on shutdown, reboot and handler transitions, so a parked script wakes up instead of waiting out the force-kill grace period.The script gets one handle,
frankenphp_get_worker_handle(): resource, a stream that reaches EOF when the worker is drained. It is meant to carry control messages later, hence one handle rather than one per purpose. It is backed by a socket pair, not a pipe: on Windows PHP'sphp_select()only waits properly on sockets before 8.5, and the socket path is version-independent. Streams don't own the socket (php_sockop_close()wouldshutdown()it on Windows), so a run gets one stream, and closing it then fetching again yields a fresh one over the same socket without losing the drain signal; the read timeout is infinite so a blocking read parks as well asstream_select()does.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_workerscounts from it, and an exit before it is a boot failure. Fetching the handle is not the ready point, nothing forces a script to fetch it after bootstrapping.Worker names are now scoped like paths: unique within a
php_serveror among global workers, so two blocks may each declarequeue. 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. The collision-driven renaming in the Caddy module is gone.Deferred: lazy start (
frankenphp_ensure_background_worker()), catch-all workers, shared-state APIs, and the orchestrator-style runtime API discussed in #2398.Supersedes #2543 and #2398.