Package · thread · deep dive

Security & performance

The guarantees Winter Thread makes about payload storage and code execution, and the cost model you’re signing up for when every task is its own OS process.

Security model

Payload storage is owner-only

Both pipeless transports restrict the serialized task to the running user:

  • TempFileTransportchmod(0600) is applied immediately after tempnam(), before any bytes are written, and the file is unlinked within microseconds of proc_open returning. The window where it exists on disk is tiny, and even then only the owner can read it.
  • ShmTransport — the segment is created with shmop_open($key, 'n', 0600, $size), so only the owning user can attach.

Payloads are signed (opt-in)

The payload is always (de)serialized through opis/closure — never native unserialize() — so a serialized closure or anonymous class survives the process boundary safely. Because that payload is executable code, an attacker who could forge one could run arbitrary code in the child. Signing closes that hole: configure a secret on the engine — new AdaptiveEngine(secret: '…'), (new ManualEngine())->withSecurity('…'), or the WINTER_THREAD_SECRET environment variable — and the parent HMAC-signs every payload while the child (wRunner / AdaptiveRunner) verifies the signature before deserializing, rejecting forged or tampered payloads (the guard against object injection). Signing is opt-in; always enable it when you serialize closures or anonymous classes. Without a secret the trust boundary falls back to the private, owner-only delivery channel. See the Security deep dive via the engine’s security().

Command construction is escaped

Everything placed on the runner’s command line — namespace, name, tag, and every --arg-* value — is passed through escapeshellarg. The shm key is cast to int, and the macOS zombie check casts the PID to int before shelling out to ps. There is no path for a task argument to break out into the shell.

Arguments are visible

Custom arguments become command-line flags, so they show up in ps and process listings. Never pass secrets (tokens, passwords) as start() arguments — fetch them inside run() from your config or secrets store.

Performance model

A process is not free

Each start() fork/execs a fresh PHP process: interpreter startup, autoloader, and its own memory footprint. That isolation is the value proposition, but it means the design fits coarse-grained work — jobs measured in tens of milliseconds and up. Spawning a process per trivial operation will cost more than it saves; batch those instead.

Payload delivery trade-offs

Transport Cost Notes
PipeTransport none extra Default in CLI; not Swoole-safe
TempFileTransport one temp-file write + unlink Universal, tiny disk touch
ShmTransport RAM only No disk I/O; needs ext-shmop

Serialization cost scales with payload size, so keep task objects lean — ids and paths, not large in-memory datasets. For big or high-frequency payloads, ShmTransport avoids the disk entirely.

join() polling granularity

join() waits by polling proc_get_status every 50 ms (usleep(50_000)). That’s the resolution of both completion detection and the timeout, and it keeps a waiting parent from busy-spinning. It also means join() can report completion up to ~50 ms after the child actually exits — negligible for real jobs, worth knowing for microbenchmarks.

Concurrency is your responsibility

Nothing throttles how many processes you spawn. For large batches, bound concurrency with a pool so you don’t exhaust CPU or memory — see Parallel tasks.