Swoole & payload delivery
Why a stdin pipe that works everywhere else breaks under Swoole, and how the alternative payload modes deliver the serialized task with zero parent-side pipe descriptors. For picking a mode, see the payload modes reference.
The fd-leak cascade
By default the payload travels through a stdin pipe created by proc_open (descriptor
0). Under Swoole with SWOOLE_HOOK_ALL, every file-descriptor operation is intercepted
and wrapped in a coroutine, and that pipe fd leaks into Swoole’s internal table:
- The parent writes the payload and calls
fclose($pipe). - Swoole cleans up the fd via
socket_free_defer, treating it as a socket — and getsEBADF. - The fd stays “dirty” in Swoole’s internal table.
- On the next request the kernel reuses that same fd number for a new pipe.
- Swoole sees the number as already registered →
posix_spawn() failed: Bad file descriptor.
The fix is to never create a parent-side pipe fd in the first place. Two transports do that.
Selection is automatic
The AdaptiveEngine (the default) detects an active Swoole runtime — inside a coroutine or
with SWOOLE_HOOK_ALL enabled — and switches to TempFileTransport on its own, so the
zero-config path is already Swoole-safe. Bind a transport explicitly only to override that
choice: Thread::bindEngine(new AdaptiveEngine(transport: new ShmTransport())).
TempFileTransport
The payload is written to a temp file in sys_get_temp_dir(), chmod-ed to 0600
before any data is written, and handed to the child as stdin (['file', $path, 'r']).
The instant proc_open returns, the parent unlinks the file — the child already holds
the descriptor, so it reads the full payload while no directory entry remains on disk.
- No pipe fd is ever created in the parent.
- No extra extension — works anywhere
sys_get_temp_dir()is writable. - Universal, safe default for Swoole / ReactPHP / Amp.
ShmTransport
The payload is written to a System V shared-memory segment (shmop_open with flag
'n', permissions 0600). The segment key is passed to the child on the command line as
--shmkey; stdin is set to /dev/null. The child reads the segment, calls shmop_delete
immediately, then proceeds.
- No pipe fd, no disk I/O — the payload lives entirely in RAM.
- Requires
ext-shmop. - Best for very large payloads or high-throughput dispatch where temp-file write latency shows up in a profile.
Crash-safe cleanup
The child normally deletes its own segment. But if it crashes before reading, the
segment would leak — so the key is carried out-of-band and released later. stage() returns
it in StagedPayload::$ref; the parent’s ProcessHandle holds that staged payload, and when
the process is reaped (join()/reap()/destructor → finish()) it calls
PayloadTransport::cleanup(), which shmop_deletes any segment that still survives. Teardown
therefore happens in a later call than start(), without the Thread having to track the
key itself.
Unique keys
Each stage() derives a key with crc32(uniqid('__wtr_thread_', true) . $seq) & 0x7fffffff
— a static $seq counter keeps it unique within the process, and the & 0x7fffffff mask
clears the sign bit so the key is a non-negative int on any build. It retries up to five
times on collision, so concurrent dispatch from many Thread objects never shares a segment.
Choosing
Inside Swoole / ReactPHP / Amp?
├── No → PipeTransport (default in CLI)
└── Yes → ext-shmop available AND payload large / high-frequency?
├── Yes → ShmTransport (RAM only, zero disk I/O)
└── No → TempFileTransport (no extension, works everywhere)Under an active Swoole runtime the AdaptiveEngine already picks TempFileTransport for you
— the safe universal choice; reach for ShmTransport only after measuring a temp-file
bottleneck.
Related
- Payload transports — the reference matrix
- Framework integration — configuring the transport
- Security & performance —
0600, trade-offs