Package · thread · deep dive

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):

  1. The child keeps echo-ing; the buffer fills.
  2. The next write() blocks — the child is now stuck, not doing work.
  3. 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() — and reap() — 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() after join() 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 just join(), 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.