Runner life cycle
The full path a task travels from start() to join(): how the
parent serializes and stages the payload and proc_opens PHP, what the
wRunner bootstrap and AdaptiveRunner do in the child (including
the detached-mode fork), and how the process is torn down. For the concept-level
version, see the mental model.
There are exactly two processes and they share no objects — only a byte stream on
fd 0, a secret in the environment, and a handful of CLI flags. The parent side is
driven by an Engine; the child side by a
Runner. The two never reference each other.
Parent: what start() does
Thread::start() guards against a double-start, serializes the Runnable, packs
everything into a LaunchSpec, and hands it to the engine’s launcher.
- Serialize the
Runnable.opis/closureis a hard dependency, so this is always\Opis\Closure\serialize($runnable, $engine->security())— never nativeserialize(). When the engine has a secret configured the output is HMAC signed; without a secret it is still opis/closure, just unsigned. - Build the
LaunchSpec— payload plus namespace, name, tag, per-runarguments, thedebugflag, theoutputtarget, and thedetachedflag. Engine::launcher()->launch($spec)(the defaultCliLauncher) does the rest:stage()the payload, assemble descriptors, build the command,proc_open.
How the launcher wires descriptors and the command
The payload’s fd 0 (stdin) spec is produced by the transport object, not by
any PAYLOAD_* constant — the launcher just plugs in whatever stage() returns:
| Transport | fd-0 spec (stdinSpec) |
Extra CLI arg | Post-spawn action |
|---|---|---|---|
PipeTransport |
['pipe', 'r'] |
— | write payload to the pipe, close it |
TempFileTransport |
['file', <0600 tmp>, 'r'] |
— | unlink the file (child keeps the fd) |
ShmTransport |
['file', '/dev/null', 'r'] |
--shmkey=<int> |
nothing — child reads by key |
- fd 1 / fd 2 (stdout/stderr) go to the
outputfile appended (modea, default/dev/null), or to non-blocking pipes back to the parent whenoutput === null. - The command is fully
escapeshellarg-escaped:php wRunner --namespace=… --name=… [--tag=…] [--debug] [--detach] [--shmkey=…] [--arg-…]. A--tagflag is only emitted when a tag was set;--detachonly when$detachedis true; each per-run argument becomes--arg-<key>[=<value>]. - Child environment — an empty
childEnvmeans the child inherits the parent’s env as-is; a non-empty one (the default engine addsWINTER_THREAD_SECRETwhen signing) is merged over the inherited env. The secret rides the environment (owner-only/proc/<pid>/environ), never argv.
After proc_open, the launcher writes/unlinks the payload as above, sets the output
pipes non-blocking, then calls proc_get_status to verify the process actually
started. If proc_open failed or the child died immediately it cleans up the
staged resource and throws ThreadException; otherwise it returns a ProcessHandle
and start() returns the launched PID.
In detached mode the returned PID is the launcher's
With detached: true, the child immediately forks and the parent-visible
process (the launcher, L) exits 0 almost at once. The PID
you get back is therefore L’s ephemeral PID, not the surviving worker’s.
PARENT (your app) CHILD (php wRunner)
───────────────── ────────────────────
Opis\Closure\serialize(runnable, sec)
transport->stage(payload) ── fd 0 ──►
build escaped command
proc_open ─────────────────────────► autoload + set_time_limit(0)
write pipe / unlink tmp getopt(namespace,name,tag,
proc_get_status → PID debug,detach,shmkey)
│ AdaptiveRunner::execute()
│ receive: shmkey? shm : STDIN
│ Opis\Closure\unserialize + verify
│ instanceof Runnable?
│ if --detach: fork + setsid ─┐
│ cli_set_process_title │
│ runnable->run(parsedArgs) ◄─┘
join() ◄──────── exit code ───────── return 0 | Throwable → 1Child: what wRunner + AdaptiveRunner do
wRunner is a thin bootstrap script (the packaged bin); the real work lives in
the child-side Runner, whose default is AdaptiveRunner. To swap the runner you
replace the bootstrap script — point the engine at your own via
AdaptiveEngine(runnerPath: …) or ManualEngine()->withRunnerPath(…), typically
paired with a custom Launcher. There is no Thread::bindRunner(); the child
side is independent of the parent Engine by design.
wRunner bootstrap:
- Autoload — locate and require the Composer autoloader (installed-vendor path,
falling back to a local
vendor/autoload.php). - Long-run defaults —
set_time_limit(0),ob_implicit_flush(),ignore_user_abort(true). - Read options with
getopt('', ['namespace::','name::','tag::','debug','detach','shmkey::']). - Error visibility —
--debugturns onE_ALL+display_errors+display_startup_errors; otherwise all three are silenced. - Build the verifier — read
WINTER_THREAD_SECRETfrom the environment; if present, construct aDefaultSecurityProvider(secret: …), elsenull(unsigned). - Run
new AdaptiveRunner($security)->execute($options)andexit()its code.
AdaptiveRunner::execute():
- Receive the payload —
--shmkeyset →ShmTransport(reads the segment, thenshmop_deletes it); otherwisePipeTransportreadsSTDIN. STDIN covers both the pipe and temp-file deliveries, which are byte-identical to the reader. An empty payload → stderr message +exit(1). - Deserialize — always
\Opis\Closure\unserialize($payload, $this->security). With a secret it verifies the HMAC and rejects forged/tampered payloads (an OpisSecurityException) before any object is built; any throw here → stderr +exit(1). - Free the string —
unset($payload)before running (matters for large payloads). - Type-check — the result must be
instanceof Runnable, otherwise stderr +exit(1). - Detached mode — if
--detachis present,daemonize():pcntl_fork(); the launcher processLexit(0)s so the parent reaps it cheaply, while the workerWcallsposix_setsid()to get a new session with no controlling terminal and be reparented to init. A failed fork or setsid → stderr +exit(1). This happens before the process title is set and the task runs. - Process title — where
cli_set_process_title()exists, setWinterThread <namespace> -> <name>@<tag>. Name falls back to the Runnable’s short class name and tag falls back torunnablewhen the corresponding option is absent. - Run — parse
--arg-*from$_SERVER['argv'](--arg-k=v→$args['k']='v', bare--arg-k→$args['k']=true), callrun($args), andreturn 0. AnyThrowableis caught, its message andgetTraceAsString()are written to stderr, and itreturns1.
detach() is not detached mode
The –detach fork above (worker survives, reparented to init) is the
child-side daemonization triggered by start(detached: true).
That is unrelated to Thread::detach(), a parent-side call that
merely stops tracking a still-running handle. See
the API reference for both.
Teardown: what join() does
Thread::join() delegates to ProcessHandle::join(), which polls
proc_get_status every 50 ms until the child is no longer running, then calls
finish($exitcode): it closes any open pipes, calls the one blocking primitive —
proc_close — and finally transport->cleanup($staged), which deletes a lingering
temp file or shared-memory segment (a crash fallback; the child normally removes its
own). It returns the exit code, or null once a positive timeout (seconds) passes,
or -1 if the thread was never started.
reap(), detach(), and __destruct() are non-blocking on a live child — they
never call proc_close while the worker runs. reap() finishes-and-collects only an
already-dead process (returning false while it is still running); detach() closes
the parent’s pipes and drops the handle without proc_close or transport cleanup
(the detached child owns its own resources); the destructor reaps a finished child or
detaches a still-running one, so it never stalls the parent. This is what lets a pool
harvest hundreds of handles in a single loop — see
security & performance.
Why the payload can't hold resources
The child is a fresh PHP process — it reconstructs your task from bytes and
shares no memory with the parent. That is the whole reason live resources
(PDO/sockets/streams) must be opened inside run(), not stored on the
Runnable’s properties.