diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index 24998ce5676..eddef23d8b9 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -10,6 +10,7 @@ namespace PHPUnit\Framework; use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\PcntlFork; use const PHP_EOL; use function assert; use function class_exists; @@ -251,10 +252,11 @@ public function run(TestCase $test): void */ public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void { - if ($this->isPcntlForkAvailable()) { + if (PcntlFork::isPcntlForkAvailable()) { // forking the parent process is a more lightweight way to run a test in isolation. // it requires the pcntl extension though. - $this->runInFork($test); + $fork = new PcntlFork(); + $fork->runTest($test); return; } @@ -262,168 +264,6 @@ public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $this->runInWorkerProcess($test, $runEntireClass, $preserveGlobalState); } - private function isPcntlForkAvailable(): bool { - $disabledFunctions = ini_get('disable_functions'); - - return - function_exists('pcntl_fork') - && !str_contains($disabledFunctions, 'pcntl') - && function_exists('socket_create_pair') - && !str_contains($disabledFunctions, 'socket') - ; - } - - // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php - private const SOCKET_HEADER_SIZE = 4; - - private function ipc_init(): array - { - // windows needs AF_INET - $domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX; - - // create a socket pair for IPC - $sockets = array(); - if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false) - { - throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error())); - } - - return $sockets; - } - - /** - * @param resource $socket - */ - private function socket_receive($socket): mixed - { - // initially read to the length of the header size, then - // expand to read more - $bytes_total = self::SOCKET_HEADER_SIZE; - $bytes_read = 0; - $have_header = false; - $socket_message = ''; - while ($bytes_read < $bytes_total) - { - $read = @socket_read($socket, $bytes_total - $bytes_read); - if ($read === false) - { - throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error())); - } - - // blank socket_read means done - if ($read == '') - { - break; - } - - $bytes_read += strlen($read); - $socket_message .= $read; - - if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE) - { - $have_header = true; - list($bytes_total) = array_values(unpack('N', $socket_message)); - $bytes_read = 0; - $socket_message = ''; - } - } - - return @unserialize($socket_message); - } - - /** - * @param resource $socket - * @param mixed $message - */ - private function socket_send($socket, $message): void - { - $serialized_message = @serialize($message); - if ($serialized_message == false) - { - throw new \RuntimeException('socket_send failed to serialize message'); - } - - $header = pack('N', strlen($serialized_message)); - $data = $header . $serialized_message; - $bytes_left = strlen($data); - while ($bytes_left > 0) - { - $bytes_sent = @socket_write($socket, $data); - if ($bytes_sent === false) - { - throw new \RuntimeException('socket_send failed to write to socket'); - } - - $bytes_left -= $bytes_sent; - $data = substr($data, $bytes_sent); - } - } - - private function runInFork(TestCase $test): void - { - list($socket_child, $socket_parent) = $this->ipc_init(); - - $pid = pcntl_fork(); - - if ($pid === -1 ) { - throw new \Exception('could not fork'); - } else if ($pid) { - // we are the parent - - socket_close($socket_parent); - - // read child stdout, stderr - $result = $this->socket_receive($socket_child); - - $stderr = ''; - $stdout = ''; - if (is_array($result) && array_key_exists('error', $result)) { - $stderr = $result['error']; - } else { - $stdout = $result; - } - - $php = AbstractPhpProcess::factory(); - $php->processChildResult($test, $stdout, $stderr); - - } else { - // we are the child - - socket_close($socket_child); - - $offset = hrtime(); - $dispatcher = Event\Facade::instance()->initForIsolation( - \PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( - $offset[0], - $offset[1] - ) - ); - - $test->setInIsolation(true); - try { - $test->run(); - } catch (Throwable $e) { - $this->socket_send($socket_parent, ['error' => $e->getMessage()]); - exit(); - } - - $result = serialize( - [ - 'testResult' => $test->result(), - 'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null, - 'numAssertions' => $test->numberOfAssertionsPerformed(), - 'output' => !$test->expectsOutput() ? $test->output() : '', - 'events' => $dispatcher->flush(), - 'passedTests' => PassedTests::instance() - ] - ); - - // send result into parent - $this->socket_send($socket_parent, $result); - exit(); - } - } - private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void { $class = new ReflectionClass($test); diff --git a/src/Util/PHP/PcntlFork.php b/src/Util/PHP/PcntlFork.php new file mode 100644 index 00000000000..eef908fb1c9 --- /dev/null +++ b/src/Util/PHP/PcntlFork.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util\PHP; + +use PHPUnit\Event\Facade; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; + +final class PcntlFork { + // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php + private const SOCKET_HEADER_SIZE = 4; + + static public function isPcntlForkAvailable(): bool { + $disabledFunctions = ini_get('disable_functions'); + + return + function_exists('pcntl_fork') + && !str_contains($disabledFunctions, 'pcntl') + && function_exists('socket_create_pair') + && !str_contains($disabledFunctions, 'socket') + ; + } + + public function runTest(TestCase $test): void + { + list($socket_child, $socket_parent) = $this->ipcInit(); + + $pid = pcntl_fork(); + + if ($pid === -1 ) { + throw new \Exception('could not fork'); + } else if ($pid) { + // we are the parent + + socket_close($socket_parent); + + // read child stdout, stderr + $result = $this->socketReceive($socket_child); + + $stderr = ''; + $stdout = ''; + if (is_array($result) && array_key_exists('error', $result)) { + $stderr = $result['error']; + } else { + $stdout = $result; + } + + $php = AbstractPhpProcess::factory(); + $php->processChildResult($test, $stdout, $stderr); + + } else { + // we are the child + + socket_close($socket_child); + + $offset = hrtime(); + $dispatcher = Facade::instance()->initForIsolation( + \PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $offset[0], + $offset[1] + ) + ); + + $test->setInIsolation(true); + try { + $test->run(); + } catch (Throwable $e) { + $this->socketSend($socket_parent, ['error' => $e->getMessage()]); + exit(); + } + + $result = serialize( + [ + 'testResult' => $test->result(), + 'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null, + 'numAssertions' => $test->numberOfAssertionsPerformed(), + 'output' => !$test->expectsOutput() ? $test->output() : '', + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance() + ] + ); + + // send result into parent + $this->socketSend($socket_parent, $result); + exit(); + } + } + + private function ipcInit(): array + { + // windows needs AF_INET + $domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX; + + // create a socket pair for IPC + $sockets = array(); + if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false) + { + throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error())); + } + + return $sockets; + } + + /** + * @param resource $socket + */ + private function socketReceive($socket): mixed + { + // initially read to the length of the header size, then + // expand to read more + $bytes_total = self::SOCKET_HEADER_SIZE; + $bytes_read = 0; + $have_header = false; + $socket_message = ''; + while ($bytes_read < $bytes_total) + { + $read = @socket_read($socket, $bytes_total - $bytes_read); + if ($read === false) + { + throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error())); + } + + // blank socket_read means done + if ($read == '') + { + break; + } + + $bytes_read += strlen($read); + $socket_message .= $read; + + if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE) + { + $have_header = true; + list($bytes_total) = array_values(unpack('N', $socket_message)); + $bytes_read = 0; + $socket_message = ''; + } + } + + return @unserialize($socket_message); + } + + /** + * @param resource $socket + * @param mixed $message + */ + private function socketSend($socket, $message): void + { + $serialized_message = @serialize($message); + if ($serialized_message == false) + { + throw new \RuntimeException('socket_send failed to serialize message'); + } + + $header = pack('N', strlen($serialized_message)); + $data = $header . $serialized_message; + $bytes_left = strlen($data); + while ($bytes_left > 0) + { + $bytes_sent = @socket_write($socket, $data); + if ($bytes_sent === false) + { + throw new \RuntimeException('socket_send failed to write to socket'); + } + + $bytes_left -= $bytes_sent; + $data = substr($data, $bytes_sent); + } + } +}