Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.
API Index — Namespaces, Packages, Reports, Indices

WorkerPool
in package

FinalYes

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
true after the sync idle queue has been migrated into $idleChan on the first coroutine dispatch.
$closed  : bool
true after close() has been called; prevents double-close and guards dispatch().
$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 $size worker 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 served counter (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 = $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.
respawn()  : void
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.
returnToIdle()  : void
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.
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_requests parity).

$workerEntry : string|null = null

Absolute path to the pool worker entry script; defaults to src/pool_worker.php.

Tags
throws
InvalidArgumentException

When $size < 1.

RuntimeException

When a worker subprocess fails to start or doesn't emit READY within 10 s.

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
int

ensureChannelPopulated()

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
bool

popIdleWorker()

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|null

readBody()

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
string

respawn()

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}
Return values
array{proc: resource, stdin: resource, stdout: resource, stderr: resource, fd3: resource|null, pid: int, served: int}
On this page