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 the parent never drains it:

  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.

For a fire-and-forget job — the common case — this is a silent killer: the task appears to start, then stalls or dies for reasons that have nothing to do with its logic.

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() call stream_get_contents on the non-blocking pipes and return whatever has arrived since the last call. Two consequences worth knowing:

  • Drain in a loop while isAlive() — a single read won’t capture a long-running task’s full output, and letting the buffer fill reintroduces the Broken pipe.
  • After join() they return ''join() closes and clears the pipes as part of teardown, so read what you need before joining (or during the alive loop).

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