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)

  • frankenphp_ensure_background_worker() and lazy-start machinery.
  • Catch-all (empty-name) workers.
  • Shared-state APIs (frankenphp_set_vars / frankenphp_get_vars).
  • An orchestrator-style frankenphp_start_background_worker() runtime API
    (discussed in feat: declared background workers + frankenphp_get_worker_handle() #2398); the primitives here are compatible with it.

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!

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