Skip to content

Don't let the wrapper shell's job notification block kill() - #88

Merged
trowski merged 1 commit into
amphp:2.xfrom
danog:wait-job-notification
Nov 7, 2026
Merged

trowski merged 1 commit into
amphp:2.xfrom
danog:wait-job-notification

Conversation

@danog

@danog danog commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Mechanism

When a job it waits for dies from a signal, the wrapper shell reports it on its stderr: bash, which is /bin/sh on macOS, prints Killed: 9. That stderr is the process's stderr pipe. If the process had filled the pipe with output nobody read, the shell blocks writing the notice and never exits.

Since #86, kill() calls reapShell(), a blocking pcntl_waitpid() on the shell, right after the SIGKILL (and so does PosixHandle::__destruct() once the exit code has been read). That wait holds the event loop, which is what would drain the pipe. Nothing can move, and kill() never returns.

I hit it with amphp/parallel: ContextWorkerPool kills its workers at shutdown. A worker that printed more than a pipe's worth (64 KiB) at startup while the parent wasn't running the loop — here, PHP 8.5 deprecation notices from dev dependencies, 85 KB on every start — hangs the parent forever, stuck in pcntl_waitpid().

Fix

The wrapper discards the stderr of the wait builtin (wait $pid 2>/dev/null). The process's own stderr isn't affected: the notice is the only thing the shell itself writes there.

Test

testKillWithUnreadOutput kills a process that wrote 1 MiB to stderr that nobody read. The kill runs in a child PHP script, because a kill() that never returns blocks the test's own event loop, and the test joins it with a timeout. It fails without the fix (macOS, PHP 8.5) and passes with it.

testSignal fails locally with or without this change: see the separate PR for its race.

When a job it waits for is killed by a signal, the wrapper shell reports it on its stderr
("Killed: 9" from bash, which is /bin/sh on macOS), and its stderr is the stderr pipe of
the process. If the process filled that pipe with output nobody read, the shell blocked
writing the notice and never exited, and the blocking waitpid() of reapShell() in kill()
(and in PosixHandle::__destruct() once the exit code was read, since amphp#86) waited for it
forever, holding the event loop that would have drained the pipe.

Discard the stderr of the wait builtin: the process's own stderr is redirected by the
command itself, the notice is the only thing the shell writes there.
@trowski
trowski merged commit 7194a79 into amphp:2.x Nov 7, 2026
7 checks passed
@trowski

trowski commented Nov 7, 2026

Copy link
Copy Markdown
Member

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants