Package · thread · deep dive

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:

  1. The parent writes the payload and calls fclose($pipe).
  2. Swoole cleans up the fd via socket_free_defer, treating it as a socket — and gets EBADF.
  3. The fd stays “dirty” in Swoole’s internal table.
  4. On the next request the kernel reuses that same fd number for a new pipe.
  5. 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

text
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.