Skip to content

Fix zombie process on process destruction - #86

Open
xtrime-ru wants to merge 3 commits into
amphp:2.xfrom
xtrime-ru:fix-posix-zombie
Open

xtrime-ru wants to merge 3 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

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.

@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
Author

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


$error = \posix_get_last_error();
if ($error === self::ESRCH) {
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really be silent?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PCNTL_ESRCH - no such process

The OS process may exit before the event loop updates its status. In that case, isRunning() still returns true, but posix_kill() fails with ESRCH because the PID no longer exists.

signal() and kill() already return silently for processes marked as Ended. Ignoring ESRCH keeps this behavior consistent. Other errors throw ProcessException, and join() still reads the exit code normally.

testSignalIgnoresProcessThatAlreadyExited() covers this race.

Comment thread test/ProcessTest.php
'require %s;'
. '$process = Amp\\Process\\Process::start("cat >/dev/null");'
. '$handle = (new ReflectionProperty($process, "handle"))->getValue($process);'
. '$handle->__destruct();',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which situation does this really emulate?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test protects against a hang introduced by an earlier version of this fix:

  1. We added a blocking waitpid() in the handle destructor so shell cleanup would not depend on the event loop running again.
  2. During cyclic garbage collection, the handle destructor can run before ProcHolder terminates the command. Blocking there prevents execution from reaching the cleanup that calls kill(). The test forces this ordering by invoking the handle destructor directly.
  3. The current implementation blocks in the destructor only when the status is Ended. For running commands, it returns without blocking, allowing ProcHolder cleanup to proceed. kill() then synchronously reaps the shell, without requiring another event-loop iteration.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated pull request desctiption with "Behavior changes" section for clarity.

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