Repository navigation
Fix zombie process on process destruction #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 2.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| use Amp\Future; | ||
| use Amp\PHPUnit\AsyncTestCase; | ||
| use Amp\Process\Process; | ||
| use Amp\Process\ProcessException; | ||
| use Amp\TimeoutCancellation; | ||
| use const Amp\Process\IS_WINDOWS; | ||
| use function Amp\async; | ||
|
|
@@ -143,6 +144,123 @@ public function testKillImmediately(): void | |
| self::assertSame(IS_WINDOWS ? 1 : 137, $process->join()); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension pcntl | ||
| * @runInSeparateProcess | ||
| * @preserveGlobalState disabled | ||
| */ | ||
| public function testKillReapsShellWithPcntlWhileProcessIsRetained(): void | ||
| { | ||
| $process = Process::start(self::CMD_PROCESS_SLOW); | ||
| $process->kill(); | ||
|
|
||
| // Keep Process reachable after kill; this is the state that previously left the shell unreaped. | ||
| $status = 0; | ||
| $remainingChildPid = \pcntl_waitpid(-1, $status); | ||
| $error = \pcntl_get_last_error(); | ||
| $exitCode = $process->join(); | ||
|
|
||
| self::assertSame(-1, $remainingChildPid); | ||
| self::assertSame(\PCNTL_ECHILD, $error); | ||
| self::assertSame(137, $exitCode); | ||
| } | ||
|
|
||
| public function testKillReapsShellWithoutPcntlWhileProcessIsRetained(): void | ||
| { | ||
| if (\DIRECTORY_SEPARATOR === "\\") { | ||
| self::markTestSkipped("Signals are not supported on Windows"); | ||
| } | ||
|
|
||
| $code = \sprintf( | ||
| 'require %s;' | ||
| . '$process = Amp\\Process\\Process::start("sleep 30");' | ||
| . '$handle = (new ReflectionProperty($process, "handle"))->getValue($process);' | ||
| . '$shellPid = (new ReflectionProperty($handle, "shellPid"))->getValue($handle);' | ||
| . '$process->kill();' | ||
| . 'exit(posix_kill($shellPid, 0) ? 1 : 0);', | ||
| \var_export(\dirname(__DIR__) . '/vendor/autoload.php', true), | ||
| ); | ||
| $process = Process::start([ | ||
| \PHP_BINARY, | ||
| '-d', | ||
| 'disable_functions=pcntl_waitpid,pcntl_get_last_error', | ||
| '-r', | ||
| $code, | ||
| ]); | ||
|
|
||
| $exitCode = $process->join(new TimeoutCancellation(2)); | ||
| $error = buffer($process->getStderr()); | ||
|
|
||
| self::assertSame(0, $exitCode, $error); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension pcntl | ||
| * @runInSeparateProcess | ||
| * @preserveGlobalState disabled | ||
| */ | ||
| public function testProcessDestructionReapsShell(): void | ||
| { | ||
| $process = Process::start(self::CMD_PROCESS_SLOW); | ||
| unset($process); | ||
|
|
||
| $status = 0; | ||
| self::assertSame(-1, \pcntl_waitpid(-1, $status, \WNOHANG)); | ||
| self::assertSame(\PCNTL_ECHILD, \pcntl_get_last_error()); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension pcntl | ||
| * @runInSeparateProcess | ||
| * @preserveGlobalState disabled | ||
| */ | ||
| public function testCompletedProcessDestructionReapsShell(): void | ||
| { | ||
| $process = Process::start('exit 0'); | ||
| self::assertSame(0, $process->join()); | ||
| unset($process); | ||
|
|
||
| $status = 0; | ||
| self::assertSame(-1, \pcntl_waitpid(-1, $status, \WNOHANG)); | ||
| self::assertSame(\PCNTL_ECHILD, \pcntl_get_last_error()); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension pcntl | ||
| */ | ||
| public function testShutdownDoesNotWaitForRunningProcess(): void | ||
| { | ||
| $code = \sprintf( | ||
| 'require %s; $GLOBALS["process"] = Amp\\Process\\Process::start("sleep 30");', | ||
| \var_export(\dirname(__DIR__) . '/vendor/autoload.php', true), | ||
| ); | ||
| $process = Process::start([\PHP_BINARY, '-r', $code]); | ||
|
|
||
| self::assertSame(0, $process->join(new TimeoutCancellation(2))); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension pcntl | ||
| */ | ||
| public function testRunningHandleDestructionDoesNotWaitForProcess(): void | ||
| { | ||
| // Emulate handle destruction in a long-running PHP worker without depending on garbage collection order. | ||
| $code = \sprintf( | ||
| 'require %s;' | ||
| . '$process = Amp\\Process\\Process::start("cat >/dev/null");' | ||
| . '$handle = (new ReflectionProperty($process, "handle"))->getValue($process);' | ||
| . '$handle->__destruct();', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which situation does this really emulate?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I updated pull request desctiption with "Behavior changes" section for clarity. |
||
| \var_export(\dirname(__DIR__) . '/vendor/autoload.php', true), | ||
| ); | ||
| $process = Process::start([\PHP_BINARY, '-r', $code]); | ||
|
|
||
| try { | ||
| self::assertSame(0, $process->join(new TimeoutCancellation(2))); | ||
| } finally { | ||
| $process->kill(); | ||
| } | ||
| } | ||
|
|
||
| public function testKillThenReadStdout(): void | ||
| { | ||
| $this->setTimeout(1); | ||
|
|
@@ -259,6 +377,42 @@ public function testSignal(): void | |
| self::assertSame(42, $process->join()); | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension posix | ||
| */ | ||
| public function testSignalThrowsIfDeliveryFails(): void | ||
| { | ||
| $process = Process::start(self::CMD_PROCESS_SLOW); | ||
|
|
||
| try { | ||
| $this->expectException(ProcessException::class); | ||
| $this->expectExceptionMessage('Failed to send signal 9999'); | ||
| $process->signal(9999); | ||
| } finally { | ||
| $process->kill(); | ||
| $process->join(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @requires extension posix | ||
| */ | ||
| public function testSignalIgnoresProcessThatAlreadyExited(): void | ||
| { | ||
| $process = Process::start('exit 0'); | ||
|
|
||
| // Keep the event loop paused so Process status is not updated before the OS process exits. | ||
| $isRunning = true; | ||
| for ($attempt = 0; $attempt < 1000 && $isRunning; ++$attempt) { | ||
| $isRunning = \posix_kill($process->getPid(), 0); | ||
| \usleep(1000); | ||
| } | ||
|
|
||
| self::assertFalse($isRunning); | ||
| $process->signal(0); | ||
| self::assertSame(0, $process->join()); | ||
| } | ||
|
|
||
| public function testCancellation(): void | ||
| { | ||
| $this->expectException(CancelledException::class); | ||
|
|
||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, butposix_kill()fails withESRCHbecause the PID no longer exists.signal()andkill()already return silently for processes marked as Ended. IgnoringESRCHkeeps this behavior consistent. Other errors throw ProcessException, andjoin()still reads the exit code normally.testSignalIgnoresProcessThatAlreadyExited()covers this race.