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; match is rejected; num >= 1, no lazy start here. $_SERVER['FRANKENPHP_WORKER'] now carries the name for every worker, HTTP ones included (the documented contract is to test its presence, not its value, as suggested on #2393), and $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in background workers so a script serving both roles can tell them apart with isset(). The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with a capped quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. A crash past the ready point is paced by that backoff but never counted toward the cap, which is about a script that never boots: unlike an HTTP worker, paced by the traffic it needs before it can crash, a background worker reaches its ready point on its own and would otherwise spin. 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 a run gets one stream, and closing it then fetching again yields a fresh one over the same socket without losing the drain signal; 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 Outdated
Comment on lines 163 to 194

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The reservation is right for num_threads, but max_threads auto is memory-derived and then floored back up to numThreads, so background workers silently push past that ceiling. Failing Init() on the total, instead of bumping max_threads, would keep it.

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.

auto is a memory heuristic that num_threads already overrides through the same floor; background threads are fixed threads and get the same treatment. Failing Init() would let a heuristic reject an explicit config, so they stay on top.

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.

IMO it's a bit unfortunate that the ceiling exists in the first place, it would be better to have something like num_regular_threads and max_regular_threads, so people don't have to do maths with worker thread count.

But with the current logic all workers count toward that ceiling (they take away threads from the regular threads), so I think it makes more sense to be consistent when it comes to background workers, or we'll just make it even more confusing.

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.

Agreed on num_regular_threads / max_regular_threads being the shape that avoids the maths; that would be a separate change to the existing settings.

On consistency: HTTP workers count against the ceiling because they draw from the same pool, autoscale into it and compete with regular threads for it. Background threads never enter that pool: fixed count, no scaling, no requests. Counting them would make num_threads mean a different HTTP capacity depending on how many background workers a config declares.

What the review did change is the plumbing. calculateMaxThreads() used to bump the totals and subtract them back later; it now resolves num_threads / max_threads against the HTTP workers alone and returns the background threads separately, for Init() to add where a real total is needed. No addition-then-subtraction anywhere.

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.

@AlliBalliBaba AlliBalliBaba Sep 9, 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.

Hmm I think you're right since streams can only send strings.

What do you think about something like this? Abstracting things a bit allows us to do more in the future without BC breaks.

$worker = new \FrankenPHP\Worker(
    onMessage: fn(\FrankenPHP\Message $message) => ...,
    onRequest: fn(\FrankenPHP\Request $request) => ...,
    onShutdown: fn() => ...
);

$handle = $worker->getHandle();

while ($message = fgets($handle)) { # or the equivalent with stream_select
  $worker->handle($message);
}

The message can literally be "1", "2", "3", it will be handled internally and forwarded to onMessage() or onShutdown()

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.

That's pure PHP over the primitive, so it can ship as a package or a docs example and evolve without a FrankenPHP release; in the engine it freezes the callback signatures and the Message / Request shapes before anything uses them. A class can be added later, not removed.

BC-wise the primitive is the smaller surface: "lines, then EOF on drain", where a new kind of message is a new prefix. Three callback signatures and two classes are more to keep stable, not less.

Callbacks also take ownership of the loop, so anything a handler waits on has to be routed back through the dispatcher. The stream composes with Revolt, amphp, ReactPHP or a plain blocking read, none of which have to know about each other.

The tasks of #2636 are the first real message type here and they wanted functions: the loop drains the queue on each wake-up, since a line is a wake-up and not a count, and inside a task the worker does a stream_select() on the task's own stream to notice the sender giving up. A dispatcher handing out one message at a time makes both awkward. frankenphp_handle_request() is callback-shaped because a request has a beginning and an end; a background worker's loop owns the process lifetime.

Hand-rolled dispatch being easy to get wrong is fair, so I'd answer it with a documented loop, and a package if it earns its place.

@AlliBalliBaba AlliBalliBaba Sep 9, 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.

The tasks of #2636 are the first real message type here and they wanted functions: the loop drains the queue on each wake-up, since a line is a wake-up and not a count, and inside a task the worker does a stream_select() on the task's own stream to notice the sender giving up. A dispatcher handing out one message at a time makes both awkward. frankenphp_handle_request() is callback-shaped because a request has a beginning and an end; a background worker's loop owns the process lifetime.

That's kind of the point, we're locking ourselves out of any changes/extensions to the api by requiring very specific steps to be followed (receiving literal "task" -> checking frankenphp_receive_task() -> receiving a stream -> passing the stream to frankenphp_update_task -> fclose)

$handle = frankenphp_get_worker_handle();
while ($message =  fgets($handle)) {
    if ($message === "task") {
        while ($task = frankenphp_receive_task()) {
            [$stream, $payload] = $task;
            frankenphp_update_task($stream, ['progress' => 50]);
            frankenphp_update_task($stream, ['result' => process($payload)]);
            fclose($stream);
        }
    }
}

It's not just about making the API less awkward, it's also about keeping control over how we handle what is sent in the streams. Doing it somehow like this still allows integrating the handle into amphp/react/etc with minimal surface.

$worker = new \FrankenPHP\Worker(onMessage: function(\FrankenPHP\Message $message){
   $message->respond(['result' => process($message->payload)]);
});
$handle = $worker->getHandle();
while ($message =  fgets($handle)) {
   $worker->handle($message);
}

Also allows us to just call exit() directly on shutdown if we want to.

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.

You're right that "task\n" is a bad idea, my mistake, and the pool case rules it out: a line is a wake-up, not a description. A plain "\n" would be enough, content unspecified.

That leaves the kind to the receive side, which may be where an object fits: frankenphp_receive([Task::class]) returning a Task. The list is the opt-in, so a script only ever sees kinds it knows, the runtime keeps the wire format and can answer for scripts that don't ask, and a new kind is a new class instead of a new function. Does that give you the room you're after? About callback-based approaches, they're doomed to fail: a handler that runs to completion holds one task at a time, so anything that keeps several open and selects across them has to escape it, which is the case multiplexing needs.

Mostly a #2636 discussion, but it belongs here too, since it confirms the shape of this PR: one handle, an opaque wake-up, EOF on drain, nothing parsed.

On exit(): EOF already ends the loop, so a script can return or exit on shutdown with no API for it.

@AlliBalliBaba AlliBalliBaba Sep 10, 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.

Yes on one hand a Task class would be much cleaner, on the other hand, forwarding the message on the handler back to us leaves us in control of what to do.

For example when we get back "1", we poll for a message and call the onMessage handler with the message as argument. When we receive "2", we call the onShutdown handler and exit out of the script.

In the future we might want to do something like send "3" when there is a "tick" or "request" event. Gives us more control, eg. we can add a custom \FrankenPHP\Tick and throw an Exception if the handler is not implemented. The user does not need to worry about what "1", "2", "3" means.

Doing this via callback doesn't stop us from handling multiple tasks at a time, $worker->handle($message) just calls another function immediately. The event loop can call $worker->handle($message) before the callback is finished again no problem (unless I'm missing something)

@AlliBalliBaba AlliBalliBaba Sep 10, 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.

Looking at how react/revolt incorporate handles, maybe we could even do something like this:

$worker = new \FrankenPHP\Worker(onTask: function(\FrankenPHP\Message $message){
   $message->respond(process($message->payload));
});
$handle = $worker->getHandle();

// --- ReactPHP ---
Loop::addReadStream($handle, function () use ($worker) {
    $worker->tick(); // handle just is a wakeup mechanism, we can decide what to do here non-blocking
});

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

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

nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
Comment thread frankenphp.c
Comment thread worker.go
// documented to test its presence, not its value, so HTTP workers moving
// from "1" to their name breaks nothing. FRANKENPHP_WORKER_BACKGROUND is
// the presence-only flag telling a script it runs as a background worker
o.env["FRANKENPHP_WORKER\x00"] = o.name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

$_SERVER['FRANKENPHP_WORKER'] moves from 1 to the worker name for every existing HTTP worker, and an unnamed worker now sees the absolute script path, so a script gating on === '1' stops detecting worker mode. The testdata/_executor.php change in this diff is that exact comparison, so calling the break out in docs/worker.md would help more than describing the new value under name alone.

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.

Called out in docs/worker.md now, with the isset() advice; the two symlink fixtures' messages fixed too.

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.

I'd be okay with this, if we weren't immediately introducing another implicit contract by setting FRANKENPHP_WORKER_BACKGROUND to literal 1.
Don't have a solution, but I suspect @AlliBalliBaba may have a smart idea

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.

We could add another var of course, eg FRANKENPHP_WORKER_NAME

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.

Fair, and the answer we just settled on for the wake-up line applies here too: presence is the contract, the value is not. The docs and the stub say so explicitly now, and the fixture reading it was changed to test presence, so nothing in the tree compares against 1.

If that still reads as too implicit, the alternative is a function rather than a variable, at the cost of API surface for a boot-time fact. Happy to go that way if you and @AlliBalliBaba prefer it.

Comment thread docs/metrics.md Outdated
Comment thread server.go
Comment thread frankenphp.go Outdated
Comment thread frankenphp.go Outdated
Comment thread threadbackgroundworker.go
Comment thread caddy/workerconfig.go
Comment thread requestoptions.go Outdated
Comment thread phpthread.go Outdated
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 7, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 9, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 10, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a line on its handle: the one
handle of php#2617 carries both the drain EOF and the wake-ups, so a script
keeps a single stream_select() loop. That line is a wake-up rather than a
description, so its content is unspecified and must not be inspected: it
says something may be pending, the script finds out what by polling. It is
not a count either, since a pool wakes one thread per task and the others
get null, and in a pool it may belong to a task a sibling took.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
@henderkes
henderkes requested a balanced review from Copilot September 10, 2026 07:12

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.

🟡 Changes recommended

Handle aliasing, unbounded crash loops, ambiguous metric identities, and extension-worker regressions remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 45/45 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread frankenphp.c
Comment thread worker.go
Comment thread requestoptions.go
Comment thread threadbackgroundworker.go Outdated
Comment thread worker.go
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 10, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a line on its handle: the one
handle of php#2617 carries both the drain EOF and the wake-ups, so a script
keeps a single stream_select() loop. That line is a wake-up rather than a
description, so its content is unspecified and must not be inspected: it
says something may be pending, the script finds out what by polling. It is
not a count either, since a pool wakes one thread per task and the others
get null, and in a pool it may belong to a task a sibling took.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
@henderkes
henderkes requested a balanced review from Copilot September 10, 2026 10:14

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.

🟡 Changes recommended

Scoped extension dispatch drops request options, and background-worker configuration has inconsistent global and environment behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 47/47 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread caddy/module.go
Comment thread worker.go
Comment thread workerextension.go Outdated
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 10, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a line on its handle: the one
handle of php#2617 carries both the drain EOF and the wake-ups, so a script
keeps a single stream_select() loop. That line is a wake-up rather than a
description, so its content is unspecified and must not be inspected: it
says something may be pending, the script finds out what by polling. It is
not a count either, since a pool wakes one thread per task and the others
get null, and in a pool it may belong to a task a sibling took.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.

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

I'm largely happy with the API decisions.

Mostly architectural pointers, I'm a bit concerned with the amount of code duplication and extra code debt.

Also raising this from above here again: #2617 (comment)

Comment thread threadbackgroundworker.go
Comment thread worker.go Outdated
Comment thread workerextension.go
Comment thread worker.go
// documented to test its presence, not its value, so HTTP workers moving
// from "1" to their name breaks nothing. FRANKENPHP_WORKER_BACKGROUND is
// the presence-only flag telling a script it runs as a background worker
o.env["FRANKENPHP_WORKER\x00"] = o.name

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.

I'd be okay with this, if we weren't immediately introducing another implicit contract by setting FRANKENPHP_WORKER_BACKGROUND to literal 1.
Don't have a solution, but I suspect @AlliBalliBaba may have a smart idea

Comment thread frankenphp.c Outdated
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, match is rejected,
num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative
exit, restart with a capped 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.

Their threads live outside the num_threads / max_threads budget, which
describes HTTP capacity: those settings size the pool background workers
never draw from, so calculateMaxThreads() resolves them against the HTTP
workers alone and returns the background threads separately, for Init()
to add to the totals. Nothing is subtracted back out.

Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'],
HTTP workers included: the documented contract is to test its presence,
not its value. Background workers also get
$_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles
can tell them apart with isset(). Both names are reserved: an env of the
worker or of its server never leaks either into a worker of the other
kind.

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 a stream can be closed and fetched again without losing the
drain signal; 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. A run gets one stream: every call returns
the same resource until the script closes it, so fetching the handle in a
loop does not grow the resource list of a request that never ends.

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, never a
name another block configured. The collision-driven renaming in the Caddy
module is gone, and WithWorkerName() resolves within the request's server
first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a
php_server block were reported under their bare name unless it collided:
both changes are called out in the docs.

Two places absorb the new worker kind rather than growing a copy of what
exists. The states a worker thread walks through between two runs live in
workerLifecycle, embedded by both handlers, which supply only what
differs: how a run starts, and what a reboot resets. And a worker without
a scope now belongs to the fallback server, the one already serving the
requests that have no server either, so a lookup is always a lookup in a
server and the parallel registry of global workers is gone.

Supersedes php#2543 and php#2398.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 10, 2026
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a line on its handle: the one
handle of php#2617 carries both the drain EOF and the wake-ups, so a script
keeps a single stream_select() loop. That line is a wake-up rather than a
description, so its content is unspecified and must not be inspected: it
says something may be pending, the script finds out what by polling. It is
not a count either, since a pool wakes one thread per task and the others
get null, and in a pool it may belong to a task a sibling took.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Thanks, all items should be addressed now!

@henderkes

Copy link
Copy Markdown
Contributor

Thanks, all items should be addressed now!

I'm not that fast in reviewing 😆. I hope to look again tomorrow.

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.

5 participants