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:
TempFileTransport—chmod(0600)is applied immediately aftertempnam(), before any bytes are written, and the file isunlinked within microseconds ofproc_openreturning. The window where it exists on disk is tiny, and even then only the owner can read it.ShmTransport— the segment is created withshmop_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.
Related
- Swoole & payload delivery
- Parallel tasks — bounding concurrency
- Installation & requirements —
ext-shmop,opis/closure