Repository navigation
feat: background workers and FrankenPHP\WorkerHandle - #2617
nicolas-grekas wants to merge 9 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.
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.
|
Round addressed: 2617 The handle built behind the constructor was real and took the process down, Crash pacing: |
|
HTTP workers are untouched, The name is what lets one entry script serve several background workers, switching on which one it is, and that's the shape to push for in Symfony: one script and one bootstrap, the way an app has a single front controller. A script per worker is the alternative and it's supported for those who'd prefer this style. #2635 itself never reads the name, The surface is already smaller than it was: the earlier iteration exposed |
6014ea1 to
a344a99
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() once frankenphp_worker_tick() returned: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: 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. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. 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. Two more savings on the wake-ups. The sender polls a first 10ms slice on its own: only a pickup that outlasts it brings the Go side in, to wake every thread of the worker and to start the goroutine that ends the wait on a drain or the shutdown, so the common case, a pickup within microseconds, spawns no goroutine and wakes no M. And a side's descriptor gets one signal per sleep: an event signals it only while the other side sleeps on it with no signal outstanding, that one is consumed on the next event and the rest is read from the task's state, so a completion behind an update, or a burst of updates, costs no syscall. Together they take a task from 24 to 15 syscalls and its futexes from 4.7 to 0.6: 15% off a plain task, 29% off one carrying 16 updates, and 5 to 15% more throughput under load. The existing worker metrics carry over: busy_workers counts a thread holding a task, from pickup to the close of the task's stream, and worker_queue_depth counts the tasks waiting for a thread, the only queue a background worker has. Two new ones break tasks down: worker_task_count{worker,server,outcome} with completed, aborted (the script ended with the task open), abandoned (the sender gave up first) or timeout (no thread picked the task up in time), settled by whichever side closes first so every task counts once, and worker_task_time, the seconds spent on tasks. The threads endpoint follows: a background thread is busy while it holds a task and waiting otherwise, counted per thread since a script may hold several. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
a344a99 to
e4b287f
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() once frankenphp_worker_tick() returned: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: 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. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. 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. Two more savings on the wake-ups. The sender polls a first 10ms slice on its own: only a pickup that outlasts it brings the Go side in, to wake every thread of the worker and to start the goroutine that ends the wait on a drain or the shutdown, so the common case, a pickup within microseconds, spawns no goroutine and wakes no M. And a side's descriptor gets one signal per sleep: an event signals it only while the other side sleeps on it with no signal outstanding, that one is consumed on the next event and the rest is read from the task's state, so a completion behind an update, or a burst of updates, costs no syscall. Together they take a task from 24 to 15 syscalls and its futexes from 4.7 to 0.6: 15% off a plain task, 29% off one carrying 16 updates, and 5 to 15% more throughput under load. The existing worker metrics carry over: busy_workers counts a thread holding a task, from pickup to the close of the task's stream, and worker_queue_depth counts the tasks waiting for a thread, the only queue a background worker has. Two new ones break tasks down: worker_task_count{worker,server,outcome} with completed, aborted (the script ended with the task open), abandoned (the sender gave up first) or timeout (no thread picked the task up in time), settled by whichever side closes first so every task counts once, and worker_task_time, the seconds spent on tasks. The threads endpoint follows: a background thread is busy while it holds a task and waiting otherwise, counted per thread since a script may hold several. 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() once frankenphp_worker_tick() returned: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: 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. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. 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. Two more savings on the wake-ups. The sender polls a first 10ms slice on its own: only a pickup that outlasts it brings the Go side in, to wake every thread of the worker and to start the goroutine that ends the wait on a drain or the shutdown, so the common case, a pickup within microseconds, spawns no goroutine and wakes no M. And a side's descriptor gets one signal per sleep: an event signals it only while the other side sleeps on it with no signal outstanding, that one is consumed on the next event and the rest is read from the task's state, so a completion behind an update, or a burst of updates, costs no syscall. Together they take a task from 24 to 15 syscalls and its futexes from 4.7 to 0.6: 15% off a plain task, 29% off one carrying 16 updates, and 5 to 15% more throughput under load. The existing worker metrics carry over: busy_workers counts a thread holding a task, from pickup to the close of the task's stream, and worker_queue_depth counts the tasks waiting for a thread, the only queue a background worker has. Two new ones break tasks down: worker_task_count{worker,server,outcome} with completed, aborted (the script ended with the task open), abandoned (the sender gave up first) or timeout (no thread picked the task up in time), settled by whichever side closes first so every task counts once, and worker_task_time, the seconds spent on tasks. The threads endpoint follows: a background thread is busy while it holds a task and waiting otherwise, counted per thread since a script may hold several. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
Hmm so the idea is that Symfony can easily register new background processes to pull data from. Can you give some more examples on what other processes you were thinking about (so far I only remember the redis/connection handling)? Since the worker is in principle async would it make sense to potentially pull everything from a single worker thread? I think doing this by name would lock the api into one task per thread. I know this discussion belongs in #2635, but maybe there's a more general abstraction than doing this via names, in the end we're building some kind of specialized channel. |
|
WDYT about the callback based approach? Just brainstorming # in the HTTP thread
frankenphp_get_var('redis', Runtime::BG_WORKER_NAME);
# in the background worker
$handle = new WorkerHandle();
$handle->onGetVar(function(string $varName){
if($varName === 'redis'){
$handle->setVar($varName, [...]);
// add the streampolling to the event loop
}
});
// callback is executed at $handle->tick |
|
Vars and tasks are not the same tool. A var slot belongs to the worker, not to a thread, so the last snapshot keeps being served while the script crashes or restarts, and reading it costs a copy, from a classic-mode request that starts from nothing as well as from the HTTP workers, which would otherwise each do the round trip: wake a thread, wait for the pickup, wait for the answer, and while the worker restarts the senders wait with it. Your The channel you describe is #2636, a On one worker multiplexing everything: it's possible today, the payload is an array and the script switches on it. Forcing it would push a dispatcher into every userland script, and it would forbid specializing a pool per kind of work, with its own num, boot timeout and max_consecutive_failures: a scheduler that must not queue behind a slow consumer, a consumer on 4 threads next to a pusher on 1. Both designs have to stay achievable, and it's not for FrankenPHP to pick one today. Names are what keep both open: one script serving several names, or one name per script. |
|
No I mean, you can still keep the caching and all. It would only trigger the callback on the first We could also always fallback to the first bgworker if no worker name is passed (aka just |
|
These generated responses are hard to fully read through...
The current api needs the name to be passed to decide what to do and there is only 1 name per worker, aka one thing to do per thread.
Why would you need a pool of workers to set a single var?
I'm not arguing against having the response cached, you should keep that logic. I'm just saying these threads should be able to do more than 1 thing as they are async.
Ofc it's up to us to pick a design, that's the whole point of this discussion. |
|
I'm pausing on this PR a bit to come back with a quieter mind about your points, and also because it'd be cool to have my other PRs that target main directly reviewed/merged before this one ;)
I understand that I've been pushing for one thread per "setVars()" namespace. Is the number of threads something to optimize for? We could try to find a way for a worker to manage several namespaces if yes. On the other end of my thoughts: not every "setVars()" worker will need to be async. I definitely see simple implementations that just poll periodically some remote endpoint. That might be good enough for many use cases. (Noted on the generated text, I'll do better) |
|
Or let's maybe clarify on the api: Each worker has it's own "vars". In principle a process-global hashmap that only the worker can write to, but everone else can also read from. When an HTTP thread calls frankenphp_get_var('something') on the worker, it will first look into the global hashmap if the var already exists and take it from there. If it does not exist, trigger the worker's setVar('something') callback. At the end of the callback, the var should be set, otherwise throw an error in the HTTP thread. Would allow doing multiple setVars per thread, which makes sense as we're running async anyways. Edit: just read your message, it's fine take your time 👍 |
They kind of need to be because of the shutdown signal, or at least they should not be blocking most of the time. Not sure if there's a good middleground between sync and async, but yeah ideally we shouldn't lock out either. |
|
What about separating the two roles names have today? A worker name identifies a pool, and what requests address becomes a namespace declared on the workers: worker {
file index.php
background
vars redis feature_flags
tasks image.resize
}One worker can own many namespaces, and an app splits them across pools only when it wants a separate |
|
Hmm so you mean that every get_vars('redis') would automatically be routed to this worker? Yeah that's actually a lot cleaner than routing by names, I like it 👍 |
|
Yes! Happy you like it, I updated the PR! |
Names were scoped per server so a script could address a background worker by name within its php_server. The namespaces of php#2635 and php#2636 do that now and no PHP API takes a worker name, so naming goes back to what main does: Caddy makes generated names unique, and the server label and the ServerMetrics interface are gone. The extension worker fixes leave too, main has the same bugs and they get a pull request of their own.
Since php#2660 worker threads come on top of num_threads and max_threads bounds the whole process. Background threads were the one exception, which took a reservation through calculateMaxThreads(), initPHPThreads() and the automatic max_threads. They now follow the same rule as HTTP workers.
HTTP workers wait for their ready point without a bound too, on builds without Zend max execution timers, so the bound belongs with both kinds of worker. Background workers keep the bound of max_execution_time where those timers exist, and the warning logged after 10 seconds elsewhere.
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, keeps 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.
The timeout reported through a blocking send, so a second timeout could wait for room in startupFailChan for good, Init() with it. It now reports like the handlers do and moves the thread to ShuttingDown, so no run starts after the one that overstayed, whether the timeout lands in a run or between two failed boots. The 30s default now only applies where Zend max execution timers are missing, as the docs said: elsewhere max_execution_time bounds the bootstrap, and a set_time_limit() raising it is no longer cut short. Comments and docs that drifted are updated, and the thread budget and the Caddy directive get the tests they lacked.
Background workers: PHP scripts FrankenPHP runs in a loop outside the request cycle, on their own thread, for what lives as long as the server: a queue consumer, a scheduler, a metrics pusher.
tick()is the background analog offrankenphp_handle_request(): its first call marks the worker ready, which the server start waits for, and it returnsfalseonce FrankenPHP drains the worker on shutdown, reboot or restart.Io\Poll\Handleon every supported version, so the loop above runs on 8.2 with symfony/polyfill-io-poll;getStream()is there for Revolt, amphp or a plainstream_select().max_consecutive_failuresandboot_timeoutfailInit()during startup only.numdefaults to 1, and threads, names and metrics work as for HTTP workers: the threads come on top ofnum_threadsand count withinmax_threads, andnameis optional.Shared state is #2635, tasks #2636.