From 1845f0a74232ea9f2030a37fbcc92a88e99bc177 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 15 Mar 2024 10:40:02 +0100 Subject: [PATCH 01/10] POC: lightweight subprocess isolation via pcntl_fork() --- src/Framework/TestRunner.php | 81 +++++++++++++++++++++++++++++ src/Util/PHP/AbstractPhpProcess.php | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index e41a44bfcb6..e9a33e374fb 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\Framework; +use PHPUnit\TestRunner\TestResult\PassedTests; use const PHP_EOL; use function assert; use function class_exists; @@ -249,6 +250,86 @@ public function run(TestCase $test): void * @throws StaticAnalysisCacheNotConfiguredException */ public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void + { + if ($runEntireClass && $this->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); + return; + } + + // running in a separate process is slow, but works in most situations. + $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') + ; + } + + private function runInFork(TestCase $test): void + { + if (socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets) === false) { + throw new \Exception('could not create socket pair'); + } + + $pid = pcntl_fork(); + // pcntl_fork may return NULL if the function is disabled in php.ini. + if ($pid === -1 || $pid === null) { + throw new \Exception('could not fork'); + } else if ($pid) { + // we are the parent + + pcntl_waitpid($pid, $status); // protect against zombie children + + // read child output + $output = ''; + while(($read = socket_read($sockets[1], 2048, PHP_BINARY_READ)) !== false) { + $output .= $read; + } + socket_close($sockets[1]); + + $php = AbstractPhpProcess::factory(); + $php->processChildResult($test, $output, ''); // TODO stderr + + } else { + // we are the child + + $offset = hrtime(); + $dispatcher = Event\Facade::instance()->initForIsolation( + \PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $offset[0], + $offset[1] + ) + ); + + $test->setInIsolation(true); + $test->runBare(); + + // send result into parent + socket_write($sockets[0], + serialize( + [ + 'testResult' => $test->result(), + 'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null, + 'numAssertions' => $test->numberOfAssertionsPerformed(), + 'output' => !$test->expectsOutput() ? $output : '', + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance() + ] + ) + ); + socket_close($sockets[0]); + } + } + + private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void { $class = new ReflectionClass($test); diff --git a/src/Util/PHP/AbstractPhpProcess.php b/src/Util/PHP/AbstractPhpProcess.php index 7d76d73c4c9..787943e6af8 100644 --- a/src/Util/PHP/AbstractPhpProcess.php +++ b/src/Util/PHP/AbstractPhpProcess.php @@ -230,7 +230,7 @@ protected function settingsToParameters(array $settings): array * @throws Exception * @throws NoPreviousThrowableException */ - private function processChildResult(Test $test, string $stdout, string $stderr): void + public function processChildResult(Test $test, string $stdout, string $stderr): void { if (!empty($stderr)) { $exception = new Exception(trim($stderr)); From d3d05ee92948001d384fad7aa43dfe1dfefcc51b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 Mar 2024 19:54:05 +0100 Subject: [PATCH 02/10] implement ipc --- src/Framework/TestRunner.php | 140 ++++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 26 deletions(-) diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index e9a33e374fb..afeee6933a0 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -273,34 +273,117 @@ function_exists('pcntl_fork') ; } + // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php + private const SOCKET_HEADER_SIZE = 4; + + private function ipc_init() + { + // 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; + } + + private function socket_receive($socket) + { + // 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); + } + + private function socket_send($socket, $message) + { + $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 { - if (socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets) === false) { - throw new \Exception('could not create socket pair'); - } + list($socket_child, $socket_parent) = $this->ipc_init(); $pid = pcntl_fork(); - // pcntl_fork may return NULL if the function is disabled in php.ini. - if ($pid === -1 || $pid === null) { + + if ($pid === -1 ) { throw new \Exception('could not fork'); } else if ($pid) { // we are the parent - pcntl_waitpid($pid, $status); // protect against zombie children + socket_close($socket_parent); + + // read child stdout, stderr + $result = $this->socket_receive($socket_child); - // read child output - $output = ''; - while(($read = socket_read($sockets[1], 2048, PHP_BINARY_READ)) !== false) { - $output .= $read; + $stderr = ''; + $stdout = ''; + if (is_array($result) && array_key_exists('error', $result)) { + $stderr = $result['error']; + } else { + $stdout = $result; } - socket_close($sockets[1]); $php = AbstractPhpProcess::factory(); - $php->processChildResult($test, $output, ''); // TODO stderr + $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( @@ -310,22 +393,27 @@ private function runInFork(TestCase $test): void ); $test->setInIsolation(true); - $test->runBare(); + try { + $test->run(); + } catch (Throwable $e) { + $this->socket_send($socket_parent, ['error' => $e->getMessage()]); + exit(); + } - // send result into parent - socket_write($sockets[0], - serialize( - [ - 'testResult' => $test->result(), - 'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null, - 'numAssertions' => $test->numberOfAssertionsPerformed(), - 'output' => !$test->expectsOutput() ? $output : '', - 'events' => $dispatcher->flush(), - 'passedTests' => PassedTests::instance() - ] - ) + $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() + ] ); - socket_close($sockets[0]); + + // send result into parent + $this->socket_send($socket_parent, $result); + exit(); } } From dd8f8735a2ae24627db4305148f0ee62e9aff259 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 Mar 2024 20:03:28 +0100 Subject: [PATCH 03/10] Update TestRunner.php --- src/Framework/TestRunner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index afeee6933a0..1d22aab5ca0 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -251,7 +251,7 @@ public function run(TestCase $test): void */ public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void { - if ($runEntireClass && $this->isPcntlForkAvailable()) { + if ($this->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); From 43973e2af5fead062734d42bad5debac31950480 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 Mar 2024 21:08:02 +0100 Subject: [PATCH 04/10] types --- src/Framework/TestRunner.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index 1d22aab5ca0..fcc26c55447 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -276,7 +276,7 @@ function_exists('pcntl_fork') // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php private const SOCKET_HEADER_SIZE = 4; - private function ipc_init() + private function ipc_init(): array { // windows needs AF_INET $domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX; @@ -291,7 +291,10 @@ private function ipc_init() return $sockets; } - private function socket_receive($socket) + /** + * @param resource $socket + */ + private function socket_receive($socket): mixed { // initially read to the length of the header size, then // expand to read more @@ -328,7 +331,11 @@ private function socket_receive($socket) return @unserialize($socket_message); } - private function socket_send($socket, $message) + /** + * @param resource $socket + * @param mixed $message + */ + private function socket_send($socket, $message): void { $serialized_message = @serialize($message); if ($serialized_message == false) From 7f1905bdb805d758233fbe51df5fcdf0b4e85dc6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 Mar 2024 21:22:28 +0100 Subject: [PATCH 05/10] extract PcntlFork --- src/Framework/TestRunner.php | 168 +------------------------------- src/Util/PHP/PcntlFork.php | 179 +++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 164 deletions(-) create mode 100644 src/Util/PHP/PcntlFork.php diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index fcc26c55447..9b36736b3e8 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); + } + } +} From 118605ddec5fe79b3c33907eaed12f3f0021a758 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 17 Mar 2024 21:22:54 +0100 Subject: [PATCH 06/10] cs --- src/Framework/TestRunner.php | 12 +--- src/Util/PHP/PcntlFork.php | 123 ++++++++++++++++++++++------------- 2 files changed, 79 insertions(+), 56 deletions(-) diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index 9b36736b3e8..23abb20c94a 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -9,8 +9,6 @@ */ namespace PHPUnit\Framework; -use PHPUnit\TestRunner\TestResult\PassedTests; -use PHPUnit\Util\PHP\PcntlFork; use const PHP_EOL; use function assert; use function class_exists; @@ -35,6 +33,7 @@ use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\Util\GlobalState; use PHPUnit\Util\PHP\AbstractPhpProcess; +use PHPUnit\Util\PHP\PcntlFork; use ReflectionClass; use SebastianBergmann\CodeCoverage\Exception as OriginalCodeCoverageException; use SebastianBergmann\CodeCoverage\InvalidArgumentException; @@ -255,17 +254,12 @@ public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool if (PcntlFork::isPcntlForkAvailable()) { // forking the parent process is a more lightweight way to run a test in isolation. // it requires the pcntl extension though. - $fork = new PcntlFork(); + $fork = new PcntlFork; $fork->runTest($test); + return; } - // running in a separate process is slow, but works in most situations. - $this->runInWorkerProcess($test, $runEntireClass, $preserveGlobalState); - } - - private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void - { $class = new ReflectionClass($test); if ($runEntireClass) { diff --git a/src/Util/PHP/PcntlFork.php b/src/Util/PHP/PcntlFork.php index eef908fb1c9..41f8887d85b 100644 --- a/src/Util/PHP/PcntlFork.php +++ b/src/Util/PHP/PcntlFork.php @@ -9,35 +9,62 @@ */ namespace PHPUnit\Util\PHP; +use function array_key_exists; +use function array_values; +use function function_exists; +use function hrtime; +use function ini_get; +use function is_array; +use function pack; +use function pcntl_fork; +use function serialize; +use function socket_close; +use function socket_create_pair; +use function socket_last_error; +use function socket_read; +use function socket_strerror; +use function socket_write; +use function str_contains; +use function strlen; +use function strtoupper; +use function substr; +use function unpack; +use function unserialize; +use Exception; use PHPUnit\Event\Facade; +use PHPUnit\Event\Telemetry\HRTime; use PHPUnit\Framework\TestCase; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TestRunner\TestResult\PassedTests; +use RuntimeException; -final class PcntlFork { +final class PcntlFork +{ // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php private const SOCKET_HEADER_SIZE = 4; - static public function isPcntlForkAvailable(): bool { + public static 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') - ; + 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(); + [$socket_child, $socket_parent] = $this->ipcInit(); $pid = pcntl_fork(); - if ($pid === -1 ) { - throw new \Exception('could not fork'); - } else if ($pid) { + if ($pid === -1) { + throw new Exception('could not fork'); + } + + if ($pid) { // we are the parent socket_close($socket_parent); @@ -47,6 +74,7 @@ public function runTest(TestCase $test): void $stderr = ''; $stdout = ''; + if (is_array($result) && array_key_exists('error', $result)) { $stderr = $result['error']; } else { @@ -61,19 +89,21 @@ public function runTest(TestCase $test): void socket_close($socket_child); - $offset = hrtime(); + $offset = hrtime(); $dispatcher = Facade::instance()->initForIsolation( - \PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + HRTime::fromSecondsAndNanoseconds( $offset[0], - $offset[1] - ) + $offset[1], + ), ); $test->setInIsolation(true); + try { $test->run(); } catch (Throwable $e) { $this->socketSend($socket_parent, ['error' => $e->getMessage()]); + exit(); } @@ -84,12 +114,13 @@ public function runTest(TestCase $test): void 'numAssertions' => $test->numberOfAssertionsPerformed(), 'output' => !$test->expectsOutput() ? $test->output() : '', 'events' => $dispatcher->flush(), - 'passedTests' => PassedTests::instance() - ] + 'passedTests' => PassedTests::instance(), + ], ); // send result into parent $this->socketSend($socket_parent, $result); + exit(); } } @@ -100,10 +131,10 @@ private function ipcInit(): array $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())); + $sockets = []; + + if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false) { + throw new RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error())); } return $sockets; @@ -116,32 +147,30 @@ 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; + $bytes_total = self::SOCKET_HEADER_SIZE; + $bytes_read = 0; + $have_header = false; $socket_message = ''; - while ($bytes_read < $bytes_total) - { + + 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())); + + if ($read === false) { + throw new RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error())); } // blank socket_read means done - if ($read == '') - { + 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; + if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE) { + $have_header = true; + [$bytes_total] = array_values(unpack('N', $socket_message)); + $bytes_read = 0; $socket_message = ''; } } @@ -151,25 +180,25 @@ private function socketReceive($socket): mixed /** * @param resource $socket - * @param mixed $message + * @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'); + + if ($serialized_message == false) { + throw new RuntimeException('socket_send failed to serialize message'); } - $header = pack('N', strlen($serialized_message)); - $data = $header . $serialized_message; + $header = pack('N', strlen($serialized_message)); + $data = $header . $serialized_message; $bytes_left = strlen($data); - while ($bytes_left > 0) - { + + while ($bytes_left > 0) { $bytes_sent = @socket_write($socket, $data); - if ($bytes_sent === false) - { - throw new \RuntimeException('socket_send failed to write to socket'); + + if ($bytes_sent === false) { + throw new RuntimeException('socket_send failed to write to socket'); } $bytes_left -= $bytes_sent; From 7adcdee369a8c51f6014c1d7618ccd98890e42ad Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 18 Mar 2024 09:03:59 +0100 Subject: [PATCH 07/10] CI: add pcntl job --- .github/workflows/ci.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0214c7b8a7e..3a17ef18cd0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -166,6 +166,11 @@ jobs: - "8.3" - "8.4" + include: + - os: ubuntu-latest + php-version: "8.3" + add-ext: ", pcntl" + steps: - name: Configure Git to avoid issues with line endings if: matrix.os == 'windows-latest' @@ -178,7 +183,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: ${{ env.PHP_EXTENSIONS }} + extensions: "${{ env.PHP_EXTENSIONS }}${{ matrix.add-ext }}" ini-values: ${{ env.PHP_INI_VALUES }} coverage: pcov tools: none From 07584682fdfd640da9efbd299936232ca9e2c095 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 18 Mar 2024 10:18:32 +0100 Subject: [PATCH 08/10] run unit tests also with pcntl --- .github/workflows/ci.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a17ef18cd0..b8258af644b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -119,6 +119,11 @@ jobs: - "8.3" - "8.4" + include: + - os: ubuntu-latest + php-version: "8.3" + add-ext: ", pcntl" + steps: - name: Configure Git to avoid issues with line endings if: matrix.os == 'windows-latest' @@ -131,7 +136,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: ${{ env.PHP_EXTENSIONS }} + extensions: "${{ env.PHP_EXTENSIONS }}${{ matrix.add-ext }}" ini-values: ${{ env.PHP_INI_VALUES }} tools: none From 38dfa4646ef57d9f6b580d1b09499328fef5949f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 29 Mar 2024 09:25:19 +0100 Subject: [PATCH 09/10] added fork-if-possible annotation support --- .../Attributes/RunClassInSeparateProcess.php | 11 +++++++++++ .../Attributes/RunInSeparateProcess.php | 11 +++++++++++ .../Attributes/RunTestsInSeparateProcesses.php | 11 +++++++++++ src/Metadata/Metadata.php | 12 ++++++------ src/Metadata/Parser/AttributeParser.php | 18 +++++++++++++++--- src/Metadata/RunClassInSeparateProcess.php | 17 +++++++++++++++++ src/Metadata/RunInSeparateProcess.php | 17 +++++++++++++++++ src/Metadata/RunTestsInSeparateProcesses.php | 17 +++++++++++++++++ 8 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/Framework/Attributes/RunClassInSeparateProcess.php b/src/Framework/Attributes/RunClassInSeparateProcess.php index 1ccd3bc6fb2..e8918e5494c 100644 --- a/src/Framework/Attributes/RunClassInSeparateProcess.php +++ b/src/Framework/Attributes/RunClassInSeparateProcess.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class RunClassInSeparateProcess { + private ?bool $forkIfPossible; + + public function __construct(bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Framework/Attributes/RunInSeparateProcess.php b/src/Framework/Attributes/RunInSeparateProcess.php index 838e267e2e4..d10cbba3efd 100644 --- a/src/Framework/Attributes/RunInSeparateProcess.php +++ b/src/Framework/Attributes/RunInSeparateProcess.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_METHOD)] final readonly class RunInSeparateProcess { + private ?bool $forkIfPossible; + + public function __construct(?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Framework/Attributes/RunTestsInSeparateProcesses.php b/src/Framework/Attributes/RunTestsInSeparateProcesses.php index 19fea88239a..ed804fa2a70 100644 --- a/src/Framework/Attributes/RunTestsInSeparateProcesses.php +++ b/src/Framework/Attributes/RunTestsInSeparateProcesses.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class RunTestsInSeparateProcesses { + private ?bool $forkIfPossible; + + public function __construct(?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index d9a787b731c..ce1296974c3 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -390,19 +390,19 @@ public static function requiresSettingOnMethod(string $setting, string $value): return new RequiresSetting(self::METHOD_LEVEL, $setting, $value); } - public static function runClassInSeparateProcess(): RunClassInSeparateProcess + public static function runClassInSeparateProcess(?bool $forkIfPossible = null): RunClassInSeparateProcess { - return new RunClassInSeparateProcess(self::CLASS_LEVEL); + return new RunClassInSeparateProcess(self::CLASS_LEVEL, $forkIfPossible); } - public static function runTestsInSeparateProcesses(): RunTestsInSeparateProcesses + public static function runTestsInSeparateProcesses(?bool $forkIfPossible = null): RunTestsInSeparateProcesses { - return new RunTestsInSeparateProcesses(self::CLASS_LEVEL); + return new RunTestsInSeparateProcesses(self::CLASS_LEVEL, $forkIfPossible); } - public static function runInSeparateProcess(): RunInSeparateProcess + public static function runInSeparateProcess(?bool $forkIfPossible = null): RunInSeparateProcess { - return new RunInSeparateProcess(self::METHOD_LEVEL); + return new RunInSeparateProcess(self::METHOD_LEVEL, $forkIfPossible); } public static function test(): Test diff --git a/src/Metadata/Parser/AttributeParser.php b/src/Metadata/Parser/AttributeParser.php index 257c38e3282..8b19fa387c5 100644 --- a/src/Metadata/Parser/AttributeParser.php +++ b/src/Metadata/Parser/AttributeParser.php @@ -297,12 +297,20 @@ public function forClass(string $className): MetadataCollection break; case RunClassInSeparateProcess::class: - $result[] = Metadata::runClassInSeparateProcess(); + assert($attributeInstance instanceof RunClassInSeparateProcess); + + $result[] = Metadata::runClassInSeparateProcess( + $attributeInstance->forkIfPossible(), + ); break; case RunTestsInSeparateProcesses::class: - $result[] = Metadata::runTestsInSeparateProcesses(); + assert($attributeInstance instanceof RunTestsInSeparateProcesses); + + $result[] = Metadata::runTestsInSeparateProcesses( + $attributeInstance->forkIfPossible(), + ); break; @@ -638,7 +646,11 @@ public function forMethod(string $className, string $methodName): MetadataCollec break; case RunInSeparateProcess::class: - $result[] = Metadata::runInSeparateProcess(); + assert($attributeInstance instanceof RunInSeparateProcess); + + $result[] = Metadata::runInSeparateProcess( + $attributeInstance->forkIfPossible(), + ); break; diff --git a/src/Metadata/RunClassInSeparateProcess.php b/src/Metadata/RunClassInSeparateProcess.php index 907a45d4524..a5b6aa7f829 100644 --- a/src/Metadata/RunClassInSeparateProcess.php +++ b/src/Metadata/RunClassInSeparateProcess.php @@ -16,6 +16,23 @@ */ final readonly class RunClassInSeparateProcess extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunClassInSeparateProcess $this */ diff --git a/src/Metadata/RunInSeparateProcess.php b/src/Metadata/RunInSeparateProcess.php index 07febae09de..9b5cc237b01 100644 --- a/src/Metadata/RunInSeparateProcess.php +++ b/src/Metadata/RunInSeparateProcess.php @@ -16,6 +16,23 @@ */ final readonly class RunInSeparateProcess extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunInSeparateProcess $this */ diff --git a/src/Metadata/RunTestsInSeparateProcesses.php b/src/Metadata/RunTestsInSeparateProcesses.php index b71233d3dbe..5a8275d293f 100644 --- a/src/Metadata/RunTestsInSeparateProcesses.php +++ b/src/Metadata/RunTestsInSeparateProcesses.php @@ -16,6 +16,23 @@ */ final readonly class RunTestsInSeparateProcesses extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunTestsInSeparateProcesses $this */ From 3628c7b6728614eaac2ddbc0e49fb1a7cc04d288 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 29 Mar 2024 10:07:05 +0100 Subject: [PATCH 10/10] wire annotations --- .../Attributes/RunClassInSeparateProcess.php | 2 +- src/Framework/TestBuilder.php | 63 ++++++++++++++++++- src/Framework/TestCase.php | 10 +++ src/Framework/TestRunner.php | 4 +- .../tests/ProcessIsolationForkedTest.php | 25 ++++++++ ...ithClassLevelIsolationAttributesForked.php | 27 ++++++++ ...thMethodLevelIsolationAttributesForked.php | 25 ++++++++ 7 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php create mode 100644 tests/_files/TestWithClassLevelIsolationAttributesForked.php create mode 100644 tests/_files/TestWithMethodLevelIsolationAttributesForked.php diff --git a/src/Framework/Attributes/RunClassInSeparateProcess.php b/src/Framework/Attributes/RunClassInSeparateProcess.php index e8918e5494c..d15069068d5 100644 --- a/src/Framework/Attributes/RunClassInSeparateProcess.php +++ b/src/Framework/Attributes/RunClassInSeparateProcess.php @@ -21,7 +21,7 @@ { private ?bool $forkIfPossible; - public function __construct(bool $forkIfPossible = null) + public function __construct(?bool $forkIfPossible = null) { $this->forkIfPossible = $forkIfPossible; } diff --git a/src/Framework/TestBuilder.php b/src/Framework/TestBuilder.php index e2e63150e42..1c89966af8d 100644 --- a/src/Framework/TestBuilder.php +++ b/src/Framework/TestBuilder.php @@ -19,6 +19,9 @@ use PHPUnit\Metadata\ExcludeStaticPropertyFromBackup; use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; use PHPUnit\Metadata\PreserveGlobalState; +use PHPUnit\Metadata\RunClassInSeparateProcess; +use PHPUnit\Metadata\RunInSeparateProcess; +use PHPUnit\Metadata\RunTestsInSeparateProcesses; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use ReflectionClass; @@ -51,6 +54,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou $this->shouldTestMethodBeRunInSeparateProcess($className, $methodName), $this->shouldGlobalStateBePreserved($className, $methodName), $this->shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess($className), + $this->shouldForkIfPossible($className, $methodName), $this->backupSettings($className, $methodName), $groups, ); @@ -64,6 +68,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou $this->shouldTestMethodBeRunInSeparateProcess($className, $methodName), $this->shouldGlobalStateBePreserved($className, $methodName), $this->shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess($className), + $this->shouldForkIfPossible($className, $methodName), $this->backupSettings($className, $methodName), ); @@ -76,7 +81,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou * @psalm-param array{backupGlobals: ?bool, backupGlobalsExcludeList: list, backupStaticProperties: ?bool, backupStaticPropertiesExcludeList: array>} $backupSettings * @psalm-param list $groups */ - private function buildDataProviderTestSuite(string $methodName, string $className, array $data, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, array $backupSettings, array $groups): DataProviderTestSuite + private function buildDataProviderTestSuite(string $methodName, string $className, array $data, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, bool $forkIfPossible, array $backupSettings, array $groups): DataProviderTestSuite { $dataProviderTestSuite = DataProviderTestSuite::empty( $className . '::' . $methodName, @@ -98,6 +103,7 @@ private function buildDataProviderTestSuite(string $methodName, string $classNam $runTestInSeparateProcess, $preserveGlobalState, $runClassInSeparateProcess, + $forkIfPossible, $backupSettings, ); @@ -110,7 +116,7 @@ private function buildDataProviderTestSuite(string $methodName, string $classNam /** * @psalm-param array{backupGlobals: ?bool, backupGlobalsExcludeList: list, backupStaticProperties: ?bool, backupStaticPropertiesExcludeList: array>} $backupSettings */ - private function configureTestCase(TestCase $test, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, array $backupSettings): void + private function configureTestCase(TestCase $test, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, bool $forkIfPossible, array $backupSettings): void { if ($runTestInSeparateProcess) { $test->setRunTestInSeparateProcess(true); @@ -120,6 +126,10 @@ private function configureTestCase(TestCase $test, bool $runTestInSeparateProces $test->setRunClassInSeparateProcess(true); } + if ($forkIfPossible) { + $test->setForkIfPossible(true); + } + if ($preserveGlobalState !== null) { $test->setPreserveGlobalState($preserveGlobalState); } @@ -272,4 +282,53 @@ private function shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess(str { return MetadataRegistry::parser()->forClass($className)->isRunClassInSeparateProcess()->isNotEmpty(); } + + /** + * @psalm-param class-string $className + * @psalm-param non-empty-string $methodName + */ + private function shouldForkIfPossible(string $className, string $methodName): bool + { + $metadataForMethod = MetadataRegistry::parser()->forMethod($className, $methodName); + + if ($metadataForMethod->isRunInSeparateProcess()->isNotEmpty()) { + $metadata = $metadataForMethod->isRunInSeparateProcess()->asArray()[0]; + + assert($metadata instanceof RunInSeparateProcess); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + $metadataForClass = MetadataRegistry::parser()->forClass($className); + + if ($metadataForClass->isRunTestsInSeparateProcesses()->isNotEmpty()) { + $metadata = $metadataForClass->isRunTestsInSeparateProcesses()->asArray()[0]; + + assert($metadata instanceof RunTestsInSeparateProcesses); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + if ($metadataForClass->isRunClassInSeparateProcess()->isNotEmpty()) { + $metadata = $metadataForClass->isRunClassInSeparateProcess()->asArray()[0]; + + assert($metadata instanceof RunClassInSeparateProcess); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + return false; + } } diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index 5f6a1a9f6b2..eade3a9d83c 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -142,6 +142,7 @@ abstract class TestCase extends Assert implements Reorderable, SelfDescribing, T */ private ?array $backupGlobalExceptionHandlers = null; private ?bool $runClassInSeparateProcess = null; + private ?bool $forkIfPossible = null; private ?bool $runTestInSeparateProcess = null; private bool $preserveGlobalState = false; private bool $inIsolation = false; @@ -340,6 +341,7 @@ final public function run(): void $this, $this->runClassInSeparateProcess && !$this->runTestInSeparateProcess, $this->preserveGlobalState, + $this->forkIfPossible === true, ); } } @@ -709,6 +711,14 @@ final public function setRunClassInSeparateProcess(bool $runClassInSeparateProce $this->runClassInSeparateProcess = $runClassInSeparateProcess; } + /** + * @internal This method is not covered by the backward compatibility promise for PHPUnit + */ + final public function setForkIfPossible(bool $forkIfPossible): void + { + $this->forkIfPossible = $forkIfPossible; + } + /** * @internal This method is not covered by the backward compatibility promise for PHPUnit */ diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index 23abb20c94a..b4c69eb4849 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -249,9 +249,9 @@ public function run(TestCase $test): void * @throws ProcessIsolationException * @throws StaticAnalysisCacheNotConfiguredException */ - public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void + public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState, bool $forkIfPossible): void { - if (PcntlFork::isPcntlForkAvailable()) { + if ($forkIfPossible && PcntlFork::isPcntlForkAvailable()) { // forking the parent process is a more lightweight way to run a test in isolation. // it requires the pcntl extension though. $fork = new PcntlFork; diff --git a/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php b/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php new file mode 100644 index 00000000000..3278fa91c7e --- /dev/null +++ b/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Metadata\Attribute; + +use PHPUnit\Framework\Attributes\RunClassInSeparateProcess; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[RunClassInSeparateProcess(true)] +#[RunTestsInSeparateProcesses] +final class ProcessIsolationForkedTest extends TestCase +{ + #[RunInSeparateProcess] + public function testOne(): void + { + } +} diff --git a/tests/_files/TestWithClassLevelIsolationAttributesForked.php b/tests/_files/TestWithClassLevelIsolationAttributesForked.php new file mode 100644 index 00000000000..4b363c3e690 --- /dev/null +++ b/tests/_files/TestWithClassLevelIsolationAttributesForked.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\TestBuilder; + +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\BackupStaticProperties; +use PHPUnit\Framework\Attributes\RunClassInSeparateProcess; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[BackupGlobals(true)] +#[BackupStaticProperties(true)] +#[RunClassInSeparateProcess] +#[RunTestsInSeparateProcesses(true)] +final class TestWithClassLevelIsolationAttributesForked extends TestCase +{ + public function testOne(): void + { + } +} diff --git a/tests/_files/TestWithMethodLevelIsolationAttributesForked.php b/tests/_files/TestWithMethodLevelIsolationAttributesForked.php new file mode 100644 index 00000000000..8a5221cfd87 --- /dev/null +++ b/tests/_files/TestWithMethodLevelIsolationAttributesForked.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\TestBuilder; + +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\BackupStaticProperties; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; + +final class TestWithMethodLevelIsolationAttributes extends TestCase +{ + #[BackupGlobals(true)] + #[BackupStaticProperties(true)] + #[RunInSeparateProcess(true)] + public function testOne(): void + { + } +}