Skip to content

Fix zombie process on process destruction - #86

Merged
trowski merged 7 commits into
amphp:2.xfrom
xtrime-ru:fix-posix-zombie
Nov 1, 2026
Merged

trowski merged 7 commits into
amphp:2.xfrom
xtrime-ru:fix-posix-zombie

Conversation

@xtrime-ru

@xtrime-ru xtrime-ru commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

This branch is rebased over #85, to fix conflict. So this PR must be merged after #85

Summary

Ensures that the POSIX wrapper shell created by proc_open() is reaped after process termination, both with and without ext-pcntl.

Shutdown remains non-blocking while the requested command is still running.

Root cause

proc_get_status($proc)['pid'], stored as $shellPid, identifies the wrapper shell created by proc_open(). This shell is a direct child of PHP.

The requested command PID, stored as $pid, belongs to a process started by the wrapper shell. It is not a direct child of PHP, so waiting for $pid cannot reap the wrapper shell.

Shell reaping was deferred through the event loop. If the event loop did not run again after the shell exited, the shell remained a zombie.

When ext-pcntl was unavailable, the cleanup path returned without performing any wait operation. By default pcntl is absent in php-fpm.

A blocking reap after kill() also requires checking signal delivery first. ESRCH means that the requested process has already exited and the wrapper shell can still be reaped. Other delivery errors must be reported instead of proceeding to a potentially blocking wait.

Changes

  • Handle posix_kill() failures, treating ESRCH as an already-exited process and throwing for other errors.
  • Reap $shellPid after kill() and after completed process destruction.
  • Keep shutdown and destruction of running processes non-blocking.
  • Use pcntl_waitpid() with EINTR handling when PCNTL is available.
  • Use proc_get_status() to reap the wrapper shell when PCNTL is unavailable.
  • Add regression coverage for signal delivery, process destruction, shutdown ordering, and shell reaping with and without PCNTL.

This corrects the POSIX shutdown handling introduced in 6c711ee.

Behavior changes

Scenario Before After
Shell exited; Process retained; event loop never runs again May remain a zombie Same: requires a later reap
kill() called while status is not Ended Sends SIGKILL Sends SIGKILL, then waits for and reaps the shell
Handle destroyed with status Ended No reap in the destructor Synchronously reaps the shell
Running handle destroyed before ProcHolder cleanup Non-blocking reap Same: allows ProcHolder to terminate the command
PCNTL unavailable No explicit reap Reaps via proc_get_status()

Our zombie issue resulted from two combined conditions: PHP-FPM lacked PCNTL, so explicit shell reaping was skipped, and kill() sent SIGKILL without reaping the wrapper shell.

Testing

  • Targeted regression tests: 8 tests, 15 assertions passed.
  • Psalm passed.
  • PHP CS Fixer passed.

With sufficient luck, another callback installs something at a file description reusing this resources file descriptor number, wreaking havoc.

The only reference to $extraDataPipe is stored by extraDataPipeCallbackId. An explicit fclose is the worst choice. Either immediately cancel $callbackId, or simply defer to __destruct.

Deferring to destructor seems safe enough.
@xtrime-ru
xtrime-ru force-pushed the fix-posix-zombie branch 5 times, most recently from 47566d0 to ffd4ab3 Compare August 26, 2026 22:21
@xtrime-ru xtrime-ru changed the title Fix zombie process on process destruction Draft: Fix zombie process on process destruction Aug 28, 2026
@xtrime-ru xtrime-ru changed the title Draft: Fix zombie process on process destruction Fix zombie process on process destruction Sep 10, 2026
@xtrime-ru

Copy link
Copy Markdown
Contributor Author

We tested this patch in production for last 2 weeks without issues. Now it is ready for review.

Comment thread src/Internal/Posix/PosixRunner.php Resolved
Comment thread test/ProcessTest.php Resolved
@xtrime-ru

Copy link
Copy Markdown
Contributor Author

I've rebased this branch over #85, to fix conflict

@trowski
trowski merged commit 6ee0d11 into amphp:2.x Nov 1, 2026
7 checks passed
@trowski

trowski commented Nov 1, 2026

Copy link
Copy Markdown
Member

Thanks. Hopefully this finally fixes this issue once and for all.

trowski pushed a commit that referenced this pull request Nov 7, 2026
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 #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.
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.

4 participants