Output & the Broken pipe rule
Why start() defaults outputTarget to /dev/null, and
what actually goes wrong when a background process writes to a pipe nobody reads.
The failure it prevents
When outputTarget is null, start() wires the child’s stdout and stderr to pipes
back to the parent. A pipe has a fixed OS buffer (~64 KB). If nothing ever reads
the pipe — and the parent never join()s or reap()s the thread (both drain it for you):
- The child keeps
echo-ing; the buffer fills. - The next
write()blocks — the child is now stuck, not doing work. - If the parent has exited or closed its read end, the write instead raises
SIGPIPE/ “Broken pipe”, killing the child.
This only bites in true fire-and-forget: you pass null and then never read, join(), or
reap(). The task appears to start, then stalls or dies for reasons that have nothing to do
with its logic — so for fire-and-forget, keep the /dev/null default.
Why /dev/null is the safe default
With the default outputTarget = '/dev/null', start() opens no pipe at all —
stdout/stderr are handed straight to the null device by the OS. Nothing buffers, nothing
blocks, and the parent needs zero lifecycle management. You can start a task and drop the
Thread object on the floor.
The trade
/dev/null is safe because it’s unread by design. The moment you want to read output,
you opt into the pipe (null) and take on the duty of draining it.
The three output modes
outputTarget |
Wiring | When |
|---|---|---|
'/dev/null' (default) |
Discarded by the OS, no pipe | Fire-and-forget background jobs |
'/path/to/file.log' |
Appended to the file | Keep a record; staging/production |
null |
Piped to the parent (set non-blocking) | Local dev — you actively read it |
With a file target, both stdout and stderr are opened in append mode, so reusing one log
file across runs is safe. With null, the pipes are switched to non-blocking and you read
them with readOutput() / readError().
Reading piped output
readOutput() / readError() return whatever arrived on the non-blocking pipes since your
last call. They are consuming (each call hands back only the new bytes) and never block.
Three things worth knowing:
- A bare
join()is safe.join()— andreap()— drain the pipes while they wait, so a child that writes far more than the ~64 KB buffer can never deadlock, even if you never read it yourself. readOutput()afterjoin()returns the full output. The bytes are buffered during the wait, so they survive the pipes being closed on completion — you no longer have to read before joining.- Poll only for live output. If you want output as it is produced (progress bars,
streaming logs), read in a loop while
isAlive(); otherwise justjoin(), then read.
--debug (the debugMode flag) is orthogonal: it enables E_ALL + display_errors in the
child so PHP warnings and notices show up in whatever target you chose.
Related
- Runner life cycle — where descriptors are wired
- Logging & debugging — the practical how-to
- API reference —
start()parameters