Skip to content

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

Open
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:bgworker-server
Open

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

Conversation

@nicolas-grekas

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

Copy link
Copy Markdown
Contributor

Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. This is the smallest useful slice of #2398, rebuilt on the Server of #2499: the parallel Scope machinery is gone, a background worker attaches to a php_server through WithWorkerServerScope() like any other worker.

Declared with background in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required, it is the script's identity and is exposed as $_SERVER['FRANKENPHP_WORKER']; match is rejected; num >= 1, no lazy start here. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() now runs on shutdown, reboot and handler transitions, so a parked script wakes up instead of waiting out the force-kill grace period.

The script gets one handle, frankenphp_get_worker_handle(): resource, a stream that reaches EOF when the worker is drained. It is meant to carry control messages later, hence one handle rather than one per purpose. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5, and the socket path is version-independent. Streams don't own the socket (php_sockop_close() would shutdown() it on Windows), so every call returns a fresh stream and closing one never affects another; the read timeout is infinite so a blocking read parks as well as stream_select() does.

A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. Fetching the handle is not the ready point, nothing forces a script to fetch it after bootstrapping.

Worker names are now scoped like paths: unique within a php_server or among global workers, so two blocks may each declare queue. The script sees the declared name; metrics and logs report a scoped worker as <server name>:<name>, with a numeric suffix on server names when two blocks resolve to the same one. The collision-driven renaming in the Caddy module is gone.

Deferred: lazy start (frankenphp_ensure_background_worker()), catch-all workers, shared-state APIs, and the orchestrator-style runtime API discussed in #2398.

Supersedes #2543 and #2398.

@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 Outdated
Comment thread threadbackgroundworker.go Outdated
Comment thread phpthread.go

@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 Outdated

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 thread threadbackgroundworker.go Outdated
Comment thread threadbackgroundworker.go Outdated
Comment thread frankenphp.go
Comment on lines +163 to 194
// background workers reserve their thread budget separately so they
// don't count against the HTTP-oriented admission checks below; the
// bump is applied on top of the calculated totals at the end
reservedThreads := 0
defer func() {
if err != nil {
return
}
opt.numThreads += reservedThreads
if opt.maxThreads > 0 {
// in auto mode (maxThreads < 0), the resolved value is floored
// to numThreads later, which already includes the reservation
opt.maxThreads += reservedThreads
}
numWorkers += reservedThreads
}()

for i, w := range opt.workers {
if w.isBackgroundWorker {
if w.num < 1 {
return 0, fmt.Errorf("background worker %q must declare num >= 1", w.name)
}
metrics.TotalWorkers(w.name, w.num)
reservedThreads += w.num

continue
}

if w.num <= 0 {
// https://github.com/php/frankenphp/issues/126
opt.workers[i].num = maxProcs
}

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.

Hmm I don't remember the specific reason why background workers are treated separately here. Wouldn't it make sense to just do this and count it into the general pool, like other workers:

if w.num <= 0 {
    if w.isBackgroundWorker {
		opt.workers[i].num = 1
    } else {
		opt.workers[i].num = maxProcs
    }
}

Worker thread count is already added on top of the general thread count. It would only overflow in case someone sets a general cap on global threads, in which case it should probably still honor that cap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The num_threads / max_threads budget exists for autoscaling HTTP workers, and background workers take no part in that: they don't scale, don't queue requests and never compete for a free thread. Counting them into the pool would change what the budget means depending on how many background workers a config declares: declare five and you silently get five fewer HTTP threads, so people would have to bump the budget just to keep the capacity they had, and the setting stops describing HTTP capacity. That's why they're reserved on top: the HTTP admission math is untouched and the totals are bumped afterwards (reservedThreads). Requiring an explicit num keeps that reservation visible in the config rather than defaulted.

Comment on lines +25 to +29
$stream = frankenphp_get_worker_handle();
$read = [$stream];
$write = null;
$except = null;
stream_select($read, $write, $except, null);

@AlliBalliBaba AlliBalliBaba Aug 26, 2026

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.

It looks like currently the handle is only used for shutdown. IIRC in the future you'd also want to use the handle to send messages or even requests.

Would it maybe be cleaner to have a separate handle for each? Makes the api look more like we're selecting over different channels, in other words:

frankenphp_get_shutdown_handle(); # instead of frankenphp_get_worker_handle
frankenphp_get_message_handle(); # future scope: can return a dedicated message
frankenphp_get_request_handle(); # future scope: can return a dedicated request object

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd rather keep one handle. In the prototype built on this primitive, shutdown, messages and requests all arrive on the same stream as typed messages, and the worker loop is a single stream_select() plus a dispatch on what was read; that worked well in practice. One handle per kind means selecting over N streams, N functions to document and keep in sync, and ordering questions between them (a message landing after shutdown was signalled on another stream). Fewer functions is also less API to get wrong. This PR only uses the EOF-on-drain part, but the handle is meant to carry the rest.

@nicolas-grekas
nicolas-grekas force-pushed the bgworker-server branch 7 times, most recently from ac0896d to 3e93a24 Compare September 6, 2026 16:35
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Two review-level items.

Name collision between a global and a php_server background worker: names are now scoped like paths, unique within a php_server or among global workers, so both start. Each script sees jobs in FRANKENPHP_WORKER, WithWorkerName() resolves within the request's server first, and metrics and logs report the scoped one as <server name>:jobs (server names get a numeric suffix when two blocks resolve to the same one). Two workers with the same name inside one php_server fail Init(). TestBackgroundWorkerOnServer and same_worker_name_in_two_servers cover both directions.

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 stream_select() on a pipe would have spun on 8.4 and older, since php_select() only waits properly on sockets before the GH-16889 fix, which is 8.5 only. The handle is therefore backed by a socket pair on every platform, which takes the version-independent Winsock path.

The branch is squashed to 3e93a24; sha references in earlier replies predate the squash.

Background workers run a script in a loop outside the HTTP request
cycle, sharing the PHP runtime with the request threads. Rebuilt on
Server from php#2499: a background worker attaches to a php_server through
WithWorkerServerScope() like any other worker.

Declared with "background" in a worker block (php_server or global) or
WithWorkerBackground() in Go. name is required and exposed as
$_SERVER['FRANKENPHP_WORKER'], match is rejected, num >= 1. The
lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart
with quadratic backoff on a crash, max_consecutive_failures fails Init()
during startup only. drain() runs on shutdown, reboot and handler
transitions so a parked script wakes up instead of waiting out the
force-kill grace period.

The script gets one handle, frankenphp_get_worker_handle(), a stream
that reaches EOF when the worker is drained, meant to carry control
messages later. It is backed by a socket pair, not a pipe: on Windows
PHP's php_select() only waits properly on sockets before 8.5. Streams
do not own the socket (php_sockop_close() would shutdown() it on
Windows), so every call returns a fresh stream and closing one never
affects another; the read timeout is infinite so a blocking read parks
as well as stream_select() does. Both ends are non-inheritable.

A worker counts as ready on its first wait on the handle (select cast
or read), the background analog of frankenphp_handle_request(): Init()
waits for it, ready_workers counts from it, and an exit before it is a
boot failure. The handle's stream ops, copied from the socket ops at
MINIT, report it once per run.

Worker names are scoped like paths: unique within a php_server or
among global workers. The script sees the declared name; metrics and
logs report a scoped worker as "<server name>:<name>", with a numeric
suffix on server names when two blocks resolve to the same one. The
collision-driven renaming in the Caddy module is gone, and
WithWorkerName() resolves within the request's server first.

Supersedes php#2543 and php#2398.
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Since the replies above, a self-review pass amended into the single commit (2f9c5b6):

  • a worker scoped to a Server never passed to WithServer() panicked on a nil map; Init() now rejects it
  • failureCount reset at the ready point, like HTTP workers: a crash after it restarts right away, only boot failures count toward max_consecutive_failures
  • the drain race check in setupScript also covers TransitionRequested
  • Windows: PHP's socketpair() emulation listens on INADDR_ANY and accepts the first peer, so the pair is verified with getpeername/getsockname; SOCK_CLOEXEC where available
  • a warning after 10s when a script has not waited on its handle, since Init() and Shutdown() wait for that point; docs say to block on the handle, feof() polling is not a wait
  • max_threads is rejected on background workers instead of ignored, and the Caddyfile requires num
  • untyped resource return in the stub, dead zend_unset_timeout() between requests removed, background threads no longer reported busy by the threads endpoint
  • tests: parking on a blocking read, RestartWorkers() draining a parked script, the handle throwing outside a background worker, the new validations

CI: all test jobs pass. The Windows job's caddy-suite timeout (POSTed configuration isn't active, then an admin GET hangs) is the same flake main's nightly runs hit (33416988237, 33302318173) and passes on rerun. The Docker matrix fails on the Go 1.27 runtime/cgo link error that #2634 and main's nightly show too.

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.

4 participants