Package · thread · deep dive

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.

  1. Serialize the Runnable. opis/closure is a hard dependency, so this is always \Opis\Closure\serialize($runnable, $engine->security()) — never native serialize(). When the engine has a secret configured the output is HMAC signed; without a secret it is still opis/closure, just unsigned.
  2. Build the LaunchSpec — payload plus namespace, name, tag, per-run arguments, the debug flag, the output target, and the detached flag.
  3. Engine::launcher()->launch($spec) (the default CliLauncher) 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 output file appended (mode a, default /dev/null), or to non-blocking pipes back to the parent when output === null.
  • The command is fully escapeshellarg-escaped: php wRunner --namespace=… --name=… [--tag=…] [--debug] [--detach] [--shmkey=…] [--arg-…]. A --tag flag is only emitted when a tag was set; --detach only when $detached is true; each per-run argument becomes --arg-<key>[=<value>].
  • Child environment — an empty childEnv means the child inherits the parent’s env as-is; a non-empty one (the default engine adds WINTER_THREAD_SECRET when 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.

text
  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 → 1

Child: 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:

  1. Autoload — locate and require the Composer autoloader (installed-vendor path, falling back to a local vendor/autoload.php).
  2. Long-run defaultsset_time_limit(0), ob_implicit_flush(), ignore_user_abort(true).
  3. Read options with getopt('', ['namespace::','name::','tag::','debug','detach','shmkey::']).
  4. Error visibility--debug turns on E_ALL + display_errors + display_startup_errors; otherwise all three are silenced.
  5. Build the verifier — read WINTER_THREAD_SECRET from the environment; if present, construct a DefaultSecurityProvider(secret: …), else null (unsigned).
  6. Run new AdaptiveRunner($security)->execute($options) and exit() its code.

AdaptiveRunner::execute():

  1. Receive the payload--shmkey set → ShmTransport (reads the segment, then shmop_deletes it); otherwise PipeTransport reads STDIN. STDIN covers both the pipe and temp-file deliveries, which are byte-identical to the reader. An empty payload → stderr message + exit(1).
  2. Deserializealways \Opis\Closure\unserialize($payload, $this->security). With a secret it verifies the HMAC and rejects forged/tampered payloads (an Opis SecurityException) before any object is built; any throw here → stderr + exit(1).
  3. Free the stringunset($payload) before running (matters for large payloads).
  4. Type-check — the result must be instanceof Runnable, otherwise stderr + exit(1).
  5. Detached mode — if --detach is present, daemonize(): pcntl_fork(); the launcher process L exit(0)s so the parent reaps it cheaply, while the worker W calls posix_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.
  6. Process title — where cli_set_process_title() exists, set WinterThread <namespace> -> <name>@<tag>. Name falls back to the Runnable’s short class name and tag falls back to runnable when the corresponding option is absent.
  7. Run — parse --arg-* from $_SERVER['argv'] (--arg-k=v$args['k']='v', bare --arg-k$args['k']=true), call run($args), and return 0. Any Throwable is caught, its message and getTraceAsString() are written to stderr, and it returns 1.

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.