Skip to content

feat: declared background workers + frankenphp_get_worker_handle() - #2543

Open
nicolas-grekas wants to merge 1 commit into
php:refactor/phpserverfrom
nicolas-grekas:bgworker-server
Open

feat: declared background workers + frankenphp_get_worker_handle()#2543
nicolas-grekas wants to merge 1 commit into
php:refactor/phpserverfrom
nicolas-grekas:bgworker-server

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

Summary

Rewrite of #2398 on top of #2499, targeting the refactor/phpserver branch:
the smallest useful slice of the background-worker work, now built on
Server and the server names merged in #2529 instead of a parallel Scope
mechanism. A single commit on top of the base branch.

A worker declared with WithWorkerBackground() / background in the
Caddyfile runs its script in a loop outside the HTTP request cycle. The
frankenphp_get_worker_handle() primitive hands the script a stream that
reaches EOF when FrankenPHP drains the worker, so it can park on
stream_select() and exit gracefully on shutdown, reboot or restart.

What's in

  • PHP API: frankenphp_get_worker_handle(): resource, closes when the
    worker is drained (the Go side closes the write end of a per-thread stop
    pipe).
  • Go API: WithWorkerBackground(). Background workers attach to a
    Server through the existing WithWorkerServerScope(); num >= 1 is
    required (no lazy-start in this build) and the name is mandatory since it
    is the script's identity ($_SERVER['FRANKENPHP_WORKER']).
  • Lifecycle: backgroundWorkerThread implements threadHandler and
    mirrors workerThread's state machine: boot, re-run on cooperative exit
    (status 0, backoff reset), crash-restart with quadratic backoff, hard
    failure on max_consecutive_failures during startup only.
  • Caddy: background flag inside worker blocks (php_server and global).
    name is required, match is rejected. Metrics/logs use the worker name,
    which feat: name Server instances and attribute workers to them聽#2529's qualification already keeps unique across php_server
    blocks, so two blocks can declare the same worker name.

What the rewrite changes vs #2398

Building on Server deletes the parallel machinery the old PR carried and
addresses all review comments:

  • Scope, NextScope, SetScopeLabel, the scopeOwners registry and
    caddy/scopelabel.go are gone; Server and the feat: name Server instances and attribute workers to them聽#2529 name resolution
    replace them. This also removes the reported scopeOwners data race and
    the m#<label>:m#<name> double-prefix in metric names. The feature diff
    is ~250 lines smaller than feat: declared background workers + frankenphp_get_worker_handle()聽#2398.
  • The C side no longer caches a php_stream in a TLS slot: each
    frankenphp_get_worker_handle() call dups the read fd into a fresh
    stream owned by its zval. No GC_ADDREF without release, no dangling
    pointer across restarts or thread recycling, nothing to clean up in
    frankenphp_update_local_thread_context() beyond the raw fds.
  • The -1 return of frankenphp_set_background_worker_and_get_stop_fd_write()
    is handled: Init() fails with a clear error during startup; past
    startup the thread backs off and retries instead of letting the script
    crash-loop on frankenphp_get_worker_handle() exceptions.
  • The "exponential backoff" comment now says quadratic, matching the
    implementation.
  • drain() (the threadHandler seam that is declared but not called yet
    on this branch) is wired into thread.shutdown() and
    rebootAllThreads(), so shutdowns and watch-triggered reboots wake
    parked background workers instead of hitting the force-kill grace period.
  • Hardening beyond the review comments: frankenphp_handle_request()
    throws when called from a background worker (previously it would have hit
    the .(*workerThread) type assertion and crashed the process), and
    WithWorkerName() rejects requests targeting a background worker.
  • Start/Ready/Stop worker metrics are balanced (background workers count as
    ready once the script starts, and every stop path emits StopWorker), so
    the readyWorkers gauge can't drift negative.

Deferred (kept out for review surface)

Test plan

  • TestBackgroundWorkerLifecycle: bg worker boots, touches sentinel,
    parks on the stop pipe, Shutdown() returns within 10s.
  • TestBackgroundWorkerCrashRestarts: exit(1) on first boot, the
    respawned run touches the "restarted" sentinel.
  • TestBackgroundWorkerOnServer: server-scoped worker inherits the
    server env, FRANKENPHP_WORKER carries the name, HTTP requests on the
    same server still serve.
  • TestBackgroundWorkerValidation: name required, num >= 1, global
    name namespace, request matchers rejected.
  • TestWorkerBackgroundConfig / RequiresName / RejectsMatch
    (Caddyfile parsing, pass locally).

The runtime tests need CI to confirm: on my current box (WSL2), per-thread
engine bootstrap of any embed ZTS build is pathologically slow, so every
Init()-based test times out locally, including on unmodified base commits.

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.
@nicolas-grekas

nicolas-grekas commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Should be considered after #2499 is merged of course!

AlliBalliBaba pushed a commit that referenced this pull request Aug 5, 2026
Follow-up to #2499, targeting `refactor/phpserver`. Four commits, each
independently reviewable.

Findings from a close read of the branch. Nothing here changes the
direction of the refactor; the first two are the ones I would not want
to ship.

### 1. The per-worker Mercure hub is dropped

`assignMercureHub()` fills `workerConfig.options`, but the new
`toWorkerOptions()` builds a fresh slice and never appends it, where
`Start()` used to pass `w.options` directly. The field is now written
and never read.

`WithWorkerMercureHub()` appends `WithMercureHub()` to the worker
request options, which is what lets a worker publish from its startup
code, outside `frankenphp_handle_request()`. Request-scoped publishing
kept working because the module carries `WithMercureHub()` in its own
request options, which is why no test noticed. #2543 extends the same
function and inherits the bug.

### 2. `FrankenPHPModule.server` can be nil in `ServeHTTP()`

It is assigned by `FrankenPHPApp.Start()`, but caddy starts apps by
ranging over a map, so the http app can begin serving first. The handler
dereferenced it unconditionally, turning that window into a nil pointer
panic where `main` returned a clean `ErrNotRunning`.

Two neighbouring lifecycle issues are fixed in the same commit: modules
left in `app.modules` by a config that failed to provision are
registered again by the next reload (the app is a process singleton and
`reset()` only runs at the end of `Start()`), and `match` in a global
worker block was parsed then silently ignored, so it is now rejected at
parse time as `docs/config.md` already documents.

### 3. A `Server` could not be registered twice

`unregisterServers()` only flipped `isRegistered`, so the worker slices
and maps kept the previous run's entries and a second `Init()` with the
same `*Server` failed with `two workers in a server cannot have the same
filename`. Caddy dodges this by building a fresh `NewServer` per
`Start()`, but `docs/library.md` presents `NewServer` + `Init` as the
library pattern with no hint the instance is single-use.

Same commit: the `server_<idx>` default was written into `s.name`
permanently, servers were marked registered about a hundred lines before
`initWorkers()` and the thread setup (a request in that window found
neither a worker nor a regular thread, so `activateServers()` is split
out), and `WithWorkerMatcher()` without `WithWorkerServerScope()` was a
silent no-op that still got path-matched via `globalWorkersByPath`, the
opposite of what the matcher asked for.

### 4. Logger and prepared env

Worker startup contexts hardcoded `globalLogger` even though the
worker's server was at hand, so worker boot messages and worker stdout
bypassed the per-`php_server` logger this branch introduces. The module
used to append `WithRequestLogger()` to each worker's request options
and that line is gone, so nothing else covered it.

`go_register_server_variables()` merged the prepared env whenever the
server had one, but `registerPreparedEnv()` only runs from
`go_update_request_info()`, which returns early without a request. For a
server-scoped extension worker handling a message the merge copied
whatever the thread-local prepared env held from an earlier request.

### Tests

Added: `toWorkerOptions()` keeps provisioned options, a `Server`
re-registered after `Shutdown()` still serves its worker and keeps its
name, and a request matcher without a server scope is rejected.

Both modules pass `go vet` including test files. The runtime tests need
CI: on my box (WSL2) per-thread engine bootstrap of an embed ZTS build
is pathologically slow and linking needs dev libs I do not have, so
every `Init()`-based test is unrunnable locally, including on unmodified
base commits.

### Not included

`worker.mercureHub` is assigned by `configureMercure()` and never read
anywhere; removing the dead field touches the `nomercure` build-tag
pair, so I left it alone. Say the word and I will fold it in.
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.

1 participant