API Index — Namespaces, Packages, Reports, Indices
WorkerPool
in package
Master-side pool manager for ZealPHP's native FCGI-style worker pool.
Spawns N persistent PHP subprocesses (via proc_open) at construction.
Each subprocess loops on stdin frames — reads a request payload, runs
the requested PHP file in its own clean global scope (mod_php-style
isolation per request), writes a response frame, then yields. Auto-
respawns any subprocess that dies (crash, exit(), OOM) or hits the
recycle limit (FPM pm.max_requests parity).
Concurrency: idle subprocesses live in a Coroutine\Channel. Multiple
coroutines on the parent OpenSwoole worker dispatch in parallel — each
dispatch() call pops a worker from the channel (yields the coroutine
if the channel is empty), writes the request frame, reads the response
frame (pipe I/O yields under HOOK_ALL), and pushes the worker back to
the channel. The parent worker handles thousands of concurrent dispatch
coroutines while the subprocess pool executes legacy PHP synchronously.
That's the architectural shape: PHP HTTP server (OpenSwoole worker) + FPM-style isolation (subprocess pool) + async dispatch (coroutines).
Outside a coroutine context (tests, CLI tools), dispatch() falls back
to a synchronous LIFO array — the same code path the spike used. This
makes the test surface trivial: new WorkerPool() → $pool->dispatch()
works without wrapping in Co::run().
Table of Contents
Properties
- $channelPopulated : bool
trueafter the sync idle queue has been migrated into$idleChanon the first coroutine dispatch.- $closed : bool
trueafterclose()has been called; prevents double-close and guardsdispatch().- $idleChan : Channel|null
- Coroutine idle queue. Created lazily — Channel push REQUIRES coroutine context, so we can't populate it at constructor time (parent worker boot is typically non-coroutine). First dispatch in a coroutine transfers the sync queue into the channel; from then on, all idle/busy transitions go through the channel for proper parallel-dispatch yield.
- $idleSync : array<int, int>
- $maxRequestsPerWorker : int
- $size : int
- Total number of worker slots allocated at construction (immutable after
__construct()). - $workerEntry : string|null
- $workers : array<int, array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}>
- FD-3 IPC architecture (v0.3.x): the subprocess writes the response BODY
to STDOUT freely (no length-prefixed framing → no risk of corruption
from user code that calls
flush()/fastcgi_finish_request()) and writes the response METADATA frame (status, headers, cookies) to fd 3.
Methods
- __construct() : mixed
- Spawn
$sizeworker subprocesses immediately. All workers are ready (READY signal received) before the constructor returns. - __destruct() : mixed
- close() : void
- Shut down all workers cleanly (close pipes, wait for exit).
- dispatch() : array<string, mixed>
- Dispatch one request to an idle worker and return the response frame.
- filterSubprocessEnv() : array<string, string>
- Build the environment handed to a pool subprocess.
- servedCounts() : array<int, int>
- Per-worker
servedcounter (for tests / observability / /healthz). - size() : int
- How many workers are alive right now.
- ensureChannelPopulated() : void
- One-time promotion of the sync idle queue into a Coroutine\Channel.
- isAlive() : bool
- popIdleWorker() : int|null
- Pop an idle worker index. In a coroutine, lazy-promotes the queue to a Coroutine\Channel (first call) and yields on it until a worker is freed or the timeout expires. Outside a coroutine, uses the synchronous LIFO — no yield, returns null if empty.
- readBody() : string
- Read exactly $n body bytes from STDOUT. The subprocess wrote
body_length = $ninto the fd 3 metadata frame and immediately after streamed $n bytes to STDOUT. Hard-capped at IPC::MAX_FRAME_BYTES (64 MB) to match the framing channel — anything bigger is a corrupted length signal. - respawn() : void
- Close all pipes for the worker at
$idx, reap the subprocess viaproc_close(), spawn a fresh replacement, and immediately return it to the idle pool. Called when a worker dies mid-request, hits its$maxRequestsPerWorkerlimit, or sends the_exitflag in its IPC frame. - returnToIdle() : void
- Return a worker to the idle pool. Routes to the channel (in coroutine
context, once promoted) so any coroutine waiting on
popwakes up; otherwise pushes to the sync LIFO. - spawn() : array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}
Properties
$channelPopulated
true after the sync idle queue has been migrated into $idleChan on the first coroutine dispatch.
private
bool
$channelPopulated
= false
$closed
true after close() has been called; prevents double-close and guards dispatch().
private
bool
$closed
= false
$idleChan
Coroutine idle queue. Created lazily — Channel push REQUIRES coroutine context, so we can't populate it at constructor time (parent worker boot is typically non-coroutine). First dispatch in a coroutine transfers the sync queue into the channel; from then on, all idle/busy transitions go through the channel for proper parallel-dispatch yield.
private
Channel|null
$idleChan
= null
$idleSync
private
array<int, int>
$idleSync
= []
Idle worker indices — primary queue at boot + the fallback when no coroutine context.
$maxRequestsPerWorker read-only
private
int
$maxRequestsPerWorker
= 500
$size read-only
Total number of worker slots allocated at construction (immutable after __construct()).
private
int
$size
$workerEntry read-only
private
string|null
$workerEntry
= null
$workers
FD-3 IPC architecture (v0.3.x): the subprocess writes the response BODY
to STDOUT freely (no length-prefixed framing → no risk of corruption
from user code that calls flush() / fastcgi_finish_request()) and
writes the response METADATA frame (status, headers, cookies) to fd 3.
private
array<int, array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}>
$workers
= []
The metadata channel uses a destructor on a static class instance —
PHP runs destructors EVEN AFTER exit() from inside a shutdown
function (phpMyAdmin's ResponseRenderer->response() does exactly
this). Routing metadata through fd 3 keeps the body channel clean and
gives us a guaranteed delivery path for status/headers regardless of
how the app terminates.
Parent reads the metadata frame from pipes[3] first (small,
~256 bytes), then drains body bytes from STDOUT until EOF.
Backward compat — older worker entry scripts that still ship the IPC-frame-on-STDOUT protocol fall through to the legacy single-channel read path when no fd 3 frame is received within a short window.
Methods
__construct()
Spawn $size worker subprocesses immediately. All workers are ready
(READY signal received) before the constructor returns.
public
__construct([int $size = 4 ][, int $maxRequestsPerWorker = 500 ][, string|null $workerEntry = null ]) : mixed
Parameters
- $size : int = 4
-
Number of persistent worker subprocesses (pool concurrency).
- $maxRequestsPerWorker : int = 500
-
Requests served before a worker is recycled (
pm.max_requestsparity). - $workerEntry : string|null = null
-
Absolute path to the pool worker entry script; defaults to
src/pool_worker.php.
Tags
__destruct()
public
__destruct() : mixed
close()
Shut down all workers cleanly (close pipes, wait for exit).
public
close() : void
dispatch()
Dispatch one request to an idle worker and return the response frame.
public
dispatch(array<string|int, mixed> $request[, float $timeout = 30.0 ]) : array<string, mixed>
Coroutine-aware: yields if all workers are busy until one frees up.
Parameters
- $request : array<string|int, mixed>
- $timeout : float = 30.0
-
Max seconds to wait for an idle worker before returning 503.
Return values
array<string, mixed> —Response (status, headers, cookies, body, return_value).
filterSubprocessEnv()
Build the environment handed to a pool subprocess.
public
static filterSubprocessEnv(array<string, string> $parentEnv, array<int, string> $allowlist, int $maxRequests) : array<string, string>
Previously this was array_merge(getenv(), …) — every parent env var
(DB passwords, ZEALPHP_TIERED_INVALIDATION_SECRET, AWS_*, k8s/systemd
secrets) was inherited by the long-lived subprocess running arbitrary
legacy app code, and — unlike the proc/fork CGI path (Dispatcher::buildCgiEnv)
— the request-controlled HTTP_PROXY was forwarded (httpoxy, CVE-2016-5385).
Default (empty $allowlist): pass the parent env through for legacy-app
compatibility but ALWAYS drop HTTP_PROXY. Set App::cgiPoolEnvAllowlist()
to a list of exact names / PREFIX* globs to harden to a strict allowlist
(the pool IPC var is always included). Per-request CGI vars travel over the
fd-3 IPC frame, not this spawn env, so a strict allowlist does not lose them.
Parameters
- $parentEnv : array<string, string>
-
Usually
getenv(). - $allowlist : array<int, string>
-
Exact names or
PREFIX*globs; empty = pass-through. - $maxRequests : int
Return values
array<string, string>servedCounts()
Per-worker served counter (for tests / observability / /healthz).
public
servedCounts() : array<int, int>
Return values
array<int, int>size()
How many workers are alive right now.
public
size() : int
Return values
intensureChannelPopulated()
One-time promotion of the sync idle queue into a Coroutine\Channel.
private
ensureChannelPopulated() : void
Runs on the first dispatch inside a coroutine context, where push is legal. After this, the sync queue is empty and the channel owns idle-worker tracking for all subsequent dispatches.
isAlive()
private
isAlive(array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int} $w) : bool
Parameters
- $w : array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}
Return values
boolpopIdleWorker()
Pop an idle worker index. In a coroutine, lazy-promotes the queue to a Coroutine\Channel (first call) and yields on it until a worker is freed or the timeout expires. Outside a coroutine, uses the synchronous LIFO — no yield, returns null if empty.
private
popIdleWorker(float $timeout) : int|null
Parameters
- $timeout : float
Return values
int|nullreadBody()
Read exactly $n body bytes from STDOUT. The subprocess wrote
body_length = $n into the fd 3 metadata frame and immediately
after streamed $n bytes to STDOUT. Hard-capped at IPC::MAX_FRAME_BYTES
(64 MB) to match the framing channel — anything bigger is a corrupted
length signal.
private
readBody(resource $fp, int<0, max> $n, float $timeout) : string
Parameters
- $fp : resource
- $n : int<0, max>
- $timeout : float
Return values
stringrespawn()
Close all pipes for the worker at $idx, reap the subprocess via
proc_close(), spawn a fresh replacement, and immediately return it
to the idle pool. Called when a worker dies mid-request, hits its
$maxRequestsPerWorker limit, or sends the _exit flag in its IPC frame.
private
respawn(int $idx) : void
Parameters
- $idx : int
returnToIdle()
Return a worker to the idle pool. Routes to the channel (in coroutine
context, once promoted) so any coroutine waiting on pop wakes up;
otherwise pushes to the sync LIFO.
private
returnToIdle(int $idx) : void
Parameters
- $idx : int
spawn()
private
spawn() : array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}