feat: declared background workers + frankenphp_get_worker_handle() - #2617
feat: declared background workers + frankenphp_get_worker_handle()#2617nicolas-grekas wants to merge 2 commits into
Conversation
Background workers are long-lived non-HTTP PHP scripts declared via WithWorkerBackground() or the `background` flag in Caddyfile worker blocks. The script runs in a loop: it is re-run on cooperative exit (status 0) and restarted with a quadratic backoff on crash, failing hard on max_consecutive_failures during startup only. frankenphp_get_worker_handle() returns a stream over the read end of a per-thread stop pipe; draining the thread (shutdown, reboot, handler transition) closes the write end, so a script parked in stream_select wakes up and can exit gracefully. Background workers attach to a Server through the existing WithWorkerServerScope(); their name is mandatory (it is the script's identity, exposed as FRANKENPHP_WORKER) and lives in the global worker namespace, which the Caddy module already qualifies per server.
|
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?
| } | ||
|
|
||
| /* Dup so the returned stream owns its fd: closing the stream (or request | ||
| * shutdown destroying it) never touches worker_stop_fds[0], which stays |
There was a problem hiding this comment.
aren't we leaking fd's on every restart then?
| // cooperative exit: the script is re-run with a reset backoff, unless | ||
| // the thread is being drained (beforeScriptExecution checks the state) | ||
| if exitStatus == 0 { | ||
| metrics.StopWorker(worker.name, StopReasonRestart) |
There was a problem hiding this comment.
without any further checks here, isn't this going to keep rapidly restarting a background script that just returns early? for worker scripts we have a guard, but I don't see one here.
| return | ||
| } | ||
|
|
||
| close(thread.drainChan) |
There was a problem hiding this comment.
| thread.handler.drain() | |
| close(thread.drainChan) |
henderkes
left a comment
There was a problem hiding this comment.
found another one, anyway, have you tested this on windows?
…guard - setHandler() closed drainChan without calling the old handler's drain(), so a background script parked in stream_select slept through handler transitions (autoscaling, thread recycling) until the force-kill grace period. Drain first, guarding against the nil handler of a fresh thread. - Wrap the two zend_unset_timeout() calls in #ifdef ZEND_MAX_EXECUTION_TIMERS, matching every other timer call site; on builds without max-execution-timers (macOS) the setitimer path must stay untouched. - A background script that exited 0 without ever fetching its handle was treated as a cooperative exit and respawned immediately: an early return spun in a tight loop. Fetching the handle via frankenphp_get_worker_handle() is now the "reached steady state" marker, the background analog of HTTP workers reaching frankenphp_handle_request(): exits without it count as boot failures (backoff, max_consecutive_failures fails Init during startup). ReadyWorker moves from script start to handle fetch, so the ready gauge only counts scripts that actually reached their park point and stays balanced with StopReasonBootFailure. - Spell out the fd lifecycle at the dup site: dup'ed fds die with their streams at request shutdown, the read end is closed by the next setup or thread recycle, the write end by the Go side on every exit path.
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.
| if (is_background_worker) { | ||
| is_background_worker = false; | ||
| frankenphp_worker_close_stop_fds(); | ||
| } |
| if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok && !handler.reachedHandle { | ||
| handler.reachedHandle = true | ||
| metrics.ReadyWorker(handler.worker.name) |
| if handler.state.Is(state.TransitionComplete) { | ||
| handler.state.Set(state.Ready) | ||
| } |
A worker marked
backgroundruns its script in a loop outside the HTTP request cycle.frankenphp_get_worker_handle()hands the script a stream that reaches EOF when FrankenPHP drains the worker, so it can park onstream_select()and exit gracefully on shutdown, reboot or restart.PHP API
frankenphp_get_worker_handle(): resource, closed when the worker is drained (the Go side closes the write end of a per-thread stop pipe). Throws when called outside a background worker. Each call dups the read fd into a fresh stream owned by its zval, so closing one never touches the fd the C side owns.Go API
WithWorkerBackground(). Background workers attach to aServerthrough the existingWithWorkerServerScope().num >= 1is required (no lazy-start in this build) and the name is mandatory, since it is the script's identity and is exposed as$_SERVER['FRANKENPHP_WORKER'].Lifecycle
backgroundWorkerThreadimplementsthreadHandlerand mirrorsworkerThread's state machine: boot, re-run on cooperative exit (status 0, backoff reset), crash-restart with quadratic backoff, hard failure onmax_consecutive_failuresduring startup only.drain()is wired intothread.shutdown()andrebootAllThreads(), so shutdowns and watch-triggered reboots wake parked background workers instead of waiting out the force-kill grace period.Caddy
backgroundflag inside worker blocks, in bothphp_serverand global ones.nameis required,matchis rejected. Metrics and logs use the worker name, which server-qualified naming already keeps unique acrossphp_serverblocks, so two blocks may declare the same worker name.Deferred
frankenphp_ensure_background_worker()and lazy-start machineryfrankenphp_set_vars/frankenphp_get_vars)frankenphp_start_background_worker()runtime API; the primitives here are compatible with itTests
TestBackgroundWorkerLifecycle: boots, touches its sentinel, parks on the stop pipe,Shutdown()returns within 10sTestBackgroundWorkerCrashRestarts:exit(1)on first boot, the respawned run touches the "restarted" sentinelTestBackgroundWorkerOnServer: inherits the server env,FRANKENPHP_WORKERcarries the name, HTTP requests on the same server still serveTestBackgroundWorkerValidation: name required,num >= 1, global name namespace, request matchers rejectedTestWorkerBackgroundConfig/RequiresName/RejectsMatch: Caddyfile parsingSupersedes #2543 and #2398.