Skip to content

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

Open
nicolas-grekas wants to merge 2 commits into
php:mainfrom
nicolas-grekas:bgworker-server
Open

feat: declared background workers + frankenphp_get_worker_handle()#2617
nicolas-grekas wants to merge 2 commits into
php:mainfrom
nicolas-grekas:bgworker-server

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

A worker marked background runs 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 on stream_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 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 and is exposed as $_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.

drain() is wired into thread.shutdown() and rebootAllThreads(), so shutdowns and watch-triggered reboots wake parked background workers instead of waiting out the force-kill grace period.

Caddy

background flag inside worker blocks, in both php_server and global ones. name is required, match is rejected. Metrics and logs use the worker name, which server-qualified naming already keeps unique across php_server blocks, so two blocks may declare the same worker name.

Deferred

  • 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; the primitives here are compatible with it

Tests

  • TestBackgroundWorkerLifecycle: boots, touches its sentinel, parks on the stop pipe, Shutdown() returns within 10s
  • TestBackgroundWorkerCrashRestarts: exit(1) on first boot, the respawned run touches the "restarted" sentinel
  • TestBackgroundWorkerOnServer: 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

Supersedes #2543 and #2398.

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.
@henderkes

Copy link
Copy Markdown
Contributor

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.

@nicolas-grekas

nicolas-grekas commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Sure, I'll let you know when I'm done, for now I just let it do the rebase 😅

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread frankenphp.c
}

/* Dup so the returned stream owns its fd: closing the stream (or request
* shutdown destroying it) never touches worker_stop_fds[0], which stays

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we leaking fd's on every restart then?

Comment thread threadbackgroundworker.go
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread phpthread.go
return
}

close(thread.drainChan)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
thread.handler.drain()
close(thread.drainChan)

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found another one, anyway, have you tested this on windows?

Comment thread frankenphp.c
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread frankenphp.c
Comment on lines +429 to +432
if (is_background_worker) {
is_background_worker = false;
frankenphp_worker_close_stop_fds();
}
Comment thread threadbackgroundworker.go
Comment on lines +233 to +235
if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok && !handler.reachedHandle {
handler.reachedHandle = true
metrics.ReadyWorker(handler.worker.name)
Comment thread threadbackgroundworker.go
Comment on lines +157 to +159
if handler.state.Is(state.TransitionComplete) {
handler.state.Set(state.Ready)
}
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.

3 participants