Description
On non-Windows systems, when a child process opened via proc_open() is killed by a signal, proc_close() returns the raw signal number (e.g. 11 for SIGSEGV) rather than following the Unix convention of 128 + signal (e.g. 139).
This makes it impossible for PHP userland code to distinguish between "process exited normally with code 11" and "process was killed by SIGSEGV".
Underlying cause
Internally, proc_close() calls waitpid() which returns an encoded status. The C macros WIFEXITED()/WEXITSTATUS() and WIFSIGNALED()/WTERMSIG() exist to decode this, but proc_close() does not use them to differentiate the two cases. It effectively passes back a value that loses the signal/exit distinction.
PHP's own pcntl_waitpid() + pcntl_wifexited() + pcntl_wtermsig() handle this correctly, but pcntl is not always available, and proc_close() is the standard way to get the exit status of a proc_open() process.
Reproduction
<?php
// Child script that triggers SIGSEGV
$child = '<?php posix_kill(posix_getpid(), 11);';
$process = proc_open(
[PHP_BINARY, '-r', $child],
[],
$pipes
);
$exitCode = proc_close($process);
// Expected: 139 (128 + 11, following bash/Unix convention)
// Actual: 11 (raw signal number, same as a normal exit code)
echo "Exit code: $exitCode\n";
echo "Is this SIGSEGV or a normal exit(11)? Impossible to tell.\n";
Expected behavior
proc_close() should return 128 + signal_number when the child was killed by a signal, consistent with how bash and other Unix shells report signal termination via $?. This would allow userland code to detect crashes.
Alternatively, expose the signal information through a separate mechanism (e.g. an optional by-reference parameter, or a new proc_exit_status() function).
Real-world impact
This affects tools that use proc_open() to spawn child processes, notably composer/xdebug-handler which restarts PHP processes. When the child crashes (e.g. due to a PHP JIT bug triggering SIGSEGV), the parent receives exit code 11 with no way to detect it was a signal — leading to silent, misleading failures in tools like Psalm, PHPStan, and Composer.
PHP Version
Tested on PHP 8.4 and 8.5, but the behavior has existed since proc_open was introduced.
Operating System
Linux / macOS (any non-Windows POSIX system)
Description
On non-Windows systems, when a child process opened via
proc_open()is killed by a signal,proc_close()returns the raw signal number (e.g.11forSIGSEGV) rather than following the Unix convention of128 + signal(e.g.139).This makes it impossible for PHP userland code to distinguish between "process exited normally with code 11" and "process was killed by SIGSEGV".
Underlying cause
Internally,
proc_close()callswaitpid()which returns an encoded status. The C macrosWIFEXITED()/WEXITSTATUS()andWIFSIGNALED()/WTERMSIG()exist to decode this, butproc_close()does not use them to differentiate the two cases. It effectively passes back a value that loses the signal/exit distinction.PHP's own
pcntl_waitpid()+pcntl_wifexited()+pcntl_wtermsig()handle this correctly, butpcntlis not always available, andproc_close()is the standard way to get the exit status of aproc_open()process.Reproduction
Expected behavior
proc_close()should return128 + signal_numberwhen the child was killed by a signal, consistent with how bash and other Unix shells report signal termination via$?. This would allow userland code to detect crashes.Alternatively, expose the signal information through a separate mechanism (e.g. an optional by-reference parameter, or a new
proc_exit_status()function).Real-world impact
This affects tools that use
proc_open()to spawn child processes, notablycomposer/xdebug-handlerwhich restarts PHP processes. When the child crashes (e.g. due to a PHP JIT bug triggering SIGSEGV), the parent receives exit code11with no way to detect it was a signal — leading to silent, misleading failures in tools like Psalm, PHPStan, and Composer.PHP Version
Tested on PHP 8.4 and 8.5, but the behavior has existed since
proc_openwas introduced.Operating System
Linux / macOS (any non-Windows POSIX system)