From d754ad41363400b9f2abfcb7a2cb2c2cb570456e Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Wed, 17 Jun 2026 09:54:05 +0200 Subject: [PATCH 01/14] Add persistent worker for parallel test execution Generalize process isolation into a worker that boots PHPUnit once and then runs an arbitrary number of tests, each in response to a command on its control channel. Results are transported back using the same serialized envelope as process isolation, so ChildProcessResultProcessor reconstitutes them unchanged. Tests run by one worker share a process and therefore do not get per-test global-state isolation. --- .../Parallel/Exception/WorkerException.php | 22 ++ src/Runner/Parallel/PersistentWorker.php | 319 ++++++++++++++++++ src/Runner/Parallel/templates/worker.tpl | 148 ++++++++ .../parallel-worker/WorkerFirstTest.php | 20 ++ .../parallel-worker/WorkerProcessProbe.php | 31 ++ .../parallel-worker/WorkerSecondTest.php | 35 ++ .../Runner/Parallel/PersistentWorkerTest.php | 100 ++++++ 7 files changed, 675 insertions(+) create mode 100644 src/Runner/Parallel/Exception/WorkerException.php create mode 100644 src/Runner/Parallel/PersistentWorker.php create mode 100644 src/Runner/Parallel/templates/worker.tpl create mode 100644 tests/_files/parallel-worker/WorkerFirstTest.php create mode 100644 tests/_files/parallel-worker/WorkerProcessProbe.php create mode 100644 tests/_files/parallel-worker/WorkerSecondTest.php create mode 100644 tests/unit/Runner/Parallel/PersistentWorkerTest.php diff --git a/src/Runner/Parallel/Exception/WorkerException.php b/src/Runner/Parallel/Exception/WorkerException.php new file mode 100644 index 0000000000..2780d7089e --- /dev/null +++ b/src/Runner/Parallel/Exception/WorkerException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Exception; +use RuntimeException; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class WorkerException extends RuntimeException implements Exception +{ +} diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php new file mode 100644 index 0000000000..554845251a --- /dev/null +++ b/src/Runner/Parallel/PersistentWorker.php @@ -0,0 +1,319 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function assert; +use function base64_encode; +use function bin2hex; +use function defined; +use function fgets; +use function file_get_contents; +use function get_include_path; +use function hrtime; +use function is_resource; +use function json_encode; +use function random_bytes; +use function serialize; +use function sys_get_temp_dir; +use function tempnam; +use function trim; +use function unlink; +use function var_export; +use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; +use PHPUnit\TextUI\Configuration\SourceMapper; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; +use PHPUnit\Util\PHP\RunningJob; +use ReflectionClass; +use SebastianBergmann\Template\Template; + +/** + * A worker process that boots PHPUnit once and then executes an arbitrary + * number of tests, each in response to a command received on its control + * channel (its standard input). + * + * Unlike the SeparateProcessTestRunner, which spawns one process per test for + * isolation, a PersistentWorker amortizes the cost of bootstrapping PHPUnit + * across all of the tests it runs. The tests executed by a single worker share + * one process and therefore do not get the per-test global-state isolation that + * process isolation provides; this is the trade-off that makes the worker + * suitable as a building block for parallel test execution. + * + * The result of each test is transported back to the parent process using the + * very same serialized envelope that process isolation uses, so that the + * ChildProcessResultProcessor can reconstitute it unchanged. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class PersistentWorker +{ + /** + * Marker written by the worker to its control channel to signal that a + * test has finished and its result has been written to the result file. + * Must be kept in sync with templates/worker.tpl. + */ + private const string DONE_PREFIX = 'PHPUNIT_WORKER_DONE:'; + private readonly JobRunner $jobRunner; + private readonly ChildProcessResultProcessor $processor; + private ?RunningJob $job = null; + + /** + * @var list + */ + private array $temporaryFiles = []; + + public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $processor) + { + $this->jobRunner = $jobRunner; + $this->processor = $processor; + } + + /** + * @throws WorkerException + */ + public function start(): void + { + $this->job = $this->jobRunner->start(new Job($this->buildWorkerCode())); + } + + /** + * @throws WorkerException + */ + public function run(TestCase $test): void + { + assert($this->job !== null); + + $class = new ReflectionClass($test); + $file = $class->getFileName(); + + assert($file !== false); + + $offset = hrtime(); + $nonce = bin2hex(random_bytes(16)); + $resultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($resultFile === false) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to create temporary file for the worker result'); + // @codeCoverageIgnoreEnd + } + + $command = [ + 'command' => 'run', + 'file' => $file, + 'className' => $class->getName(), + 'methodName' => $test->name(), + 'data' => base64_encode(serialize($test->providedData())), + 'dataName' => $test->dataName(), + 'dependencyInput' => base64_encode(serialize($test->dependencyInput())), + 'repetition' => $test->repetition(), + 'totalRepetitions' => $test->totalRepetitions(), + 'attempt' => $test->attempt(), + 'maxAttempts' => $test->maxAttempts(), + 'offsetSeconds' => $offset[0], + 'offsetNanoseconds' => $offset[1], + 'resultFile' => $resultFile, + 'nonce' => $nonce, + ]; + + $encodedCommand = json_encode($command); + + assert($encodedCommand !== false); + + $this->job->write($encodedCommand . "\n"); + + if (!$this->awaitResult($nonce)) { + $result = $this->job->wait(); + $this->job = null; + + @unlink($resultFile); + + $this->processor->process($test, '', $result->stderr(), $nonce); + + return; + } + + $serializedResult = file_get_contents($resultFile); + + @unlink($resultFile); + + if ($serializedResult === false) { + $serializedResult = ''; + } + + $this->processor->process($test, $serializedResult, '', $nonce); + } + + public function stop(): void + { + if ($this->job !== null) { + $encodedCommand = json_encode(['command' => 'stop']); + + assert($encodedCommand !== false); + + $this->job->write($encodedCommand . "\n"); + $this->job->closeStdin(); + + $result = $this->job->wait(); + + EventFacade::emitter()->childProcessFinished($result->stdout(), $result->stderr()); + + $this->job = null; + } + + foreach ($this->temporaryFiles as $temporaryFile) { + @unlink($temporaryFile); + } + + $this->temporaryFiles = []; + } + + /** + * Read the worker's control channel until it reports that the test + * identified by the given nonce has finished. Any unrelated output the + * worker may have written to the channel is skipped. Returns false if the + * worker terminated before reporting completion. + */ + private function awaitResult(string $nonce): bool + { + assert($this->job !== null); + + $stdout = $this->job->stdout(); + + if (!is_resource($stdout)) { + // @codeCoverageIgnoreStart + return false; + // @codeCoverageIgnoreEnd + } + + $expected = self::DONE_PREFIX . $nonce; + + while (($line = fgets($stdout)) !== false) { + if (trim($line) === $expected) { + return true; + } + } + + return false; + } + + /** + * @throws WorkerException + * + * @return non-empty-string + */ + private function buildWorkerCode(): string + { + $configuration = ConfigurationRegistry::get(); + + $bootstrap = ''; + + if ($configuration->hasBootstrap()) { + $bootstrap = $configuration->bootstrap(); + } + + if (defined('PHPUNIT_COMPOSER_INSTALL')) { + $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); + } else { + $composerAutoload = '\'\''; + } + + if (defined('__PHPUNIT_PHAR__')) { + $phar = var_export(__PHPUNIT_PHAR__, true); + } else { + $phar = '\'\''; + } + + if (CodeCoverage::instance()->isActive()) { + $coverage = 'true'; + } else { + $coverage = 'false'; + } + + $includePath = var_export(get_include_path(), true); + $includePath = "'." . $includePath . ".'"; + + $template = new Template(__DIR__ . '/templates/worker.tpl'); + + $template->setVar( + [ + 'composerAutoload' => $composerAutoload, + 'phar' => $phar, + 'collectCodeCoverageInformation' => $coverage, + 'iniSettings' => '', + 'include_path' => $includePath, + 'serializedConfiguration' => $this->saveConfigurationForChildProcess(), + 'sourceMapFile' => $this->sourceMapFileForChildProcess(), + 'bootstrap' => $bootstrap, + ], + ); + + $code = $template->render(); + + assert($code !== ''); + + return $code; + } + + /** + * @throws WorkerException + */ + private function saveConfigurationForChildProcess(): string + { + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($path === false) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to create temporary file for the worker configuration'); + // @codeCoverageIgnoreEnd + } + + if (!ConfigurationRegistry::saveTo($path)) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to write the worker configuration to a temporary file'); + // @codeCoverageIgnoreEnd + } + + $this->temporaryFiles[] = $path; + + return $path; + } + + private function sourceMapFileForChildProcess(): string + { + if (!ConfigurationRegistry::get()->source()->notEmpty()) { + return ''; + } + + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($path === false) { + // @codeCoverageIgnoreStart + return ''; + // @codeCoverageIgnoreEnd + } + + if (!SourceMapper::saveTo($path, ConfigurationRegistry::get()->source())) { + // @codeCoverageIgnoreStart + return ''; + // @codeCoverageIgnoreEnd + } + + $this->temporaryFiles[] = $path; + + return $path; + } +} diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl new file mode 100644 index 0000000000..a069af7b62 --- /dev/null +++ b/src/Runner/Parallel/templates/worker.tpl @@ -0,0 +1,148 @@ +source()); +} + +(new PhpHandler)->handle(ConfigurationRegistry::get()->php()); + +if ('{bootstrap}' !== '') { + require_once '{bootstrap}'; +} + +$__phpunit_configuration = ConfigurationRegistry::get(); + +if ({collectCodeCoverageInformation}) { + CodeCoverage::instance()->init($__phpunit_configuration, CodeCoverageFilterRegistry::instance(), true); +} + +ErrorHandlerBootstrapper::bootstrap($__phpunit_configuration); + +ob_end_clean(); + +function __phpunit_worker_run_test(object $command): string +{ + $dispatcher = Facade::instance()->initForIsolation( + PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $command->offsetSeconds, + $command->offsetNanoseconds + ), + ); + + require_once $command->file; + + $className = $command->className; + $methodName = $command->methodName; + + $test = new $className($methodName); + + $test->setData($command->dataName, unserialize(base64_decode($command->data))); + $test->setDependencyInput(unserialize(base64_decode($command->dependencyInput))); + $test->setRepetition($command->repetition, $command->totalRepetitions); + $test->setAttempt($command->attempt, $command->maxAttempts); + $test->setInIsolation(true); + + $test->run(); + + $output = ChildProcessOutputCollector::collect($test); + + $codeCoverage = null; + + if (CodeCoverage::instance()->isActive()) { + $codeCoverage = CodeCoverage::instance()->codeCoverage(); + } + + $result = $command->nonce . serialize( + (object) [ + 'testResult' => $test->result(), + 'status' => $test->status(), + 'codeCoverage' => $codeCoverage, + 'numAssertions' => $test->numberOfAssertionsPerformed(), + 'output' => $output, + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance(), + ] + ); + + // Per-test code coverage has been collected for this command and is about + // to be shipped to the parent process. It is cleared here so that the next + // test executed by this worker does not ship it a second time. + if (CodeCoverage::instance()->isActive()) { + CodeCoverage::instance()->codeCoverage()->clear(); + } + + // Reset the stream that captures test output so that the next test does + // not inherit the output of the test that has just finished. + if (@rewind(STDOUT)) { + @ftruncate(STDOUT, 0); + } + + return $result; +} + +$__phpunit_input = fopen('php://stdin', 'rb'); + +while (($__phpunit_line = fgets($__phpunit_input)) !== false) { + $__phpunit_line = trim($__phpunit_line); + + if ($__phpunit_line === '') { + continue; + } + + $__phpunit_command = json_decode($__phpunit_line); + + if ($__phpunit_command->command === 'stop') { + break; + } + + $__phpunit_result = __phpunit_worker_run_test($__phpunit_command); + + file_put_contents($__phpunit_command->resultFile, $__phpunit_result); + + fwrite($__phpunit_worker_control, 'PHPUNIT_WORKER_DONE:' . $__phpunit_command->nonce . "\n"); + fflush($__phpunit_worker_control); +} diff --git a/tests/_files/parallel-worker/WorkerFirstTest.php b/tests/_files/parallel-worker/WorkerFirstTest.php new file mode 100644 index 0000000000..363bb36f16 --- /dev/null +++ b/tests/_files/parallel-worker/WorkerFirstTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +use PHPUnit\Framework\TestCase; + +final class WorkerFirstTest extends TestCase +{ + public function testStartsTheProcessLocalCounter(): void + { + $this->assertSame(1, WorkerProcessProbe::increment()); + } +} diff --git a/tests/_files/parallel-worker/WorkerProcessProbe.php b/tests/_files/parallel-worker/WorkerProcessProbe.php new file mode 100644 index 0000000000..f9e73d445f --- /dev/null +++ b/tests/_files/parallel-worker/WorkerProcessProbe.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +/** + * Holds process-local state so that tests can assert whether they are executed + * in the same process as a previous test. + */ +final class WorkerProcessProbe +{ + private static int $counter = 0; + + public static function increment(): int + { + self::$counter++; + + return self::$counter; + } + + public static function value(): int + { + return self::$counter; + } +} diff --git a/tests/_files/parallel-worker/WorkerSecondTest.php b/tests/_files/parallel-worker/WorkerSecondTest.php new file mode 100644 index 0000000000..16e50d19d5 --- /dev/null +++ b/tests/_files/parallel-worker/WorkerSecondTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +use PHPUnit\Framework\TestCase; + +final class WorkerSecondTest extends TestCase +{ + public function testSeesTheStateLeftBehindByTheFirstTest(): void + { + // Passes only when this test shares a process with WorkerFirstTest, + // which is what running both tests in one persistent worker provides. + $this->assertSame(1, WorkerProcessProbe::value()); + $this->assertSame(2, WorkerProcessProbe::increment()); + } + + public function testThatFails(): void + { + $this->assertTrue(false, 'intentional failure inside a persistent worker'); + } + + public function testThatKillsTheWorkerProcess(): void + { + // Terminates the worker before it can report a result, exercising the + // parent's handling of a worker that dies in the middle of a test. + exit(1); + } +} diff --git a/tests/unit/Runner/Parallel/PersistentWorkerTest.php b/tests/unit/Runner/Parallel/PersistentWorkerTest.php new file mode 100644 index 0000000000..eaf48eb0ca --- /dev/null +++ b/tests/unit/Runner/Parallel/PersistentWorkerTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Event\Emitter; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(PersistentWorker::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class PersistentWorkerTest extends TestCase +{ + public function testRunsMultipleTestsFromDifferentClassesInOneProcess(): void + { + $worker = $this->worker(); + + $worker->start(); + + $first = new WorkerFirstTest('testStartsTheProcessLocalCounter'); + $second = new WorkerSecondTest('testSeesTheStateLeftBehindByTheFirstTest'); + + $worker->run($first); + $worker->run($second); + + $worker->stop(); + + $this->assertTrue($first->status()->isSuccess()); + $this->assertSame(1, $first->numberOfAssertionsPerformed()); + + // The second test only passes if it ran in the same process as the + // first one, which is what reusing a single worker provides. + $this->assertTrue($second->status()->isSuccess()); + $this->assertSame(2, $second->numberOfAssertionsPerformed()); + } + + public function testReportsFailingTestsBackToTheParentProcess(): void + { + $worker = $this->worker(); + + $worker->start(); + + $failing = new WorkerSecondTest('testThatFails'); + + $worker->run($failing); + + $worker->stop(); + + $this->assertTrue($failing->status()->isFailure()); + $this->assertStringContainsString( + 'intentional failure inside a persistent worker', + $failing->status()->message(), + ); + } + + public function testReportsAnErrorWhenTheWorkerDiesWhileRunningATest(): void + { + $worker = $this->worker(); + + $worker->start(); + + $crashing = new WorkerSecondTest('testThatKillsTheWorkerProcess'); + + $worker->run($crashing); + + $worker->stop(); + + $this->assertTrue($crashing->status()->isError()); + } + + private function worker(): PersistentWorker + { + $processor = new ChildProcessResultProcessor( + new Facade, + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + return new PersistentWorker(new JobRunner($processor), $processor); + } +} From cd6a933608d5f74e5ad60ca1b9136859987c8a53 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Wed, 17 Jun 2026 15:32:55 +0200 Subject: [PATCH 02/14] Ignore code that is unreachable from code coverage --- src/Runner/Parallel/PersistentWorker.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index 554845251a..df3151dfbe 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -151,7 +151,9 @@ public function run(TestCase $test): void @unlink($resultFile); if ($serializedResult === false) { + // @codeCoverageIgnoreStart $serializedResult = ''; + // @codeCoverageIgnoreEnd } $this->processor->process($test, $serializedResult, '', $nonce); @@ -228,11 +230,15 @@ private function buildWorkerCode(): string if (defined('PHPUNIT_COMPOSER_INSTALL')) { $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); } else { + // @codeCoverageIgnoreStart $composerAutoload = '\'\''; + // @codeCoverageIgnoreEnd } if (defined('__PHPUNIT_PHAR__')) { + // @codeCoverageIgnoreStart $phar = var_export(__PHPUNIT_PHAR__, true); + // @codeCoverageIgnoreEnd } else { $phar = '\'\''; } @@ -240,7 +246,9 @@ private function buildWorkerCode(): string if (CodeCoverage::instance()->isActive()) { $coverage = 'true'; } else { + // @codeCoverageIgnoreStart $coverage = 'false'; + // @codeCoverageIgnoreEnd } $includePath = var_export(get_include_path(), true); @@ -295,7 +303,9 @@ private function saveConfigurationForChildProcess(): string private function sourceMapFileForChildProcess(): string { if (!ConfigurationRegistry::get()->source()->notEmpty()) { + // @codeCoverageIgnoreStart return ''; + // @codeCoverageIgnoreEnd } $path = tempnam(sys_get_temp_dir(), 'phpunit_'); From 7fed9a83bb11e2f31a41949456615a1690d92208 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Tue, 23 Jun 2026 08:04:51 +0200 Subject: [PATCH 03/14] Harden awaitResult() matching --- src/Runner/Parallel/PersistentWorker.php | 9 ++++++++- .../parallel-worker/WorkerSecondTest.php | 15 +++++++++++++++ .../Runner/Parallel/PersistentWorkerTest.php | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index df3151dfbe..4f5e517222 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -21,6 +21,7 @@ use function json_encode; use function random_bytes; use function serialize; +use function str_ends_with; use function sys_get_temp_dir; use function tempnam; use function trim; @@ -188,6 +189,12 @@ public function stop(): void * identified by the given nonce has finished. Any unrelated output the * worker may have written to the channel is skipped. Returns false if the * worker terminated before reporting completion. + * + * The marker is the last thing the worker writes on its line, followed by + * a newline. Stray output that the test produced on the control channel + * without a trailing newline therefore fuses with the marker as a prefix + * of the same line; matching the marker as a suffix tolerates this instead + * of being defeated by it. */ private function awaitResult(string $nonce): bool { @@ -204,7 +211,7 @@ private function awaitResult(string $nonce): bool $expected = self::DONE_PREFIX . $nonce; while (($line = fgets($stdout)) !== false) { - if (trim($line) === $expected) { + if (str_ends_with(trim($line), $expected)) { return true; } } diff --git a/tests/_files/parallel-worker/WorkerSecondTest.php b/tests/_files/parallel-worker/WorkerSecondTest.php index 16e50d19d5..a303133ce6 100644 --- a/tests/_files/parallel-worker/WorkerSecondTest.php +++ b/tests/_files/parallel-worker/WorkerSecondTest.php @@ -9,10 +9,25 @@ */ namespace PHPUnit\TestFixture\ParallelWorker; +use function fopen; +use function fwrite; use PHPUnit\Framework\TestCase; final class WorkerSecondTest extends TestCase { + public function testThatWritesStrayOutputWithoutANewlineToTheControlChannel(): void + { + // Writes directly to the worker's control channel (file descriptor 1) + // without a trailing newline, so that the stray output fuses with the + // completion marker the worker appends on the same line. The parent + // must still recognise the marker. + $controlChannel = fopen('php://fd/1', 'wb'); + + fwrite($controlChannel, 'stray output without a trailing newline'); + + $this->assertTrue(true); + } + public function testSeesTheStateLeftBehindByTheFirstTest(): void { // Passes only when this test shares a process with WorkerFirstTest, diff --git a/tests/unit/Runner/Parallel/PersistentWorkerTest.php b/tests/unit/Runner/Parallel/PersistentWorkerTest.php index eaf48eb0ca..1b4855072a 100644 --- a/tests/unit/Runner/Parallel/PersistentWorkerTest.php +++ b/tests/unit/Runner/Parallel/PersistentWorkerTest.php @@ -71,6 +71,24 @@ public function testReportsFailingTestsBackToTheParentProcess(): void ); } + public function testRecognizesCompletionEvenWhenATestLeavesStrayOutputOnTheControlChannel(): void + { + $worker = $this->worker(); + + $worker->start(); + + $stray = new WorkerSecondTest('testThatWritesStrayOutputWithoutANewlineToTheControlChannel'); + + $worker->run($stray); + + $worker->stop(); + + // The completion marker fuses with the stray output the test wrote to + // the control channel without a trailing newline; the worker is only + // reported as succeeding if the parent still recognizes the marker. + $this->assertTrue($stray->status()->isSuccess()); + } + public function testReportsAnErrorWhenTheWorkerDiesWhileRunningATest(): void { $worker = $this->worker(); From 04600a66b1b8ff24ce79fec56ad1d8f5cb6edfd9 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Fri, 26 Jun 2026 06:53:52 +0200 Subject: [PATCH 04/14] Run a test suite across a pool of worker processes instead of sequentially, selected with the new --parallel= CLI option (sequential remains the default). Builds on the persistent worker and async JobRunner from #6753. The distribution unit is one test class, run in a worker that reconstructs and runs it from the same serialized envelope process isolation uses. PHPT tests are distributed too, reconstructed in a worker from their file path. A ResultAggregator replays each unit's collected events into the parent in deterministic suite order, so output, logging, results, and coverage are produced exactly as in sequential mode. Tests that cannot run in a worker are run in the main process at their suite position instead: those marked #[DoNotRunInParallel] (new class- and method-level attribute), those requiring process isolation, those whose data cannot be serialized, and PHPT tests carrying a --DO_NOT_RUN_IN_PARALLEL-- section. --- phpunit.xml | 2 + .../Attributes/DoNotRunInParallel.php | 22 + src/Metadata/DoNotRunInParallel.php | 23 + src/Metadata/Metadata.php | 18 + src/Metadata/MetadataCollection.php | 10 + src/Metadata/Parser/AttributeParser.php | 11 + src/Runner/Parallel/CompletedWorkUnit.php | 82 +++ src/Runner/Parallel/PersistentWorker.php | 308 +++++++++- src/Runner/Parallel/PhptWorkUnit.php | 55 ++ src/Runner/Parallel/ResultAggregator.php | 224 ++++++++ src/Runner/Parallel/TestClassWorkUnit.php | 81 +++ src/Runner/Parallel/WorkUnit.php | 32 ++ src/Runner/Parallel/WorkerPool.php | 219 +++++++ src/Runner/Parallel/templates/worker.tpl | 121 +++- src/TextUI/Application.php | 20 +- src/TextUI/Configuration/Cli/Builder.php | 22 + .../Configuration/Cli/Configuration.php | 31 +- src/TextUI/Configuration/Configuration.php | 17 +- src/TextUI/Configuration/Merger.php | 7 + src/TextUI/Help.php | 1 + src/TextUI/ParallelTestRunner.php | 537 ++++++++++++++++++ .../tests/DoNotRunInParallelTest.php | 22 + tests/_files/parallel-worker/worker.phpt | 7 + .../_files/output-cli-help-color.txt | 2 + tests/end-to-end/_files/output-cli-usage.txt | 1 + .../invalid-configuration.phpt | 1 + .../valid-configuration.phpt | 1 + ...ml-depends-defects-duration-ascending.phpt | 1 + ...n-ascending-test-methods-with-defects.phpt | 1 + ...y-defects-does-not-hoist-skipped-test.phpt | 1 + ...fects-preserves-top-level-suite-order.phpt | 1 + ...fects-but-result-cache-does-not-exist.phpt | 1 + ...-by-defects-test-classes-with-defects.phpt | 1 + ...fects-but-result-cache-does-not-exist.phpt | 1 + ...-by-defects-test-methods-with-defects.phpt | 1 + ...methods-with-dependencies-and-defects.phpt | 1 + ...asses-but-result-cache-does-not-exist.phpt | 1 + ...er-by-duration-ascending-test-classes.phpt | 1 + ...thods-but-result-cache-does-not-exist.phpt | 1 + ...er-by-duration-ascending-test-methods.phpt | 1 + ...asses-but-result-cache-does-not-exist.phpt | 1 + ...r-by-duration-descending-test-classes.phpt | 1 + ...thods-but-result-cache-does-not-exist.phpt | 1 + ...r-by-duration-descending-test-methods.phpt | 1 + ...asses-but-result-cache-does-not-exist.phpt | 1 + .../order-by-duration-test-classes.phpt | 1 + ...thods-but-result-cache-does-not-exist.phpt | 1 + .../order-by-duration-test-methods.phpt | 1 + .../migration/migration-from-100.phpt | 1 + .../migration/migration-from-110.phpt | 1 + ...igration-from-85-with-custom-filename.phpt | 1 + .../migration/migration-from-85.phpt | 1 + .../migration/migration-from-92.phpt | 1 + .../migration/migration-from-95.phpt | 1 + .../no-migration-needed-current-schema.phpt | 1 + ...migration-needed-relative-schema-path.phpt | 1 + ...ility-to-migrate-from-100-is-detected.phpt | 1 + ...bility-to-migrate-from-85-is-detected.phpt | 1 + ...bility-to-migrate-from-92-is-detected.phpt | 1 + ...bility-to-migrate-from-95-is-detected.phpt | 1 + .../migration/unsupported-schema.phpt | 1 + .../parallel/_files/FirstParallelTest.php | 25 + .../parallel/_files/SecondParallelTest.php | 25 + .../_files/ClassLevelTest.php | 22 + .../_files/InheritedTest.php | 26 + .../_files/MethodLevelTest.php | 27 + .../_files/PlainTest.php | 20 + .../do-not-run-in-parallel.phpt | 36 ++ .../_files/NonSerializableDataTest.php | 43 ++ .../non-serializable/non-serializable.phpt | 27 + .../ordering/_files/FirstOrderingTest.php | 20 + .../ordering/_files/SecondOrderingTest.php | 22 + .../ordering/_files/ThirdOrderingTest.php | 20 + .../parallel/ordering/ordering.phpt | 30 + .../parallel/parallel-execution.phpt | 37 ++ .../phpt/_files/sample-phpt-test.phpt | 7 + .../parallel/phpt/phpt-runs-in-parallel.phpt | 22 + .../Assert/assertNotInstanceOfTest.php | 2 + .../unit/Metadata/MetadataCollectionTest.php | 10 + tests/unit/Metadata/MetadataTest.php | 219 +++++++ .../Metadata/Parser/AttributeParserTest.php | 2 + .../Parser/AttributeParserTestCase.php | 19 + .../Runner/Parallel/ResultAggregatorTest.php | 197 +++++++ tests/unit/Runner/Parallel/WorkerPoolTest.php | 201 +++++++ tests/unit/TextUI/PhpHandlerTest.php | 2 + 85 files changed, 2960 insertions(+), 12 deletions(-) create mode 100644 src/Framework/Attributes/DoNotRunInParallel.php create mode 100644 src/Metadata/DoNotRunInParallel.php create mode 100644 src/Runner/Parallel/CompletedWorkUnit.php create mode 100644 src/Runner/Parallel/PhptWorkUnit.php create mode 100644 src/Runner/Parallel/ResultAggregator.php create mode 100644 src/Runner/Parallel/TestClassWorkUnit.php create mode 100644 src/Runner/Parallel/WorkUnit.php create mode 100644 src/Runner/Parallel/WorkerPool.php create mode 100644 src/TextUI/ParallelTestRunner.php create mode 100644 tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php create mode 100644 tests/_files/parallel-worker/worker.phpt create mode 100644 tests/end-to-end/parallel/_files/FirstParallelTest.php create mode 100644 tests/end-to-end/parallel/_files/SecondParallelTest.php create mode 100644 tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php create mode 100644 tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php create mode 100644 tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php create mode 100644 tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php create mode 100644 tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt create mode 100644 tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php create mode 100644 tests/end-to-end/parallel/non-serializable/non-serializable.phpt create mode 100644 tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php create mode 100644 tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php create mode 100644 tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php create mode 100644 tests/end-to-end/parallel/ordering/ordering.phpt create mode 100644 tests/end-to-end/parallel/parallel-execution.phpt create mode 100644 tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt/phpt-runs-in-parallel.phpt create mode 100644 tests/unit/Runner/Parallel/ResultAggregatorTest.php create mode 100644 tests/unit/Runner/Parallel/WorkerPoolTest.php diff --git a/phpunit.xml b/phpunit.xml index 10017aaae9..8eea4772da 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,6 +35,7 @@ tests/end-to-end/logging/testdox tests/end-to-end/metadata tests/end-to-end/migration + tests/end-to-end/parallel tests/end-to-end/phpt tests/end-to-end/regression tests/end-to-end/repeat @@ -48,6 +49,7 @@ tests/end-to-end/groups-from-configuration/_files tests/end-to-end/logging/_files tests/end-to-end/migration/_files + tests/end-to-end/parallel/phpt/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files tests/end-to-end/self-direct-indirect/_files diff --git a/src/Framework/Attributes/DoNotRunInParallel.php b/src/Framework/Attributes/DoNotRunInParallel.php new file mode 100644 index 0000000000..10bef64138 --- /dev/null +++ b/src/Framework/Attributes/DoNotRunInParallel.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\Attributes; + +use Attribute; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final readonly class DoNotRunInParallel +{ +} diff --git a/src/Metadata/DoNotRunInParallel.php b/src/Metadata/DoNotRunInParallel.php new file mode 100644 index 0000000000..038a7a44ee --- /dev/null +++ b/src/Metadata/DoNotRunInParallel.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Metadata; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + */ +final readonly class DoNotRunInParallel extends Metadata +{ + public function isDoNotRunInParallel(): true + { + return true; + } +} diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 5249ab1f78..d79358d50f 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -175,6 +175,16 @@ public static function disableReturnValueGenerationForTestDoubles(): DisableRetu return new DisableReturnValueGenerationForTestDoubles(Level::CLASS_LEVEL); } + public static function doNotRunInParallelOnClass(): DoNotRunInParallel + { + return new DoNotRunInParallel(Level::CLASS_LEVEL); + } + + public static function doNotRunInParallelOnMethod(): DoNotRunInParallel + { + return new DoNotRunInParallel(Level::METHOD_LEVEL); + } + public static function doesNotPerformAssertionsOnClass(): DoesNotPerformAssertions { return new DoesNotPerformAssertions(Level::CLASS_LEVEL); @@ -789,6 +799,14 @@ public function isDoesNotPerformAssertions(): bool return false; } + /** + * @phpstan-assert-if-true DoNotRunInParallel $this + */ + public function isDoNotRunInParallel(): bool + { + return false; + } + /** * @phpstan-assert-if-true ExcludeGlobalVariableFromBackup $this */ diff --git a/src/Metadata/MetadataCollection.php b/src/Metadata/MetadataCollection.php index 4a6c4311d7..055f6bf00c 100644 --- a/src/Metadata/MetadataCollection.php +++ b/src/Metadata/MetadataCollection.php @@ -348,6 +348,16 @@ public function isDoesNotPerformAssertions(): self ); } + public function isDoNotRunInParallel(): self + { + return new self( + ...array_filter( + $this->metadata, + static fn (Metadata $metadata): bool => $metadata->isDoNotRunInParallel(), + ), + ); + } + public function isGroup(): self { return new self( diff --git a/src/Metadata/Parser/AttributeParser.php b/src/Metadata/Parser/AttributeParser.php index d0409eaf16..5425b369b4 100644 --- a/src/Metadata/Parser/AttributeParser.php +++ b/src/Metadata/Parser/AttributeParser.php @@ -51,6 +51,7 @@ use PHPUnit\Framework\Attributes\DependsUsingShallowClone; use PHPUnit\Framework\Attributes\DisableReturnValueGenerationForTestDoubles; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\ExcludeGlobalVariableFromBackup; use PHPUnit\Framework\Attributes\ExcludeStaticPropertyFromBackup; use PHPUnit\Framework\Attributes\Group; @@ -244,6 +245,11 @@ public function forClass(string $className): MetadataCollection break; + case DoNotRunInParallel::class: + $result[] = Metadata::doNotRunInParallelOnClass(); + + break; + case ExcludeGlobalVariableFromBackup::class: assert($attributeInstance instanceof ExcludeGlobalVariableFromBackup); @@ -724,6 +730,11 @@ public function forMethod(string $className, string $methodName): MetadataCollec break; + case DoNotRunInParallel::class: + $result[] = Metadata::doNotRunInParallelOnMethod(); + + break; + case ExcludeGlobalVariableFromBackup::class: assert($attributeInstance instanceof ExcludeGlobalVariableFromBackup); diff --git a/src/Runner/Parallel/CompletedWorkUnit.php b/src/Runner/Parallel/CompletedWorkUnit.php new file mode 100644 index 0000000000..8d3230d056 --- /dev/null +++ b/src/Runner/Parallel/CompletedWorkUnit.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * The outcome of a WorkUnit that a worker has finished: the serialized result + * envelope written by the worker, together with the unit it belongs to and the + * nonce that authenticates the envelope. + * + * A unit whose worker died before reporting completion is marked as crashed; + * the serialized envelope is then empty. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class CompletedWorkUnit +{ + private WorkUnit $unit; + private string $serializedResult; + + /** + * @var ?non-empty-string + */ + private ?string $nonce; + private bool $crashed; + + /** + * A human-readable explanation of why a crashed unit did not run, used in + * place of the generic "ended unexpectedly" message when a more specific + * reason is known (for example, that the unit's test data could not be + * serialized for transport to a worker). + */ + private ?string $message; + + /** + * @param ?non-empty-string $nonce + */ + public function __construct(WorkUnit $unit, string $serializedResult, ?string $nonce, bool $crashed, ?string $message = null) + { + $this->unit = $unit; + $this->serializedResult = $serializedResult; + $this->nonce = $nonce; + $this->crashed = $crashed; + $this->message = $message; + } + + public function unit(): WorkUnit + { + return $this->unit; + } + + public function serializedResult(): string + { + return $this->serializedResult; + } + + /** + * @return ?non-empty-string + */ + public function nonce(): ?string + { + return $this->nonce; + } + + public function crashed(): bool + { + return $this->crashed; + } + + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index 4f5e517222..41ec8684e9 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -13,6 +13,7 @@ use function base64_encode; use function bin2hex; use function defined; +use function feof; use function fgets; use function file_get_contents; use function get_include_path; @@ -21,7 +22,11 @@ use function json_encode; use function random_bytes; use function serialize; +use function sprintf; +use function str_contains; use function str_ends_with; +use function stream_get_contents; +use function stream_set_blocking; use function sys_get_temp_dir; use function tempnam; use function trim; @@ -38,6 +43,7 @@ use PHPUnit\Util\PHP\RunningJob; use ReflectionClass; use SebastianBergmann\Template\Template; +use Throwable; /** * A worker process that boots PHPUnit once and then executes an arbitrary @@ -76,10 +82,34 @@ final class PersistentWorker */ private array $temporaryFiles = []; - public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $processor) + /** + * The unit currently being executed by this worker, together with the + * bookkeeping needed to harvest its result once the worker reports that it + * has finished. These are set by dispatch() and cleared by tick() (or by a + * detected crash); a null currentUnit means the worker is idle. + */ + private ?WorkUnit $currentUnit = null; + + /** + * @var ?non-empty-string + */ + private ?string $currentNonce = null; + private ?string $currentResultFile = null; + private string $controlChannelBuffer = ''; + + /** + * @var non-negative-int + */ + private readonly int $id; + + /** + * @param non-negative-int $id + */ + public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $processor, int $id = 0) { $this->jobRunner = $jobRunner; $this->processor = $processor; + $this->id = $id; } /** @@ -87,7 +117,17 @@ public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $p */ public function start(): void { - $this->job = $this->jobRunner->start(new Job($this->buildWorkerCode())); + // The worker's identity is exposed to the tests it runs so that test + // fixtures can partition shared resources (a database, a port, a + // temporary directory, ...) per worker and thereby avoid colliding with + // the tests running concurrently in the other workers. + $environmentVariables = [ + 'PHPUNIT_WORKER_ID' => (string) $this->id, + ]; + + $this->job = $this->jobRunner->start( + new Job($this->buildWorkerCode(), [], $environmentVariables), + ); } /** @@ -160,6 +200,130 @@ public function run(TestCase $test): void $this->processor->process($test, $serializedResult, '', $nonce); } + /** + * Send a unit of work to the worker without waiting for it to finish. + * + * The command is written to the worker's control channel and dispatch() + * returns immediately; the caller is expected to multiplex this worker's + * standard output with stream_select() and to call tick() once output + * becomes available, so that a single thread of control can keep several + * workers busy at the same time. + * + * @throws WorkerException + */ + public function dispatch(WorkUnit $unit): void + { + assert($this->job !== null); + assert($this->currentUnit === null); + + $offset = hrtime(); + $nonce = bin2hex(random_bytes(16)); + $resultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($resultFile === false) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to create temporary file for the worker result'); + // @codeCoverageIgnoreEnd + } + + if ($unit instanceof PhptWorkUnit) { + $command = $this->phptCommand($unit, $offset, $resultFile, $nonce); + } else { + assert($unit instanceof TestClassWorkUnit); + + $command = $this->testClassCommand($unit, $offset, $resultFile, $nonce); + } + + $encodedCommand = json_encode($command); + + assert($encodedCommand !== false); + + $this->currentUnit = $unit; + $this->currentNonce = $nonce; + $this->currentResultFile = $resultFile; + $this->controlChannelBuffer = ''; + + $this->job->write($encodedCommand . "\n"); + } + + /** + * Whether the worker is currently executing a dispatched unit. + */ + public function isBusy(): bool + { + return $this->currentUnit !== null; + } + + /** + * Whether the worker process is still usable. A worker that died while + * running a unit is no longer usable and must not be dispatched to again. + */ + public function isAlive(): bool + { + return $this->job !== null; + } + + /** + * The worker's standard output, to be passed to stream_select() so that + * the caller can wait for the worker to report progress without blocking. + * + * @return ?resource + */ + public function controlChannel(): mixed + { + if ($this->job === null) { + return null; + } + + return $this->job->stdout(); + } + + /** + * Consume whatever the worker has written to its control channel since the + * last call and, if the dispatched unit has finished, harvest its result. + * + * Returns null while the unit is still running. Intended to be called after + * stream_select() has reported the stream returned by controlChannel() as + * ready. + */ + public function tick(): ?CompletedWorkUnit + { + assert($this->currentUnit !== null); + assert($this->currentNonce !== null); + + if ($this->job === null) { + // @codeCoverageIgnoreStart + return $this->crashed(); + // @codeCoverageIgnoreEnd + } + + $stdout = $this->job->stdout(); + + if (!is_resource($stdout)) { + // @codeCoverageIgnoreStart + return $this->crashed(); + // @codeCoverageIgnoreEnd + } + + stream_set_blocking($stdout, false); + + $chunk = stream_get_contents($stdout); + + if ($chunk !== false && $chunk !== '') { + $this->controlChannelBuffer .= $chunk; + } + + if (str_contains($this->controlChannelBuffer, self::DONE_PREFIX . $this->currentNonce)) { + return $this->finished(); + } + + if (feof($stdout)) { + return $this->crashed(); + } + + return null; + } + public function stop(): void { if ($this->job !== null) { @@ -184,6 +348,83 @@ public function stop(): void $this->temporaryFiles = []; } + /** + * @param array{0: int, 1: int} $offset + * @param non-empty-string $resultFile + * @param non-empty-string $nonce + * + * @throws WorkerException + * + * @return array + */ + private function testClassCommand(TestClassWorkUnit $unit, array $offset, string $resultFile, string $nonce): array + { + $class = new ReflectionClass($unit->className()); + $file = $class->getFileName(); + + assert($file !== false); + + $tests = []; + + foreach ($unit->tests() as $test) { + try { + $data = base64_encode(serialize($test->providedData())); + $dependencyInput = base64_encode(serialize($test->dependencyInput())); + } catch (Throwable $t) { + @unlink($resultFile); + + throw new WorkerException( + sprintf( + 'The tests of class %s cannot be run in parallel because their data cannot be serialized: %s', + $unit->className(), + $t->getMessage(), + ), + ); + } + + $tests[] = [ + 'methodName' => $test->name(), + 'data' => $data, + 'dataName' => $test->dataName(), + 'dependencyInput' => $dependencyInput, + 'repetition' => $test->repetition(), + 'totalRepetitions' => $test->totalRepetitions(), + 'attempt' => $test->attempt(), + 'maxAttempts' => $test->maxAttempts(), + ]; + } + + return [ + 'command' => 'runUnit', + 'file' => $file, + 'className' => $unit->className(), + 'tests' => $tests, + 'offsetSeconds' => $offset[0], + 'offsetNanoseconds' => $offset[1], + 'resultFile' => $resultFile, + 'nonce' => $nonce, + ]; + } + + /** + * @param array{0: int, 1: int} $offset + * @param non-empty-string $resultFile + * @param non-empty-string $nonce + * + * @return array + */ + private function phptCommand(PhptWorkUnit $unit, array $offset, string $resultFile, string $nonce): array + { + return [ + 'command' => 'runPhpt', + 'file' => $unit->file(), + 'offsetSeconds' => $offset[0], + 'offsetNanoseconds' => $offset[1], + 'resultFile' => $resultFile, + 'nonce' => $nonce, + ]; + } + /** * Read the worker's control channel until it reports that the test * identified by the given nonce has finished. Any unrelated output the @@ -219,6 +460,69 @@ private function awaitResult(string $nonce): bool return false; } + /** + * Harvest the result of the unit the worker has just reported as finished. + */ + private function finished(): CompletedWorkUnit + { + assert($this->currentUnit !== null); + assert($this->currentResultFile !== null); + + $serializedResult = file_get_contents($this->currentResultFile); + + @unlink($this->currentResultFile); + + if ($serializedResult === false) { + // @codeCoverageIgnoreStart + $serializedResult = ''; + // @codeCoverageIgnoreEnd + } + + $completed = new CompletedWorkUnit( + $this->currentUnit, + $serializedResult, + $this->currentNonce, + false, + ); + + $this->clearCurrentUnit(); + + return $completed; + } + + /** + * Record that the worker died while running the dispatched unit and reap + * the dead process so that it is not used again. + */ + private function crashed(): CompletedWorkUnit + { + assert($this->currentUnit !== null); + + if ($this->currentResultFile !== null) { + @unlink($this->currentResultFile); + } + + $completed = new CompletedWorkUnit($this->currentUnit, '', null, true); + + if ($this->job !== null) { + $this->job->wait(); + + $this->job = null; + } + + $this->clearCurrentUnit(); + + return $completed; + } + + private function clearCurrentUnit(): void + { + $this->currentUnit = null; + $this->currentNonce = null; + $this->currentResultFile = null; + $this->controlChannelBuffer = ''; + } + /** * @throws WorkerException * diff --git a/src/Runner/Parallel/PhptWorkUnit.php b/src/Runner/Parallel/PhptWorkUnit.php new file mode 100644 index 0000000000..44067e7b9c --- /dev/null +++ b/src/Runner/Parallel/PhptWorkUnit.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * A unit of work for a single PHPT test, identified by the path of its .phpt + * file. A PHPT test is not a PHPUnit\Framework\TestCase and carries no test + * data, so the worker reconstructs it from nothing more than this file path. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class PhptWorkUnit implements WorkUnit +{ + /** + * @var non-negative-int + */ + private int $index; + private string $file; + + /** + * @param non-negative-int $index + */ + public function __construct(int $index, string $file) + { + $this->index = $index; + $this->file = $file; + } + + /** + * @return non-negative-int + */ + public function index(): int + { + return $this->index; + } + + public function file(): string + { + return $this->file; + } + + public function name(): string + { + return $this->file; + } +} diff --git a/src/Runner/Parallel/ResultAggregator.php b/src/Runner/Parallel/ResultAggregator.php new file mode 100644 index 0000000000..ae65cd3c73 --- /dev/null +++ b/src/Runner/Parallel/ResultAggregator.php @@ -0,0 +1,224 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function hash_equals; +use function property_exists; +use function sprintf; +use function strlen; +use function substr; +use function unserialize; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use stdClass; + +/** + * Collects the results that the workers produce and replays them into the + * parent process' event subsystem. + * + * Workers finish their units out of order, but every printer, logger, and + * report assumes the suite-ordered arrival it sees in sequential mode. The + * aggregator therefore buffers each finished unit and forwards it only once all + * of the units that precede it in suite order have been forwarded; the result + * is an event stream that is byte-for-byte identical to the sequential one, + * which keeps CI diffs stable. + * + * Forwarding a unit means replaying its collected event stream through the + * parent dispatcher, importing the tests it recorded as passed, and merging its + * code coverage. Because every user-facing output is a downstream subscriber of + * these events, the parent's entire output, logging, and result subsystem works + * unchanged. + * + * Some units do not run in a worker but in the main process (see + * ParallelTestRunner): a unit attributed with #[DoNotRunInParallel], one that + * needs process isolation, or one whose data cannot be serialized. Such a unit + * is registered with its suite index and run, by the aggregator, at the moment + * its index comes up in the release sequence. Running it there — between the + * units that precede and follow it in suite order — lets its events reach the + * dispatcher live and still in global suite order, so the ordering guarantee + * holds for every output format, not just the ones that re-sort. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ResultAggregator +{ + private readonly Facade $eventFacade; + private readonly Emitter $emitter; + private readonly PassedTests $passedTests; + private readonly CodeCoverage $codeCoverage; + + /** + * @var array + */ + private array $buffer = []; + + /** + * @var array + */ + private array $inProcessRunners = []; + + /** + * @var non-negative-int + */ + private int $nextIndex = 0; + + public function __construct(Facade $eventFacade, Emitter $emitter, PassedTests $passedTests, CodeCoverage $codeCoverage) + { + $this->eventFacade = $eventFacade; + $this->emitter = $emitter; + $this->passedTests = $passedTests; + $this->codeCoverage = $codeCoverage; + } + + /** + * Register a unit that is to be run, in the main process, at the point its + * suite index comes up in the release sequence. + * + * @param non-negative-int $index + * @param callable():void $runner + */ + public function registerInProcessUnit(int $index, callable $runner): void + { + $this->inProcessRunners[$index] = $runner; + } + + /** + * Accept a finished worker unit and release every unit that has now become + * releasable in suite order. + */ + public function add(CompletedWorkUnit $completed): void + { + $this->buffer[$completed->unit()->index()] = $completed; + + $this->release(); + } + + /** + * Release every unit that is releasable in suite order, running registered + * in-process units in place as their index comes up. Called both after a + * worker finishes and directly by the runner, so that in-process units that + * precede or follow all worker units are run even when no worker completion + * drives the release. + */ + public function flush(): void + { + $this->release(); + } + + private function release(): void + { + while (true) { + if (isset($this->buffer[$this->nextIndex])) { + $this->forward($this->buffer[$this->nextIndex]); + + unset($this->buffer[$this->nextIndex]); + + $this->nextIndex++; + + continue; + } + + if (isset($this->inProcessRunners[$this->nextIndex])) { + $runner = $this->inProcessRunners[$this->nextIndex]; + + unset($this->inProcessRunners[$this->nextIndex]); + + $runner(); + + $this->nextIndex++; + + continue; + } + + break; + } + } + + private function forward(CompletedWorkUnit $completed): void + { + if ($completed->crashed()) { + $message = $completed->message(); + + if ($message === null || $message === '') { + $message = sprintf( + 'The worker process running %s ended unexpectedly', + $completed->unit()->name(), + ); + } + + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning($message); + + return; + } + + $serializedResult = $completed->serializedResult(); + $nonce = $completed->nonce(); + + if ($nonce !== null && $serializedResult !== '') { + $nonceLength = strlen($nonce); + + if (strlen($serializedResult) < $nonceLength || + !hash_equals($nonce, substr($serializedResult, 0, $nonceLength))) { + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning( + sprintf( + 'The result of the worker process running %s was tampered with or written by an unexpected process', + $completed->unit()->name(), + ), + ); + + return; + } + + $serializedResult = substr($serializedResult, $nonceLength); + } + + $childResult = @unserialize($serializedResult); + + if (!$childResult instanceof stdClass || + !property_exists($childResult, 'events') || + !property_exists($childResult, 'passedTests') || + !$childResult->events instanceof EventCollection || + !$childResult->passedTests instanceof PassedTests) { + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning( + sprintf( + 'The worker process running %s ended unexpectedly', + $completed->unit()->name(), + ), + ); + + return; + } + + $this->eventFacade->forward($childResult->events); + $this->passedTests->import($childResult->passedTests); + + if (!$this->codeCoverage->isActive()) { + return; + } + + // @codeCoverageIgnoreStart + if (!isset($childResult->codeCoverage) || !$childResult->codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { + return; + } + + CodeCoverage::instance()->codeCoverage()->merge( + $childResult->codeCoverage, + ); + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Runner/Parallel/TestClassWorkUnit.php b/src/Runner/Parallel/TestClassWorkUnit.php new file mode 100644 index 0000000000..4e5fb1164d --- /dev/null +++ b/src/Runner/Parallel/TestClassWorkUnit.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\TestCase; + +/** + * A unit of work that bundles all of the selected tests of a single test class, + * kept together so that the class' shared fixtures (#[BeforeClass] / + * #[AfterClass]) and intra-class ordering are preserved when the unit is run by + * a single worker. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class TestClassWorkUnit implements WorkUnit +{ + /** + * @var non-negative-int + */ + private int $index; + + /** + * @var class-string + */ + private string $className; + + /** + * @var list + */ + private array $tests; + + /** + * @param non-negative-int $index + * @param class-string $className + * @param list $tests + */ + public function __construct(int $index, string $className, array $tests) + { + $this->index = $index; + $this->className = $className; + $this->tests = $tests; + } + + /** + * @return non-negative-int + */ + public function index(): int + { + return $this->index; + } + + /** + * @return class-string + */ + public function className(): string + { + return $this->className; + } + + /** + * @return list + */ + public function tests(): array + { + return $this->tests; + } + + public function name(): string + { + return $this->className; + } +} diff --git a/src/Runner/Parallel/WorkUnit.php b/src/Runner/Parallel/WorkUnit.php new file mode 100644 index 0000000000..d46cbd93ed --- /dev/null +++ b/src/Runner/Parallel/WorkUnit.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * A unit of work distributed to a worker process. + * + * The index records the unit's position in the deterministic suite order; the + * ResultAggregator uses it to release the units' results in that order even + * though the workers finish them out of order. The name is a human-readable + * label used when a unit has to be reported on, for instance after a crash. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +interface WorkUnit +{ + /** + * @return non-negative-int + */ + public function index(): int; + + public function name(): string; +} diff --git a/src/Runner/Parallel/WorkerPool.php b/src/Runner/Parallel/WorkerPool.php new file mode 100644 index 0000000000..b60c69b98c --- /dev/null +++ b/src/Runner/Parallel/WorkerPool.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_shift; +use function is_resource; +use function stream_select; + +/** + * A fixed-size pool of PersistentWorkers across which units of work are + * distributed. + * + * Distribution is a dynamic work-stealing queue rather than a static + * pre-partitioning of the units: whenever a worker becomes idle it pulls the + * next unit from the queue, which self-balances the load against stragglers. + * + * A single thread of control keeps all of the workers busy by multiplexing + * their control channels with stream_select(): it blocks until at least one + * worker has reported progress, harvests every worker that is ready, hands the + * finished units to the caller-supplied callback, and tops the idle workers up + * with more work until the queue is drained. + * + * If a worker dies, the unit it was running is reported to the callback as a + * crashed unit and the dead worker is dropped from the pool; the remaining + * units are redistributed across the surviving workers. Should every worker + * die, the units that were never started are likewise reported as crashed so + * that the caller can account for all of them. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class WorkerPool +{ + /** + * @var non-empty-list + */ + private readonly array $workers; + + /** + * @param non-empty-list $workers + */ + public function __construct(array $workers) + { + $this->workers = $workers; + } + + /** + * @throws WorkerException + */ + public function start(): void + { + foreach ($this->workers as $worker) { + $worker->start(); + } + } + + /** + * Run all of the given units across the pool, invoking the callback once + * for each unit as it finishes (in completion order, not suite order). + * + * @param list $units + * @param callable(CompletedWorkUnit):void $onCompleted + */ + public function run(array $units, callable $onCompleted): void + { + $queue = $units; + + while (true) { + $this->dispatchTo($queue, $onCompleted); + + $busy = $this->busyWorkers(); + + if ($busy === []) { + break; + } + + $readable = $this->awaitReadable($busy); + + foreach ($readable as $worker) { + $completed = $worker->tick(); + + if ($completed !== null) { + $onCompleted($completed); + } + } + } + + // Every worker has died while units remain to be run: account for the + // abandoned units so that the caller does not silently lose them. + foreach ($queue as $unit) { + $onCompleted(new CompletedWorkUnit($unit, '', null, true)); + } + } + + public function stop(): void + { + foreach ($this->workers as $worker) { + $worker->stop(); + } + } + + /** + * Hand the next queued units to the idle, alive workers. + * + * A unit that cannot be dispatched — for instance because its test data + * cannot be serialized for transport to a worker — is reported to the + * callback as a crashed unit and skipped, so that one undispatchable unit + * does not abort the entire run or starve an otherwise idle worker. + * + * @param list $queue + * @param callable(CompletedWorkUnit):void $onCompleted + */ + private function dispatchTo(array &$queue, callable $onCompleted): void + { + foreach ($this->workers as $worker) { + if (!$worker->isAlive() || $worker->isBusy()) { + continue; + } + + while ($queue !== []) { + $unit = array_shift($queue); + + try { + $worker->dispatch($unit); + + break; + } catch (WorkerException $e) { + $onCompleted(new CompletedWorkUnit($unit, '', null, true, $e->getMessage())); + } + } + } + } + + /** + * @return list + */ + private function busyWorkers(): array + { + $busy = []; + + foreach ($this->workers as $worker) { + if ($worker->isAlive() && $worker->isBusy()) { + $busy[] = $worker; + } + } + + return $busy; + } + + /** + * Block until at least one of the busy workers has produced output on its + * control channel, then return those workers. + * + * @param non-empty-list $busy + * + * @return list + */ + private function awaitReadable(array $busy): array + { + $streams = []; + $byId = []; + + foreach ($busy as $worker) { + $stream = $worker->controlChannel(); + + if (!is_resource($stream)) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } + + $streams[] = $stream; + $byId[] = $worker; + } + + if ($streams === []) { + // @codeCoverageIgnoreStart + return $busy; + // @codeCoverageIgnoreEnd + } + + $write = null; + $except = null; + + $ready = @stream_select($streams, $write, $except, 1); + + if ($ready === false || $ready === 0) { + // @codeCoverageIgnoreStart + // A signal interrupted the wait, or it timed out: re-examine every + // busy worker on the next iteration rather than risk missing one. + return $busy; + // @codeCoverageIgnoreEnd + } + + $readable = []; + + foreach ($streams as $stream) { + foreach ($byId as $index => $worker) { + if ($worker->controlChannel() === $stream) { + $readable[] = $worker; + + unset($byId[$index]); + + break; + } + } + } + + return $readable; + } +} diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl index a069af7b62..d1a78ccdff 100644 --- a/src/Runner/Parallel/templates/worker.tpl +++ b/src/Runner/Parallel/templates/worker.tpl @@ -2,11 +2,13 @@ use PHPUnit\Event\Facade; use PHPUnit\Framework\TestRunner\ChildProcessOutputCollector; use PHPUnit\Framework\TestRunner\ErrorHandlerBootstrapper; +use PHPUnit\Framework\TestSuite; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\Configuration\PhpHandler; use PHPUnit\TextUI\Configuration\SourceMapper; +use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\PassedTests; use PHPUnit\Util\DifferBuilder; @@ -62,6 +64,15 @@ if ({collectCodeCoverageInformation}) { ErrorHandlerBootstrapper::bootstrap($__phpunit_configuration); +// A unit of work is run as a TestSuite, whose run loop consults the test +// result facade to decide whether to stop. The facade lazily registers its +// collector as an event subscriber on first use, which is not possible once +// the event facade has been sealed for isolation. The collector is therefore +// created here, while the facade is still open; it never receives events in +// the worker (the parent process owns the authoritative test result), so the +// worker never decides to stop on its own. +TestResultFacade::init(); + ob_end_clean(); function __phpunit_worker_run_test(object $command): string @@ -124,6 +135,108 @@ function __phpunit_worker_run_test(object $command): string return $result; } +function __phpunit_worker_run_unit(object $command): string +{ + $dispatcher = Facade::instance()->initForIsolation( + PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $command->offsetSeconds, + $command->offsetNanoseconds + ), + ); + + require_once $command->file; + + $suite = TestSuite::empty($command->className); + + foreach ($command->tests as $__phpunit_test) { + $className = $command->className; + $methodName = $__phpunit_test->methodName; + + $test = new $className($methodName); + + $test->setData($__phpunit_test->dataName, unserialize(base64_decode($__phpunit_test->data))); + $test->setDependencyInput(unserialize(base64_decode($__phpunit_test->dependencyInput))); + $test->setRepetition($__phpunit_test->repetition, $__phpunit_test->totalRepetitions); + $test->setAttempt($__phpunit_test->attempt, $__phpunit_test->maxAttempts); + + $suite->addTest($test); + } + + $suite->run(); + + $codeCoverage = null; + + if (CodeCoverage::instance()->isActive()) { + $codeCoverage = CodeCoverage::instance()->codeCoverage(); + } + + $result = $command->nonce . serialize( + (object) [ + 'codeCoverage' => $codeCoverage, + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance(), + ] + ); + + // Per-unit code coverage has been collected for this command and is about + // to be shipped to the parent process. It is cleared here so that the next + // unit executed by this worker does not ship it a second time. + if (CodeCoverage::instance()->isActive()) { + CodeCoverage::instance()->codeCoverage()->clear(); + } + + // Reset the stream that captures test output so that the next unit does + // not inherit the output of the unit that has just finished. + if (@rewind(STDOUT)) { + @ftruncate(STDOUT, 0); + } + + return $result; +} + +function __phpunit_worker_run_phpt(object $command): string +{ + $dispatcher = Facade::instance()->initForIsolation( + PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $command->offsetSeconds, + $command->offsetNanoseconds + ), + ); + + $test = new PHPUnit\Runner\Phpt\TestCase($command->file); + + $test->run(); + + $codeCoverage = null; + + if (CodeCoverage::instance()->isActive()) { + $codeCoverage = CodeCoverage::instance()->codeCoverage(); + } + + $result = $command->nonce . serialize( + (object) [ + 'codeCoverage' => $codeCoverage, + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance(), + ] + ); + + // Per-test code coverage has been collected for this command and is about + // to be shipped to the parent process. It is cleared here so that the next + // test executed by this worker does not ship it a second time. + if (CodeCoverage::instance()->isActive()) { + CodeCoverage::instance()->codeCoverage()->clear(); + } + + // Reset the stream that captures test output so that the next test does + // not inherit the output of the test that has just finished. + if (@rewind(STDOUT)) { + @ftruncate(STDOUT, 0); + } + + return $result; +} + $__phpunit_input = fopen('php://stdin', 'rb'); while (($__phpunit_line = fgets($__phpunit_input)) !== false) { @@ -139,7 +252,13 @@ while (($__phpunit_line = fgets($__phpunit_input)) !== false) { break; } - $__phpunit_result = __phpunit_worker_run_test($__phpunit_command); + if ($__phpunit_command->command === 'runUnit') { + $__phpunit_result = __phpunit_worker_run_unit($__phpunit_command); + } else if ($__phpunit_command->command === 'runPhpt') { + $__phpunit_result = __phpunit_worker_run_phpt($__phpunit_command); + } else { + $__phpunit_result = __phpunit_worker_run_test($__phpunit_command); + } file_put_contents($__phpunit_command->resultFile, $__phpunit_result); diff --git a/src/TextUI/Application.php b/src/TextUI/Application.php index 7c5611bccc..b5a2ac0dae 100644 --- a/src/TextUI/Application.php +++ b/src/TextUI/Application.php @@ -252,13 +252,19 @@ public function run(array $argv): int if ($coverageInitializationStatus === CodeCoverageInitializationStatus::NOT_REQUESTED || $coverageInitializationStatus === CodeCoverageInitializationStatus::SUCCEEDED) { - $runner = new TestRunner; - - $runner->run( - $configuration, - $resultCache, - $testSuite, - ); + if ($configuration->numberOfParallelWorkers() > 1) { + new ParallelTestRunner()->run( + $configuration, + $resultCache, + $testSuite, + ); + } else { + new TestRunner()->run( + $configuration, + $resultCache, + $testSuite, + ); + } } $duration = $timer->stop(); diff --git a/src/TextUI/Configuration/Cli/Builder.php b/src/TextUI/Configuration/Cli/Builder.php index 75506f032c..be6822338e 100644 --- a/src/TextUI/Configuration/Cli/Builder.php +++ b/src/TextUI/Configuration/Cli/Builder.php @@ -113,6 +113,7 @@ final class Builder 'no-results', 'order-by=', 'process-isolation', + 'parallel=', 'do-not-report-useless-tests', 'random-order', 'random-order-seed=', @@ -365,6 +366,7 @@ public function fromParameters(array $parameters): Configuration $noResults = null; $noLogging = null; $processIsolation = null; + $numberOfParallelWorkers = null; $randomOrderSeed = null; $repeat = null; $retry = null; @@ -839,6 +841,25 @@ public function fromParameters(array $parameters): Configuration break; + case '--parallel': + if (!is_numeric($option[1]) || + (string) (int) $option[1] !== $option[1] || + (int) $option[1] < 1) { + EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + sprintf( + 'Option "--parallel %s" ignored because "%s" is not a positive integer', + $option[1], + $option[1], + ), + ); + + break; + } + + $numberOfParallelWorkers = (int) $option[1]; + + break; + case '--stderr': $stderr = true; @@ -1487,6 +1508,7 @@ public function fromParameters(array $parameters): Configuration $debug, $withTelemetry, $extensions, + $numberOfParallelWorkers, ); } diff --git a/src/TextUI/Configuration/Cli/Configuration.php b/src/TextUI/Configuration/Cli/Configuration.php index 9f7a0fcead..ca0769cd3c 100644 --- a/src/TextUI/Configuration/Cli/Configuration.php +++ b/src/TextUI/Configuration/Cli/Configuration.php @@ -208,6 +208,11 @@ */ private ?array $extensions; + /** + * @var ?positive-int + */ + private ?int $numberOfParallelWorkers; + /** * @param list $arguments * @param ?positive-int $diffContext @@ -222,8 +227,9 @@ * @param ?non-empty-list $testSuffixes * @param ?non-empty-list $coverageFilter * @param ?non-empty-list $extensions + * @param ?positive-int $numberOfParallelWorkers */ - public function __construct(array $arguments, ?string $testFilesFile, ?string $testIdFile, ?string $testIdFilter, ?bool $all, ?string $atLeastVersion, ?bool $backupGlobals, ?bool $backupStaticProperties, ?bool $beStrictAboutChangesToGlobalState, ?string $bootstrap, ?string $cacheDirectory, ?bool $cacheResult, bool $checkPhpConfiguration, bool $checkVersion, ?string $colors, null|int|string $columns, ?string $configurationFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4J, ?string $coverageHtml, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, ?bool $coverageTextShowUncoveredFiles, ?bool $coverageTextShowOnlySummary, ?string $coverageXml, ?bool $coverageXmlIncludeSource, ?bool $pathCoverage, ?bool $branchCoverage, bool $warmCoverageCache, ?int $defaultTimeLimit, ?int $diffContext, ?bool $disableCodeCoverageIgnore, ?bool $disableCoverageTargeting, ?bool $disallowTestOutput, ?bool $enforceTimeLimit, ?array $excludeGroups, ?int $executionOrder, ?int $executionOrderDefects, ?bool $failOnAllIssues, ?bool $failOnDeprecation, ?bool $failOnPhpunitDeprecation, ?bool $failOnPhpunitNotice, ?bool $failOnPhpunitWarning, ?bool $failOnEmptyTestSuite, ?bool $failOnIncomplete, ?bool $failOnNotice, ?bool $failOnRisky, ?bool $failOnSkipped, ?bool $failOnWarning, ?bool $doNotFailOnDeprecation, ?bool $doNotFailOnPhpunitDeprecation, ?bool $doNotFailOnPhpunitNotice, ?bool $doNotFailOnPhpunitWarning, ?bool $doNotFailOnEmptyTestSuite, ?bool $doNotFailOnIncomplete, ?bool $doNotFailOnNotice, ?bool $doNotFailOnRisky, ?bool $doNotFailOnSkipped, ?bool $doNotFailOnWarning, ?int $stopOnDefect, ?int $stopOnDeprecation, ?string $specificDeprecationToStopOn, ?int $stopOnError, ?int $stopOnFailure, ?int $stopOnIncomplete, ?int $stopOnNotice, ?int $stopOnRisky, ?int $stopOnSkipped, ?int $stopOnWarning, ?string $filter, ?string $excludeFilter, ?string $generateBaseline, ?string $useBaseline, bool $ignoreBaseline, bool $generateConfiguration, bool $migrateConfiguration, bool $validateConfiguration, ?array $groups, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, bool $help, ?string $includePath, ?array $iniSettings, ?string $junitLogfile, ?string $otrLogfile, ?bool $includeGitInformation, bool $listGroups, bool $listSuites, bool $listTestFiles, bool $listTestIds, bool $listTests, ?string $listTestsXml, ?bool $noCoverage, ?bool $noExtensions, ?bool $noOutput, ?bool $noProgress, ?bool $noResults, ?bool $noLogging, ?bool $processIsolation, ?int $randomOrderSeed, ?int $repeat, ?int $retry, ?bool $reportUselessTests, ?bool $resolveDependencies, ?bool $reverseList, ?bool $stderr, ?bool $strictCoverage, ?bool $requireCoverageContribution, ?string $teamcityLogfile, ?string $testdoxHtmlFile, ?string $testdoxTextFile, ?array $testSuffixes, ?string $testSuite, ?string $excludeTestSuite, bool $useDefaultConfiguration, ?bool $displayDetailsOnAllIssues, ?bool $displayDetailsOnIncompleteTests, ?bool $displayDetailsOnSkippedTests, ?bool $displayDetailsOnTestsThatTriggerDeprecations, ?bool $displayDetailsOnPhpunitDeprecations, ?bool $displayDetailsOnPhpunitNotices, ?bool $displayDetailsOnTestsThatTriggerErrors, ?bool $displayDetailsOnTestsThatTriggerNotices, ?bool $displayDetailsOnTestsThatTriggerWarnings, bool $version, ?array $coverageFilter, ?string $logEventsText, ?string $logEventsVerboseText, ?bool $printerCompact, ?bool $printerTeamCity, ?bool $testdoxPrinter, ?bool $testdoxPrinterSummary, bool $debug, bool $withTelemetry, ?array $extensions) + public function __construct(array $arguments, ?string $testFilesFile, ?string $testIdFile, ?string $testIdFilter, ?bool $all, ?string $atLeastVersion, ?bool $backupGlobals, ?bool $backupStaticProperties, ?bool $beStrictAboutChangesToGlobalState, ?string $bootstrap, ?string $cacheDirectory, ?bool $cacheResult, bool $checkPhpConfiguration, bool $checkVersion, ?string $colors, null|int|string $columns, ?string $configurationFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4J, ?string $coverageHtml, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, ?bool $coverageTextShowUncoveredFiles, ?bool $coverageTextShowOnlySummary, ?string $coverageXml, ?bool $coverageXmlIncludeSource, ?bool $pathCoverage, ?bool $branchCoverage, bool $warmCoverageCache, ?int $defaultTimeLimit, ?int $diffContext, ?bool $disableCodeCoverageIgnore, ?bool $disableCoverageTargeting, ?bool $disallowTestOutput, ?bool $enforceTimeLimit, ?array $excludeGroups, ?int $executionOrder, ?int $executionOrderDefects, ?bool $failOnAllIssues, ?bool $failOnDeprecation, ?bool $failOnPhpunitDeprecation, ?bool $failOnPhpunitNotice, ?bool $failOnPhpunitWarning, ?bool $failOnEmptyTestSuite, ?bool $failOnIncomplete, ?bool $failOnNotice, ?bool $failOnRisky, ?bool $failOnSkipped, ?bool $failOnWarning, ?bool $doNotFailOnDeprecation, ?bool $doNotFailOnPhpunitDeprecation, ?bool $doNotFailOnPhpunitNotice, ?bool $doNotFailOnPhpunitWarning, ?bool $doNotFailOnEmptyTestSuite, ?bool $doNotFailOnIncomplete, ?bool $doNotFailOnNotice, ?bool $doNotFailOnRisky, ?bool $doNotFailOnSkipped, ?bool $doNotFailOnWarning, ?int $stopOnDefect, ?int $stopOnDeprecation, ?string $specificDeprecationToStopOn, ?int $stopOnError, ?int $stopOnFailure, ?int $stopOnIncomplete, ?int $stopOnNotice, ?int $stopOnRisky, ?int $stopOnSkipped, ?int $stopOnWarning, ?string $filter, ?string $excludeFilter, ?string $generateBaseline, ?string $useBaseline, bool $ignoreBaseline, bool $generateConfiguration, bool $migrateConfiguration, bool $validateConfiguration, ?array $groups, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, bool $help, ?string $includePath, ?array $iniSettings, ?string $junitLogfile, ?string $otrLogfile, ?bool $includeGitInformation, bool $listGroups, bool $listSuites, bool $listTestFiles, bool $listTestIds, bool $listTests, ?string $listTestsXml, ?bool $noCoverage, ?bool $noExtensions, ?bool $noOutput, ?bool $noProgress, ?bool $noResults, ?bool $noLogging, ?bool $processIsolation, ?int $randomOrderSeed, ?int $repeat, ?int $retry, ?bool $reportUselessTests, ?bool $resolveDependencies, ?bool $reverseList, ?bool $stderr, ?bool $strictCoverage, ?bool $requireCoverageContribution, ?string $teamcityLogfile, ?string $testdoxHtmlFile, ?string $testdoxTextFile, ?array $testSuffixes, ?string $testSuite, ?string $excludeTestSuite, bool $useDefaultConfiguration, ?bool $displayDetailsOnAllIssues, ?bool $displayDetailsOnIncompleteTests, ?bool $displayDetailsOnSkippedTests, ?bool $displayDetailsOnTestsThatTriggerDeprecations, ?bool $displayDetailsOnPhpunitDeprecations, ?bool $displayDetailsOnPhpunitNotices, ?bool $displayDetailsOnTestsThatTriggerErrors, ?bool $displayDetailsOnTestsThatTriggerNotices, ?bool $displayDetailsOnTestsThatTriggerWarnings, bool $version, ?array $coverageFilter, ?string $logEventsText, ?string $logEventsVerboseText, ?bool $printerCompact, ?bool $printerTeamCity, ?bool $testdoxPrinter, ?bool $testdoxPrinterSummary, bool $debug, bool $withTelemetry, ?array $extensions, ?int $numberOfParallelWorkers = null) { $this->arguments = $arguments; $this->testFilesFile = $testFilesFile; @@ -363,6 +369,7 @@ public function __construct(array $arguments, ?string $testFilesFile, ?string $t $this->debug = $debug; $this->withTelemetry = $withTelemetry; $this->extensions = $extensions; + $this->numberOfParallelWorkers = $numberOfParallelWorkers; } /** @@ -2881,4 +2888,26 @@ public function extensions(): array return $this->extensions; } + + /** + * @phpstan-assert-if-true !null $this->numberOfParallelWorkers + */ + public function hasNumberOfParallelWorkers(): bool + { + return $this->numberOfParallelWorkers !== null; + } + + /** + * @throws Exception + * + * @return positive-int + */ + public function numberOfParallelWorkers(): int + { + if (!$this->hasNumberOfParallelWorkers()) { + throw new Exception; + } + + return $this->numberOfParallelWorkers; + } } diff --git a/src/TextUI/Configuration/Configuration.php b/src/TextUI/Configuration/Configuration.php index 74e39129bc..d0d4e75a37 100644 --- a/src/TextUI/Configuration/Configuration.php +++ b/src/TextUI/Configuration/Configuration.php @@ -478,6 +478,11 @@ */ private int $shortenArraysForExportThreshold; + /** + * @var positive-int + */ + private int $numberOfParallelWorkers; + /** * @param list $cliArguments * @param ?non-empty-string $testFilesFile @@ -553,8 +558,9 @@ * @param positive-int $numberOfTestsBeforeGarbageCollection * @param null|non-empty-string $generateBaseline * @param non-negative-int $shortenArraysForExportThreshold + * @param positive-int $numberOfParallelWorkers */ - public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, ?string $coverageDriver, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold) + public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, ?string $coverageDriver, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold, int $numberOfParallelWorkers) { $this->cliArguments = $cliArguments; $this->testFilesFile = $testFilesFile; @@ -713,6 +719,7 @@ public function __construct(array $cliArguments, ?string $testFilesFile, ?string $this->debug = $debug; $this->withTelemetry = $withTelemetry; $this->shortenArraysForExportThreshold = $shortenArraysForExportThreshold; + $this->numberOfParallelWorkers = $numberOfParallelWorkers; } /** @@ -2352,4 +2359,12 @@ public function shortenArraysForExportThreshold(): int { return $this->shortenArraysForExportThreshold; } + + /** + * @return positive-int + */ + public function numberOfParallelWorkers(): int + { + return $this->numberOfParallelWorkers; + } } diff --git a/src/TextUI/Configuration/Merger.php b/src/TextUI/Configuration/Merger.php index bbb0f308a9..6bd645a2f0 100644 --- a/src/TextUI/Configuration/Merger.php +++ b/src/TextUI/Configuration/Merger.php @@ -930,6 +930,12 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $retry = $cliConfiguration->retry(); } + $numberOfParallelWorkers = 1; + + if ($cliConfiguration->hasNumberOfParallelWorkers()) { + $numberOfParallelWorkers = $cliConfiguration->numberOfParallelWorkers(); + } + if ($xmlConfiguration->wasLoadedFromFile() && $xmlConfiguration->hasValidationErrors()) { if ((new SchemaDetector)->detect($xmlConfiguration->filename())->detected()) { EventFacade::emitter()->testRunnerTriggeredPhpunitDeprecation( @@ -1330,6 +1336,7 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $cliConfiguration->debug(), $cliConfiguration->withTelemetry(), $xmlConfiguration->phpunit()->shortenArraysForExportThreshold(), + $numberOfParallelWorkers, ); } diff --git a/src/TextUI/Help.php b/src/TextUI/Help.php index 6ac5d2458a..da39ff5233 100644 --- a/src/TextUI/Help.php +++ b/src/TextUI/Help.php @@ -214,6 +214,7 @@ private function elements(): array 'Execution' => [ ['arg' => '--process-isolation', 'desc' => 'Run each test in a separate PHP process'], + ['arg' => '--parallel ', 'desc' => 'Run test classes in parallel using worker processes'], ['arg' => '--globals-backup', 'desc' => 'Backup and restore $GLOBALS for each test'], ['arg' => '--static-backup', 'desc' => 'Backup and restore static properties for each test'], ['spacer' => ''], diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php new file mode 100644 index 0000000000..51c4e1e6eb --- /dev/null +++ b/src/TextUI/ParallelTestRunner.php @@ -0,0 +1,537 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TextUI; + +use function file; +use function get_parent_class; +use function gettype; +use function is_array; +use function is_object; +use function is_resource; +use function mt_srand; +use function preg_match; +use function serialize; +use function spl_object_id; +use PHPUnit\Event; +use PHPUnit\Framework\Test; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Framework\TestSuite; +use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\Runner\Parallel\CompletedWorkUnit; +use PHPUnit\Runner\Parallel\PersistentWorker; +use PHPUnit\Runner\Parallel\PhptWorkUnit; +use PHPUnit\Runner\Parallel\ResultAggregator; +use PHPUnit\Runner\Parallel\TestClassWorkUnit; +use PHPUnit\Runner\Parallel\WorkerException; +use PHPUnit\Runner\Parallel\WorkerPool; +use PHPUnit\Runner\Parallel\WorkUnit; +use PHPUnit\Runner\Phpt\TestCase as PhptTestCase; +use PHPUnit\Runner\ResultCache\ResultCache; +use PHPUnit\Runner\TestSuiteSorter; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\TextUI\Configuration\Configuration; +use PHPUnit\Util\PHP\JobRunner; +use Throwable; + +/** + * Runs a test suite by distributing its test classes across a pool of worker + * processes that execute them concurrently. + * + * The distribution unit is one test class: all of the selected tests of a class + * are run together by a single worker, which preserves the class' shared + * fixtures (#[BeforeClass] / #[AfterClass]) and its intra-class ordering. + * + * Apart from the parallel execution itself, this runner is a drop-in + * replacement for the sequential TestRunner: it performs the same test suite + * sorting and filtering and emits the same test runner lifecycle events, so the + * parent process' output, logging, and result subsystem is unaffected. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ParallelTestRunner +{ + /** + * @throws RuntimeException + */ + public function run(Configuration $configuration, ResultCache $resultCache, TestSuite $suite): void + { + try { + Event\Facade::emitter()->testRunnerStarted(); + + if ($configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) { + mt_srand($configuration->randomOrderSeed()); + } + + if ($configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT || + $configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT || + $configuration->resolveDependencies()) { + $resultCache->load(); + + new TestSuiteSorter($resultCache)->reorderTestsInSuite( + $suite, + $configuration->executionOrder(), + $configuration->resolveDependencies(), + $configuration->executionOrderDefects(), + ); + + Event\Facade::emitter()->testSuiteSorted( + $configuration->executionOrder(), + $configuration->executionOrderDefects(), + $configuration->resolveDependencies(), + ); + } + + (new TestSuiteFilterProcessor)->process($configuration, $suite); + + Event\Facade::emitter()->testRunnerExecutionStarted( + Event\TestSuite\TestSuiteBuilder::from($suite), + ); + + $collected = $this->collectUnits($suite); + + if ($collected['units'] !== [] || $collected['standalone'] !== []) { + $this->execute($configuration, $collected['units'], $collected['standalone']); + } + + Event\Facade::emitter()->testRunnerExecutionFinished(); + Event\Facade::emitter()->testRunnerFinished(); + } catch (Throwable $t) { + throw new RuntimeException( + $t->getMessage(), + (int) $t->getCode(), + $t, + ); + } + } + + /** + * Units are run in two phases. + * + * The parallel phase distributes across the worker pool every unit whose + * tests may run in a worker process: those that are neither attributed with + * #[DoNotRunInParallel] nor carry test data that cannot be serialized for + * transport to a worker. + * + * The in-process phase then runs the remaining units one at a time in the + * main PHPUnit process, after the parallel phase has finished so that they + * never overlap a worker. A unit runs here when it is attributed with + * #[DoNotRunInParallel] (the author has declared that its tests must not run + * alongside others, for instance because they share a process-global + * resource), when it is configured to run in a separate process (an + * isolation that a shared worker cannot provide but the main process can), + * or when its test data cannot be serialized for transport to a worker (in + * which case the main process is the only place it can run at all). Running + * in the main process is ordinary execution that behaves exactly as it would + * in sequential mode. + * + * Standalone tests that are not PHPUnit\Framework\TestCase instances — most + * commonly PHPT tests — cannot be reconstructed in a worker and are likewise + * run in the main process, each at its own suite index. + * + * @param list $units + * @param list $standalone + * + * @throws WorkerException + */ + private function execute(Configuration $configuration, array $units, array $standalone): void + { + $aggregator = new ResultAggregator( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + $parallel = []; + + $processIsolation = $configuration->processIsolation(); + + foreach ($units as $unit) { + if ($unit instanceof TestClassWorkUnit && + ($processIsolation || + $this->mustNotRunInParallel($unit) || + $this->requiresProcessIsolation($unit) || + !$this->canBeSerialized($unit))) { + // The unit keeps its global suite index; the aggregator runs it + // in the main process at the moment that index comes up in the + // release sequence, which keeps the output in global suite order. + $aggregator->registerInProcessUnit( + $unit->index(), + function () use ($unit): void + { + $this->runInProcess($unit); + }, + ); + + continue; + } + + $parallel[] = $unit; + } + + foreach ($standalone as $item) { + $test = $item['test']; + + $aggregator->registerInProcessUnit( + $item['index'], + static function () use ($test): void + { + $test->run(); + }, + ); + } + + // Run any in-process units that precede the first worker unit, then the + // worker units (whose completions drive the release of the in-process + // units interspersed among them), then any that follow the last one. + $aggregator->flush(); + + if ($parallel !== []) { + $this->runInParallel($parallel, $aggregator, $configuration->numberOfParallelWorkers()); + } + + $aggregator->flush(); + } + + /** + * @param non-empty-list $units + * @param positive-int $numberOfWorkers + * + * @throws WorkerException + */ + private function runInParallel(array $units, ResultAggregator $aggregator, int $numberOfWorkers): void + { + $pool = $this->createPool($numberOfWorkers); + + $pool->start(); + + try { + $pool->run( + $units, + static function (CompletedWorkUnit $completed) use ($aggregator): void + { + $aggregator->add($completed); + }, + ); + } finally { + $pool->stop(); + } + } + + /** + * Run all of a unit's tests in the main PHPUnit process, exactly as the + * sequential test runner would: the class is reassembled into a test suite + * and run, which preserves its shared fixtures and intra-class ordering and + * lets its events reach the parent's output and result subsystem directly. + */ + private function runInProcess(TestClassWorkUnit $unit): void + { + $suite = TestSuite::empty($unit->className()); + + foreach ($unit->tests() as $test) { + $suite->addTest($test); + } + + $suite->run(); + } + + /** + * Whether every test of the unit carries data that survives serialization + * for transport to a worker process. A unit that does not must be run in + * the main process instead. + * + * Two kinds of data do not survive: data that cannot be serialized at all + * (a closure, for example), which makes serialize() throw; and a resource, + * which serialize() silently turns into the integer 0 rather than rejecting + * — a test would then receive 0 in place of its resource and fail in a way + * that has nothing to do with the code under test. + */ + private function canBeSerialized(TestClassWorkUnit $unit): bool + { + foreach ($unit->tests() as $test) { + try { + serialize($test->providedData()); + serialize($test->dependencyInput()); + } catch (Throwable) { + return false; + } + + if ($this->containsResource($test->providedData()) || + $this->containsResource($test->dependencyInput())) { + return false; + } + } + + return true; + } + + /** + * @param array $seen + */ + private function containsResource(mixed $value, array &$seen = []): bool + { + if (is_resource($value)) { + return true; + } + + // is_resource() returns false for a resource that has already been + // closed, yet serialize() degrades it to 0 just the same, so a closed + // resource must be recognized here too. + if (gettype($value) === 'resource (closed)') { + return true; + } + + if (is_array($value)) { + foreach ($value as $item) { + if ($this->containsResource($item, $seen)) { + return true; + } + } + + return false; + } + + if (is_object($value)) { + $id = spl_object_id($value); + + if (isset($seen[$id])) { + return false; + } + + $seen[$id] = $value; + + foreach ((array) $value as $item) { + if ($this->containsResource($item, $seen)) { + return true; + } + } + } + + return false; + } + + /** + * A work unit is the whole of a test class, so a single test method that is + * attributed with #[DoNotRunInParallel] excludes its entire class from the + * parallel phase. + */ + private function mustNotRunInParallel(TestClassWorkUnit $unit): bool + { + $className = $unit->className(); + + $class = $className; + + do { + if (MetadataRegistry::parser()->forClass($class)->isDoNotRunInParallel()->isNotEmpty()) { + return true; + } + } while (($class = get_parent_class($class)) !== false); + + foreach ($unit->tests() as $test) { + if (MetadataRegistry::parser()->forMethod($className, $test->name())->isDoNotRunInParallel()->isNotEmpty()) { + return true; + } + } + + return false; + } + + /** + * Whether any of the unit's tests is configured to run in a separate + * process. Such a test relies on process isolation that a shared worker + * cannot provide; it is therefore run in the main process, where the test + * runner spawns the isolated child process for it as usual. + */ + private function requiresProcessIsolation(TestClassWorkUnit $unit): bool + { + $className = $unit->className(); + + $class = $className; + + do { + if (MetadataRegistry::parser()->forClass($class)->isRunTestsInSeparateProcesses()->isNotEmpty()) { + return true; + } + } while (($class = get_parent_class($class)) !== false); + + foreach ($unit->tests() as $test) { + if (MetadataRegistry::parser()->forMethod($className, $test->name())->isRunInSeparateProcess()->isNotEmpty()) { + return true; + } + } + + return false; + } + + /** + * @param positive-int $numberOfWorkers + */ + private function createPool(int $numberOfWorkers): WorkerPool + { + $processor = new ChildProcessResultProcessor( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + $jobRunner = new JobRunner($processor); + + $workers = [new PersistentWorker($jobRunner, $processor, 0)]; + + for ($id = 1; $id < $numberOfWorkers; $id++) { + $workers[] = new PersistentWorker($jobRunner, $processor, $id); + } + + return new WorkerPool($workers); + } + + /** + * Walk the suite and group the selected tests into units in suite order. + * + * The tests of a class are gathered into one TestClassWorkUnit, indexed by + * the order in which the class first appears. A PHPT test becomes a + * PhptWorkUnit of its own. Both kinds can run in a worker, so both are + * returned as parallel-eligible units. Any other kind of test — one that is + * neither a PHPUnit\Framework\TestCase nor a PHPT test — cannot be + * reconstructed in a worker and is returned as a standalone test to run in + * the main process. All draw from one shared index sequence so that they + * can be released together in global suite order. + * + * @return array{units: list, standalone: list} + */ + private function collectUnits(TestSuite $suite): array + { + /** @var array, array{index: non-negative-int, tests: list}> $byClass */ + $byClass = []; + + /** @var list $phpt */ + $phpt = []; + + /** @var list $standalone */ + $standalone = []; + + $index = 0; + + $this->collect($suite, $byClass, $phpt, $standalone, $index); + + $units = []; + + foreach ($byClass as $className => $group) { + $units[] = new TestClassWorkUnit($group['index'], $className, $group['tests']); + } + + foreach ($phpt as $item) { + $units[] = new PhptWorkUnit($item['index'], $item['file']); + } + + return [ + 'units' => $units, + 'standalone' => $standalone, + ]; + } + + /** + * @param array, array{index: non-negative-int, tests: list}> $byClass + * @param list $phpt + * @param list $standalone + * @param non-negative-int $index + */ + private function collect(TestSuite $suite, array &$byClass, array &$phpt, array &$standalone, int &$index): void + { + foreach ($suite as $test) { + if ($test instanceof TestSuite) { + $this->collect($test, $byClass, $phpt, $standalone, $index); + + continue; + } + + if ($test instanceof TestCase) { + $className = $test::class; + + if (!isset($byClass[$className])) { + $byClass[$className] = [ + 'index' => $index, + 'tests' => [], + ]; + + $index++; + } + + $byClass[$className]['tests'][] = $test; + + continue; + } + + if ($test instanceof PhptTestCase) { + $file = $test->toString(); + + if ($this->phptMustNotRunInParallel($file)) { + // A PHPT test cannot carry the #[DoNotRunInParallel] + // attribute, so it opts out with a --DO_NOT_RUN_IN_PARALLEL-- + // section, which routes it to the main process. + $standalone[] = [ + 'index' => $index, + 'test' => $test, + ]; + } else { + // A PHPT test is not a PHPUnit\Framework\TestCase, but a + // worker can reconstruct it from its file path alone and run + // it just like the main process would. + $phpt[] = [ + 'index' => $index, + 'file' => $file, + ]; + } + + $index++; + + continue; + } + + // Any other kind of test cannot be reconstructed in a worker and is + // run as a standalone unit in the main process. + $standalone[] = [ + 'index' => $index, + 'test' => $test, + ]; + + $index++; + } + } + + /** + * Whether a PHPT test declares, with a --DO_NOT_RUN_IN_PARALLEL-- section, + * that it must not run in parallel — typically because it relies on a shared + * resource such as a fixed temporary file or the result cache. + */ + private function phptMustNotRunInParallel(string $file): bool + { + $lines = @file($file); + + if ($lines === false) { + // @codeCoverageIgnoreStart + return false; + // @codeCoverageIgnoreEnd + } + + foreach ($lines as $line) { + if (preg_match('/^--DO_NOT_RUN_IN_PARALLEL--/', $line) === 1) { + return true; + } + } + + return false; + } +} diff --git a/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php b/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php new file mode 100644 index 0000000000..1e1c3342ff --- /dev/null +++ b/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php @@ -0,0 +1,22 @@ + + * + * 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\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class DoNotRunInParallelTest extends TestCase +{ + #[DoNotRunInParallel] + public function testOne(): void + { + } +} diff --git a/tests/_files/parallel-worker/worker.phpt b/tests/_files/parallel-worker/worker.phpt new file mode 100644 index 0000000000..749140d632 --- /dev/null +++ b/tests/_files/parallel-worker/worker.phpt @@ -0,0 +1,7 @@ +--TEST-- +A PHPT test executed by a parallel worker +--FILE-- +  Run test classes in parallel using + worker processes --globals-backup  Backup and restore $GLOBALS for each test --static-backup  Backup and restore static properties for diff --git a/tests/end-to-end/_files/output-cli-usage.txt b/tests/end-to-end/_files/output-cli-usage.txt index c993b3c1aa..e46c297c30 100644 --- a/tests/end-to-end/_files/output-cli-usage.txt +++ b/tests/end-to-end/_files/output-cli-usage.txt @@ -46,6 +46,7 @@ Selection: Execution: --process-isolation Run each test in a separate PHP process + --parallel Run test classes in parallel using worker processes --globals-backup Backup and restore $GLOBALS for each test --static-backup Backup and restore static properties for each test diff --git a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt index 0a8701b825..1a4c123e78 100644 --- a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt +++ b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt @@ -1,5 +1,6 @@ --TEST-- phpunit --validate-configuration with invalid configuration file +--DO_NOT_RUN_IN_PARALLEL-- --FILE-- + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelExecution; + +use PHPUnit\Framework\TestCase; + +final class FirstParallelTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } + + public function testTwo(): void + { + $this->assertSame(2, 1 + 1); + } +} diff --git a/tests/end-to-end/parallel/_files/SecondParallelTest.php b/tests/end-to-end/parallel/_files/SecondParallelTest.php new file mode 100644 index 0000000000..ff6afa22ff --- /dev/null +++ b/tests/end-to-end/parallel/_files/SecondParallelTest.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\ParallelExecution; + +use PHPUnit\Framework\TestCase; + +final class SecondParallelTest extends TestCase +{ + public function testThree(): void + { + $this->assertSame(3, 1 + 2); + } + + public function testFour(): void + { + $this->assertSame(4, 5); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php new file mode 100644 index 0000000000..7a83e85cb4 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class ClassLevelTest extends TestCase +{ + public function testClassLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php new file mode 100644 index 0000000000..2f9ef4a066 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +abstract class AbstractSequentialTestCase extends TestCase +{ +} + +final class InheritedTest extends AbstractSequentialTestCase +{ + public function testInherited(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php new file mode 100644 index 0000000000..0106d2fe82 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.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\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +final class MethodLevelTest extends TestCase +{ + public function testPlainMethod(): void + { + $this->assertTrue(true); + } + + #[DoNotRunInParallel] + public function testSequentialMethod(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php new file mode 100644 index 0000000000..c430f3c342 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\TestCase; + +final class PlainTest extends TestCase +{ + public function testPlain(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt b/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt new file mode 100644 index 0000000000..0d75904455 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt @@ -0,0 +1,36 @@ +--TEST-- +phpunit --parallel=2 runs tests attributed with #[DoNotRunInParallel] sequentially and forwards all results in suite order +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +..... 5 / 5 (100%) + +Time: %s, Memory: %s + +Class Level (PHPUnit\TestFixture\DoNotRunInParallel\ClassLevel) + ✔ Class level + +Inherited (PHPUnit\TestFixture\DoNotRunInParallel\Inherited) + ✔ Inherited + +Method Level (PHPUnit\TestFixture\DoNotRunInParallel\MethodLevel) + ✔ Plain method + ✔ Sequential method + +Plain (PHPUnit\TestFixture\DoNotRunInParallel\Plain) + ✔ Plain + +OK (5 tests, 5 assertions) diff --git a/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php b/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php new file mode 100644 index 0000000000..a07a3c83a6 --- /dev/null +++ b/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelNonSerializable; + +use function fopen; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class NonSerializableDataTest extends TestCase +{ + public static function resourceProvider(): array + { + return [ + [fopen('php://memory', 'r')], + ]; + } + + public static function closureProvider(): array + { + return [ + [static fn (): bool => true], + ]; + } + + #[DataProvider('resourceProvider')] + public function testReceivesAnOpenResource(mixed $resource): void + { + $this->assertIsResource($resource); + } + + #[DataProvider('closureProvider')] + public function testReceivesACallable(mixed $callable): void + { + $this->assertIsCallable($callable); + } +} diff --git a/tests/end-to-end/parallel/non-serializable/non-serializable.phpt b/tests/end-to-end/parallel/non-serializable/non-serializable.phpt new file mode 100644 index 0000000000..f5608d0f6f --- /dev/null +++ b/tests/end-to-end/parallel/non-serializable/non-serializable.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 runs tests whose data cannot be serialized in the main process instead of a worker +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +Non Serializable Data (PHPUnit\TestFixture\ParallelNonSerializable\NonSerializableData) + ✔ Receives an open resource with data set #0 + ✔ Receives a callable with data set #0 + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php new file mode 100644 index 0000000000..4f42c0ef54 --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\TestCase; + +final class FirstOrderingTest extends TestCase +{ + public function testFirst(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php new file mode 100644 index 0000000000..6e3b7d8f1c --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class SecondOrderingTest extends TestCase +{ + public function testSecond(): void + { + $this->assertSame(1, 2); + } +} diff --git a/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php new file mode 100644 index 0000000000..ab48141e0f --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\TestCase; + +final class ThirdOrderingTest extends TestCase +{ + public function testThird(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/ordering/ordering.phpt b/tests/end-to-end/parallel/ordering/ordering.phpt new file mode 100644 index 0000000000..3e65f29c29 --- /dev/null +++ b/tests/end-to-end/parallel/ordering/ordering.phpt @@ -0,0 +1,30 @@ +--TEST-- +phpunit --parallel=2 forwards results in global suite order even when an in-process unit is interspersed among worker units +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.F. 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) PHPUnit\TestFixture\ParallelOrdering\SecondOrderingTest::testSecond +Failed asserting that 2 is identical to 1. + +%sSecondOrderingTest.php:20 + +FAILURES! +Tests: 3, Assertions: 3, Failures: 1. diff --git a/tests/end-to-end/parallel/parallel-execution.phpt b/tests/end-to-end/parallel/parallel-execution.phpt new file mode 100644 index 0000000000..261ff4210f --- /dev/null +++ b/tests/end-to-end/parallel/parallel-execution.phpt @@ -0,0 +1,37 @@ +--TEST-- +phpunit --parallel=2 runs each test class in a worker process and forwards the results in suite order +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +...F 4 / 4 (100%) + +Time: %s, Memory: %s + +First Parallel (PHPUnit\TestFixture\ParallelExecution\FirstParallel) + ✔ One + ✔ Two + +Second Parallel (PHPUnit\TestFixture\ParallelExecution\SecondParallel) + ✔ Three + ✘ Four + │ + │ Failed asserting that 5 is identical to 4. + │ + │ %sSecondParallelTest.php:23 + │ + +FAILURES! +Tests: 4, Assertions: 4, Failures: 1. diff --git a/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt b/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt new file mode 100644 index 0000000000..fc341e85b5 --- /dev/null +++ b/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt @@ -0,0 +1,7 @@ +--TEST-- +A PHPT test that is expected to run under parallel test execution +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 1 assertion) diff --git a/tests/unit/Framework/Assert/assertNotInstanceOfTest.php b/tests/unit/Framework/Assert/assertNotInstanceOfTest.php index 9966c63fdb..41f0abbe34 100644 --- a/tests/unit/Framework/Assert/assertNotInstanceOfTest.php +++ b/tests/unit/Framework/Assert/assertNotInstanceOfTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProviderExternal; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\TestDox; @@ -21,6 +22,7 @@ #[Small] #[Group('framework')] #[Group('framework/assertions')] +#[DoNotRunInParallel] final class assertNotInstanceOfTest extends TestCase { #[DataProviderExternal(assertInstanceOfTest::class, 'failureProvider')] diff --git a/tests/unit/Metadata/MetadataCollectionTest.php b/tests/unit/Metadata/MetadataCollectionTest.php index 591b811d8b..ef70f5efbb 100644 --- a/tests/unit/Metadata/MetadataCollectionTest.php +++ b/tests/unit/Metadata/MetadataCollectionTest.php @@ -36,6 +36,7 @@ #[UsesClass(DependsOnMethod::class)] #[UsesClass(DisableReturnValueGenerationForTestDoubles::class)] #[UsesClass(DoesNotPerformAssertions::class)] +#[UsesClass(DoNotRunInParallel::class)] #[UsesClass(Group::class)] #[UsesClass(Metadata::class)] #[UsesClass(PostCondition::class)] @@ -308,6 +309,14 @@ public function test_Can_be_filtered_for_DoesNotPerformAssertions(): void $this->assertTrue($collection->asArray()[0]->isDoesNotPerformAssertions()); } + public function test_Can_be_filtered_for_DoNotRunInParallel(): void + { + $collection = $this->collectionWithOneOfEach()->isDoNotRunInParallel(); + + $this->assertCount(1, $collection); + $this->assertTrue($collection->asArray()[0]->isDoNotRunInParallel()); + } + public function test_Can_be_filtered_for_ExcludeGlobalVariableFromBackup(): void { $collection = $this->collectionWithOneOfEach()->isExcludeGlobalVariableFromBackup(); @@ -625,6 +634,7 @@ private function collectionWithOneOfEach(): MetadataCollection Metadata::dependsOnMethod('', '', false, false), Metadata::disableReturnValueGenerationForTestDoubles(), Metadata::doesNotPerformAssertionsOnClass(), + Metadata::doNotRunInParallelOnClass(), Metadata::excludeGlobalVariableFromBackupOnClass(''), Metadata::excludeStaticPropertyFromBackupOnClass('', ''), Metadata::groupOnClass(''), diff --git a/tests/unit/Metadata/MetadataTest.php b/tests/unit/Metadata/MetadataTest.php index 5b0f662074..15857103f5 100644 --- a/tests/unit/Metadata/MetadataTest.php +++ b/tests/unit/Metadata/MetadataTest.php @@ -51,6 +51,7 @@ public function testCanBeAfter(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -121,6 +122,7 @@ public function testCanBeAfterClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -189,6 +191,7 @@ public function testCanBeAllowMockObjectsWithoutExpectations(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -256,6 +259,7 @@ public function testCanBeBackupGlobalsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -324,6 +328,7 @@ public function testCanBeBackupGlobalsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -392,6 +397,7 @@ public function testCanBeBackupStaticPropertiesOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -460,6 +466,7 @@ public function testCanBeBackupStaticPropertiesOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -530,6 +537,7 @@ public function testCanBeBeforeClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -599,6 +607,7 @@ public function testCanBeBefore(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -667,6 +676,7 @@ public function testCanBeCoversClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -738,6 +748,7 @@ public function testCanBeCoversNamespace(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -807,6 +818,7 @@ public function testCanBeCoversClassesThatExtendClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -876,6 +888,7 @@ public function testCanBeCoversClassesThatImplementInterface(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -945,6 +958,7 @@ public function testCanBeCoversFunction(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1014,6 +1028,7 @@ public function testCanBeCoversMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1084,6 +1099,7 @@ public function testCanBeCoversNothingOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1151,6 +1167,7 @@ public function testCanBeCoversNothingOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1218,6 +1235,7 @@ public function testCanBeCoversTrait(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1287,6 +1305,7 @@ public function testCanBeDataProvider(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1362,6 +1381,7 @@ public function testCanBeDataProviderClosure(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1432,6 +1452,7 @@ public function testCanBeDependsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1503,6 +1524,7 @@ public function testCanBeDependsOnMethod(): void $this->assertTrue($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1575,6 +1597,7 @@ public function testCanBeDisableReturnValueGenerationForTestDoubles(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertTrue($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1642,6 +1665,7 @@ public function testCanBeDoesNotPerformAssertionsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertTrue($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1709,6 +1733,143 @@ public function testCanBeDoesNotPerformAssertionsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertTrue($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); + $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); + $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); + $this->assertFalse($metadata->isGroup()); + $this->assertFalse($metadata->isIgnoreDeprecations()); + $this->assertFalse($metadata->isIgnorePhpunitDeprecations()); + $this->assertFalse($metadata->isIgnorePHPUnitWarnings()); + $this->assertFalse($metadata->isInvalidAttribute()); + $this->assertFalse($metadata->isRepeat()); + $this->assertFalse($metadata->isRetry()); + $this->assertFalse($metadata->isRunInSeparateProcess()); + $this->assertFalse($metadata->isRunTestsInSeparateProcesses()); + $this->assertFalse($metadata->isTest()); + $this->assertFalse($metadata->isPreCondition()); + $this->assertFalse($metadata->isPostCondition()); + $this->assertFalse($metadata->isPreserveGlobalState()); + $this->assertFalse($metadata->isRequiresMethod()); + $this->assertFalse($metadata->isRequiresFunction()); + $this->assertFalse($metadata->isRequiresOperatingSystem()); + $this->assertFalse($metadata->isRequiresOperatingSystemFamily()); + $this->assertFalse($metadata->isRequiresPhp()); + $this->assertFalse($metadata->isRequiresPhpExtension()); + $this->assertFalse($metadata->isRequiresPhpunit()); + $this->assertFalse($metadata->isRequiresPhpunitExtension()); + $this->assertFalse($metadata->isRequiresEnvironmentVariable()); + $this->assertFalse($metadata->isWithEnvironmentVariable()); + $this->assertFalse($metadata->isRequiresSetting()); + $this->assertFalse($metadata->isTestDox()); + $this->assertFalse($metadata->isTestDoxFormatter()); + $this->assertFalse($metadata->isTestWith()); + $this->assertFalse($metadata->isUsesNamespace()); + $this->assertFalse($metadata->isUsesClass()); + $this->assertFalse($metadata->isUsesClassesThatExtendClass()); + $this->assertFalse($metadata->isUsesClassesThatImplementInterface()); + $this->assertFalse($metadata->isUsesFunction()); + $this->assertFalse($metadata->isUsesMethod()); + $this->assertFalse($metadata->isUsesTrait()); + $this->assertFalse($metadata->isWithoutErrorHandler()); + + $this->assertTrue($metadata->isMethodLevel()); + $this->assertFalse($metadata->isClassLevel()); + } + + public function testCanBeDoNotRunInParallelOnClass(): void + { + $metadata = Metadata::doNotRunInParallelOnClass(); + + $this->assertFalse($metadata->isAfter()); + $this->assertFalse($metadata->isAfterClass()); + $this->assertFalse($metadata->isAllowMockObjectsWithoutExpectations()); + $this->assertFalse($metadata->isBackupGlobals()); + $this->assertFalse($metadata->isBackupStaticProperties()); + $this->assertFalse($metadata->isBeforeClass()); + $this->assertFalse($metadata->isBefore()); + $this->assertFalse($metadata->isCoversNamespace()); + $this->assertFalse($metadata->isCoversClass()); + $this->assertFalse($metadata->isCoversClassesThatExtendClass()); + $this->assertFalse($metadata->isCoversClassesThatImplementInterface()); + $this->assertFalse($metadata->isCoversFunction()); + $this->assertFalse($metadata->isCoversMethod()); + $this->assertFalse($metadata->isCoversNothing()); + $this->assertFalse($metadata->isCoversTrait()); + $this->assertFalse($metadata->isDataProvider()); + $this->assertFalse($metadata->isDataProviderClosure()); + $this->assertFalse($metadata->isDependsOnClass()); + $this->assertFalse($metadata->isDependsOnMethod()); + $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); + $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertTrue($metadata->isDoNotRunInParallel()); + $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); + $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); + $this->assertFalse($metadata->isGroup()); + $this->assertFalse($metadata->isIgnoreDeprecations()); + $this->assertFalse($metadata->isIgnorePhpunitDeprecations()); + $this->assertFalse($metadata->isIgnorePHPUnitWarnings()); + $this->assertFalse($metadata->isInvalidAttribute()); + $this->assertFalse($metadata->isRepeat()); + $this->assertFalse($metadata->isRetry()); + $this->assertFalse($metadata->isRunInSeparateProcess()); + $this->assertFalse($metadata->isRunTestsInSeparateProcesses()); + $this->assertFalse($metadata->isTest()); + $this->assertFalse($metadata->isPreCondition()); + $this->assertFalse($metadata->isPostCondition()); + $this->assertFalse($metadata->isPreserveGlobalState()); + $this->assertFalse($metadata->isRequiresMethod()); + $this->assertFalse($metadata->isRequiresFunction()); + $this->assertFalse($metadata->isRequiresOperatingSystem()); + $this->assertFalse($metadata->isRequiresOperatingSystemFamily()); + $this->assertFalse($metadata->isRequiresPhp()); + $this->assertFalse($metadata->isRequiresPhpExtension()); + $this->assertFalse($metadata->isRequiresPhpunit()); + $this->assertFalse($metadata->isRequiresPhpunitExtension()); + $this->assertFalse($metadata->isRequiresEnvironmentVariable()); + $this->assertFalse($metadata->isWithEnvironmentVariable()); + $this->assertFalse($metadata->isRequiresSetting()); + $this->assertFalse($metadata->isTestDox()); + $this->assertFalse($metadata->isTestDoxFormatter()); + $this->assertFalse($metadata->isTestWith()); + $this->assertFalse($metadata->isUsesNamespace()); + $this->assertFalse($metadata->isUsesClass()); + $this->assertFalse($metadata->isUsesClassesThatExtendClass()); + $this->assertFalse($metadata->isUsesClassesThatImplementInterface()); + $this->assertFalse($metadata->isUsesFunction()); + $this->assertFalse($metadata->isUsesMethod()); + $this->assertFalse($metadata->isUsesTrait()); + $this->assertFalse($metadata->isWithoutErrorHandler()); + + $this->assertTrue($metadata->isClassLevel()); + $this->assertFalse($metadata->isMethodLevel()); + } + + public function testCanBeDoNotRunInParallelOnMethod(): void + { + $metadata = Metadata::doNotRunInParallelOnMethod(); + + $this->assertFalse($metadata->isAfter()); + $this->assertFalse($metadata->isAfterClass()); + $this->assertFalse($metadata->isAllowMockObjectsWithoutExpectations()); + $this->assertFalse($metadata->isBackupGlobals()); + $this->assertFalse($metadata->isBackupStaticProperties()); + $this->assertFalse($metadata->isBeforeClass()); + $this->assertFalse($metadata->isBefore()); + $this->assertFalse($metadata->isCoversNamespace()); + $this->assertFalse($metadata->isCoversClass()); + $this->assertFalse($metadata->isCoversClassesThatExtendClass()); + $this->assertFalse($metadata->isCoversClassesThatImplementInterface()); + $this->assertFalse($metadata->isCoversFunction()); + $this->assertFalse($metadata->isCoversMethod()); + $this->assertFalse($metadata->isCoversNothing()); + $this->assertFalse($metadata->isCoversTrait()); + $this->assertFalse($metadata->isDataProvider()); + $this->assertFalse($metadata->isDataProviderClosure()); + $this->assertFalse($metadata->isDependsOnClass()); + $this->assertFalse($metadata->isDependsOnMethod()); + $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); + $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertTrue($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1776,6 +1937,7 @@ public function testCanBeExcludeGlobalVariableFromBackupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertTrue($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1844,6 +2006,7 @@ public function testCanBeExcludeGlobalVariableFromBackupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertTrue($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1912,6 +2075,7 @@ public function testCanBeExcludeStaticPropertyFromBackupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertTrue($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1982,6 +2146,7 @@ public function testCanBeExcludeStaticPropertyFromBackupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertTrue($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2052,6 +2217,7 @@ public function testCanBeGroupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertTrue($metadata->isGroup()); @@ -2123,6 +2289,7 @@ public function testCanBeIgnoreDeprecationsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2191,6 +2358,7 @@ public function testCanBeIgnoreDeprecationsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2258,6 +2426,7 @@ public function testCanBeIgnorePhpunitDeprecationsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2325,6 +2494,7 @@ public function testCanBeIgnorePhpunitDeprecationsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2394,6 +2564,7 @@ public function testCanBeIgnorePHPUnitWarnings(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2460,6 +2631,7 @@ public function testCanBeInvalidAttributeOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2526,6 +2698,7 @@ public function testCanBeInvalidAttributeOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2592,6 +2765,7 @@ public function testCanBeGroupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertTrue($metadata->isGroup()); @@ -2661,6 +2835,7 @@ public function testCanBeRunTestsInSeparateProcesses(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2728,6 +2903,7 @@ public function testCanBeRunInSeparateProcess(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2793,6 +2969,7 @@ public function testCanBeTest(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2862,6 +3039,7 @@ public function testCanBePreCondition(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2932,6 +3110,7 @@ public function testCanBePostCondition(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2996,6 +3175,7 @@ public function testCanBePreserveGlobalStateOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3061,6 +3241,7 @@ public function testCanBePreserveGlobalStateOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3130,6 +3311,7 @@ public function testCanBeRequiresMethodOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3200,6 +3382,7 @@ public function testCanBeRequiresMethodOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3270,6 +3453,7 @@ public function testCanBeRequiresFunctionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3339,6 +3523,7 @@ public function testCanBeRequiresFunctionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3408,6 +3593,7 @@ public function testCanBeRequiresOperatingSystemOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3477,6 +3663,7 @@ public function testCanBeRequiresOperatingSystemOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3546,6 +3733,7 @@ public function testCanBeRequiresOperatingSystemFamilyOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3615,6 +3803,7 @@ public function testCanBeRequiresOperatingSystemFamilyOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3689,6 +3878,7 @@ public function testCanBeRequiresPhpOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3763,6 +3953,7 @@ public function testCanBeRequiresPhpOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3832,6 +4023,7 @@ public function testCanBeRequiresPhpExtensionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3911,6 +4103,7 @@ public function testCanBeRequiresPhpExtensionWithVersionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3982,6 +4175,7 @@ public function testCanBeRequiresPhpExtensionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4061,6 +4255,7 @@ public function testCanBeRequiresPhpExtensionWithVersionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4137,6 +4332,7 @@ public function testCanBeRequiresPhpunitOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4206,6 +4402,7 @@ public function testCanBeRequiresPhpunitExtensionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4280,6 +4477,7 @@ public function testCanBeRequiresPhpunitOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4349,6 +4547,7 @@ public function testCanBeRequiresPhpunitExtensionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4418,6 +4617,7 @@ public function testCanBeRequiresEnvironmentVariableOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4488,6 +4688,7 @@ public function testCanBeRequiresEnvironmentVariableOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4558,6 +4759,7 @@ public function testCanBeWithEnvironmentVariableOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4628,6 +4830,7 @@ public function testCanBeWithEnvironmentVariableOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4698,6 +4901,7 @@ public function testCanBeRequiresSettingOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4768,6 +4972,7 @@ public function testCanBeRequiresSettingOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4838,6 +5043,7 @@ public function testCanBeTestDoxOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4907,6 +5113,7 @@ public function testCanBeTestDoxOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4976,6 +5183,7 @@ public function testCanBeTestDoxFormatterOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5046,6 +5254,7 @@ public function testCanBeTestWith(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5118,6 +5327,7 @@ public function testCanBeUsesNamespace(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5187,6 +5397,7 @@ public function testCanBeUsesClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5256,6 +5467,7 @@ public function testCanBeUsesClassesThatExtendClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5325,6 +5537,7 @@ public function testCanBeUsesClassesThatImplementInterface(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5394,6 +5607,7 @@ public function testCanBeUsesFunction(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5462,6 +5676,7 @@ public function testCanBeUsesMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5532,6 +5747,7 @@ public function testCanBeUsesTrait(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5601,6 +5817,7 @@ public function testCanBeWithoutErrorHandler(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5668,6 +5885,7 @@ public function testCanBeRepeat(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5737,6 +5955,7 @@ public function testCanBeRetry(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); diff --git a/tests/unit/Metadata/Parser/AttributeParserTest.php b/tests/unit/Metadata/Parser/AttributeParserTest.php index b1592b0714..5529abba24 100644 --- a/tests/unit/Metadata/Parser/AttributeParserTest.php +++ b/tests/unit/Metadata/Parser/AttributeParserTest.php @@ -36,6 +36,7 @@ use PHPUnit\Framework\Attributes\DependsUsingDeepClone; use PHPUnit\Framework\Attributes\DependsUsingShallowClone; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\ExcludeGlobalVariableFromBackup; use PHPUnit\Framework\Attributes\ExcludeStaticPropertyFromBackup; use PHPUnit\Framework\Attributes\Group; @@ -106,6 +107,7 @@ #[CoversClass(DependsUsingShallowClone::class)] #[CoversClass(DisableReturnValueGenerationForTestDoubles::class)] #[CoversClass(DoesNotPerformAssertions::class)] +#[CoversClass(DoNotRunInParallel::class)] #[CoversClass(ExcludeGlobalVariableFromBackup::class)] #[CoversClass(ExcludeStaticPropertyFromBackup::class)] #[CoversClass(Group::class)] diff --git a/tests/unit/Metadata/Parser/AttributeParserTestCase.php b/tests/unit/Metadata/Parser/AttributeParserTestCase.php index d6bbeacd24..66c0415267 100644 --- a/tests/unit/Metadata/Parser/AttributeParserTestCase.php +++ b/tests/unit/Metadata/Parser/AttributeParserTestCase.php @@ -38,6 +38,7 @@ use PHPUnit\TestFixture\Metadata\Attribute\DependencyTest; use PHPUnit\TestFixture\Metadata\Attribute\DisableReturnValueGenerationForTestDoublesTest; use PHPUnit\TestFixture\Metadata\Attribute\DoesNotPerformAssertionsTest; +use PHPUnit\TestFixture\Metadata\Attribute\DoNotRunInParallelTest; use PHPUnit\TestFixture\Metadata\Attribute\DuplicateSmallAttributeTest; use PHPUnit\TestFixture\Metadata\Attribute\DuplicateTestAttributeTest; use PHPUnit\TestFixture\Metadata\Attribute\Example; @@ -202,6 +203,15 @@ public function test_parses_DoesNotPerformAssertions_attribute_on_class(): void $this->assertTrue($metadata->asArray()[0]->isDoesNotPerformAssertions()); } + #[TestDox('Parses #[DoNotRunInParallel] attribute on class')] + public function test_parses_DoNotRunInParallel_attribute_on_class(): void + { + $metadata = $this->parser()->forClass(DoNotRunInParallelTest::class)->isDoNotRunInParallel(); + + $this->assertCount(1, $metadata); + $this->assertTrue($metadata->asArray()[0]->isDoNotRunInParallel()); + } + #[TestDox('Parses #[ExcludeGlobalVariableFromBackup] attribute on class')] public function test_parses_ExcludeGlobalVariableFromBackup_attribute_on_class(): void { @@ -814,6 +824,15 @@ public function test_parses_DoesNotPerformAssertions_attribute_on_method(): void $this->assertTrue($metadata->asArray()[0]->isDoesNotPerformAssertions()); } + #[TestDox('Parses #[DoNotRunInParallel] attribute on method')] + public function test_parses_DoNotRunInParallel_attribute_on_method(): void + { + $metadata = $this->parser()->forMethod(DoNotRunInParallelTest::class, 'testOne')->isDoNotRunInParallel(); + + $this->assertCount(1, $metadata); + $this->assertTrue($metadata->asArray()[0]->isDoNotRunInParallel()); + } + #[TestDox('Parses #[ExcludeGlobalVariableFromBackup] attribute on method')] public function test_parses_ExcludeGlobalVariableFromBackup_attribute_on_method(): void { diff --git a/tests/unit/Runner/Parallel/ResultAggregatorTest.php b/tests/unit/Runner/Parallel/ResultAggregatorTest.php new file mode 100644 index 0000000000..ea0fa3b716 --- /dev/null +++ b/tests/unit/Runner/Parallel/ResultAggregatorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function serialize; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; + +#[CoversClass(ResultAggregator::class)] +#[UsesClass(TestClassWorkUnit::class)] +#[UsesClass(CompletedWorkUnit::class)] +#[Small] +final class ResultAggregatorTest extends TestCase +{ + public function testForwardsAndImportsAFinishedUnit(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->never())->method('childProcessErrored'); + $emitter->expects($this->never())->method('testRunnerTriggeredPhpunitWarning'); + + $aggregator = $this->aggregator($emitter); + + $nonce = 'abc'; + + $aggregator->add( + new CompletedWorkUnit( + new TestClassWorkUnit(0, self::class, []), + $nonce . serialize((object) ['codeCoverage' => null, 'events' => new EventCollection, 'passedTests' => new PassedTests]), + $nonce, + false, + ), + ); + } + + public function testReportsACrashedUnitAsAWarning(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('ended unexpectedly')); + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), '', null, true), + ); + } + + public function testRejectsAResultWhoseNonceDoesNotMatch(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('tampered with')); + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), 'expected-nonce' . serialize((object) []), 'actual-nonce', false), + ); + } + + public function testRejectsAMalformedResult(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('ended unexpectedly')); + + $nonce = 'abc'; + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), $nonce . 'not-a-serialized-envelope', $nonce, false), + ); + } + + public function testForwardsBufferedUnitsInSuiteOrderRegardlessOfCompletionOrder(): void + { + $messages = []; + + $emitter = $this->createMock(Emitter::class); + + $emitter->method('testRunnerTriggeredPhpunitWarning')->willReturnCallback( + static function (string $message) use (&$messages): void + { + $messages[] = $message; + }, + ); + + $aggregator = $this->aggregator($emitter); + + // The unit at index 1 finishes first; it must not be forwarded until + // the unit at index 0, which precedes it in suite order, has been + // forwarded. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(1, WorkerSecondTest::class, []), '', null, true)); + + $this->assertSame([], $messages); + + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(0, WorkerFirstTest::class, []), '', null, true)); + + $this->assertCount(2, $messages); + $this->assertStringContainsString(WorkerFirstTest::class, $messages[0]); + $this->assertStringContainsString(WorkerSecondTest::class, $messages[1]); + } + + public function testRunsInProcessUnitsAtTheirIndexInGlobalSuiteOrder(): void + { + $order = []; + + $emitter = $this->createMock(Emitter::class); + + $emitter->method('testRunnerTriggeredPhpunitWarning')->willReturnCallback( + static function (string $message) use (&$order): void + { + $order[] = $message; + }, + ); + + $aggregator = $this->aggregator($emitter); + + // The unit at index 1 runs in the main process; the units at index 0 + // and 2 run in workers and finish out of order. + $aggregator->registerInProcessUnit( + 1, + static function () use (&$order): void + { + $order[] = 'in-process'; + }, + ); + + // The worker unit at index 2 finishes first and must be held back. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(2, WorkerSecondTest::class, []), '', null, true)); + + $this->assertSame([], $order); + + // Once index 0 arrives, index 0 is forwarded, then the in-process unit + // at index 1 is run in place, then index 2 is released — global order. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(0, WorkerFirstTest::class, []), '', null, true)); + + $this->assertCount(3, $order); + $this->assertStringContainsString(WorkerFirstTest::class, $order[0]); + $this->assertSame('in-process', $order[1]); + $this->assertStringContainsString(WorkerSecondTest::class, $order[2]); + } + + public function testRunsRegisteredInProcessUnitsThatPrecedeAllWorkerUnitsOnFlush(): void + { + $order = []; + + $aggregator = $this->aggregator($this->createStub(Emitter::class)); + + $aggregator->registerInProcessUnit( + 0, + static function () use (&$order): void + { + $order[] = 'in-process'; + }, + ); + + // No worker completion drives the release, so an explicit flush() must + // run the leading in-process unit. + $this->assertSame([], $order); + + $aggregator->flush(); + + $this->assertSame(['in-process'], $order); + } + + private function aggregator(Emitter $emitter): ResultAggregator + { + return new ResultAggregator( + new Facade, + $emitter, + new PassedTests, + new CodeCoverage, + ); + } +} diff --git a/tests/unit/Runner/Parallel/WorkerPoolTest.php b/tests/unit/Runner/Parallel/WorkerPoolTest.php new file mode 100644 index 0000000000..b93729e578 --- /dev/null +++ b/tests/unit/Runner/Parallel/WorkerPoolTest.php @@ -0,0 +1,201 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function sort; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(WorkerPool::class)] +#[CoversClass(TestClassWorkUnit::class)] +#[CoversClass(PhptWorkUnit::class)] +#[CoversClass(CompletedWorkUnit::class)] +#[CoversClass(PersistentWorker::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class WorkerPoolTest extends TestCase +{ + public function testRunsAPhptTestInAWorker(): void + { + $units = [ + new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker.phpt'), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(1, $completed); + $this->assertFalse($completed[0]->crashed()); + $this->assertNotSame('', $completed[0]->serializedResult()); + $this->assertNotNull($completed[0]->nonce()); + } + + public function testRunsUnitsAcrossWorkersAndReportsEachAsCompleted(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ]; + + $completed = $this->execute($this->pool(2), $units); + + $this->assertCount(2, $completed); + + foreach ($completed as $unit) { + $this->assertFalse($unit->crashed()); + $this->assertNotSame('', $unit->serializedResult()); + $this->assertNotNull($unit->nonce()); + } + + $this->assertSame([0, 1], $this->indexesOf($completed)); + } + + public function testReportsACrashedUnitWhenItsWorkerDies(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(1, $completed); + $this->assertTrue($completed[0]->crashed()); + } + + public function testReportsAUnitWhoseDataCannotBeSerializedAsCrashedAndKeepsRunning(): void + { + $test = new WorkerFirstTest('testStartsTheProcessLocalCounter'); + $test->setData('with-closure', [static fn (): null => null]); + + $units = [ + new TestClassWorkUnit(0, WorkerFirstTest::class, [$test]), + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(2, $completed); + + $byIndex = []; + + foreach ($completed as $unit) { + $byIndex[$unit->unit()->index()] = $unit; + } + + $this->assertTrue($byIndex[0]->crashed()); + $this->assertNotNull($byIndex[0]->message()); + $this->assertStringContainsString('cannot be serialized', (string) $byIndex[0]->message()); + + // The unit that could be serialized still ran on the same worker. + $this->assertFalse($byIndex[1]->crashed()); + } + + public function testRedistributesRemainingUnitsAcrossSurvivingWorkersWhenAWorkerDies(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + new TestClassWorkUnit(1, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + new TestClassWorkUnit(2, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ]; + + $completed = $this->execute($this->pool(2), $units); + + $this->assertCount(3, $completed); + $this->assertSame([0, 1, 2], $this->indexesOf($completed)); + + $crashed = []; + + foreach ($completed as $unit) { + $crashed[$unit->unit()->index()] = $unit->crashed(); + } + + $this->assertTrue($crashed[0]); + $this->assertFalse($crashed[1]); + $this->assertFalse($crashed[2]); + } + + /** + * @param list $units + * + * @return list + */ + private function execute(WorkerPool $pool, array $units): array + { + $completed = []; + + $pool->start(); + + try { + $pool->run( + $units, + static function (CompletedWorkUnit $unit) use (&$completed): void + { + $completed[] = $unit; + }, + ); + } finally { + $pool->stop(); + } + + return $completed; + } + + /** + * @param positive-int $numberOfWorkers + */ + private function pool(int $numberOfWorkers): WorkerPool + { + $processor = new ChildProcessResultProcessor( + new Facade, + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + $jobRunner = new JobRunner($processor); + + $workers = []; + + for ($id = 0; $id < $numberOfWorkers; $id++) { + $workers[] = new PersistentWorker($jobRunner, $processor, $id); + } + + return new WorkerPool($workers); + } + + /** + * @param list $completed + * + * @return list + */ + private function indexesOf(array $completed): array + { + $indexes = []; + + foreach ($completed as $unit) { + $indexes[] = $unit->unit()->index(); + } + + sort($indexes); + + return $indexes; + } +} diff --git a/tests/unit/TextUI/PhpHandlerTest.php b/tests/unit/TextUI/PhpHandlerTest.php index 3a543c3aa0..f51cd24e95 100644 --- a/tests/unit/TextUI/PhpHandlerTest.php +++ b/tests/unit/TextUI/PhpHandlerTest.php @@ -19,6 +19,7 @@ use function putenv; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Medium; use PHPUnit\Framework\TestCase; @@ -28,6 +29,7 @@ #[Medium] #[Group('textui')] #[Group('textui/configuration')] +#[DoNotRunInParallel] final class PhpHandlerTest extends TestCase { #[BackupGlobals(true)] From 653dbcfc656559a20bbfa5bfa2eafbb76c923111 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Fri, 26 Jun 2026 12:12:28 +0200 Subject: [PATCH 05/14] Run PHPT tests concurrently in the main process instead of nesting them inside parallel workers (which hung on Windows) --- phpunit.xml | 1 + src/Event/CollectingEmitter.php | 46 +++++ src/Event/Facade.php | 22 +++ src/Runner/Parallel/PersistentWorker.php | 27 +-- src/Runner/Parallel/PhptRunner.php | 160 ++++++++++++++++++ src/Runner/Parallel/PhptWorkUnit.php | 11 ++ src/Runner/Parallel/templates/worker.tpl | 45 ----- src/Runner/Phpt/TestCase.php | 112 ++++++++---- src/TextUI/ParallelTestRunner.php | 76 ++++++++- src/Util/PHP/JobRunner.php | 94 ++++++---- src/Util/PHP/RunningJob.php | 17 ++ .../parallel-worker/worker-skipped.phpt | 10 ++ .../parallel-worker/worker-with-clean.phpt | 12 ++ .../_files/SampleClassTest.php | 20 +++ .../phpt-with-classes/_files/aaa-passing.phpt | 7 + .../phpt-with-classes/_files/bbb-failing.phpt | 7 + .../phpt-runs-in-parallel-with-classes.phpt | 35 ++++ .../retry/retry-phpt-cli-option.phpt | 1 + .../end-to-end/retry/retry-phpt-summary.phpt | 1 + tests/unit/Runner/Parallel/PhptRunnerTest.php | 147 ++++++++++++++++ tests/unit/Runner/Parallel/WorkerPoolTest.php | 15 -- 21 files changed, 706 insertions(+), 160 deletions(-) create mode 100644 src/Event/CollectingEmitter.php create mode 100644 src/Runner/Parallel/PhptRunner.php create mode 100644 tests/_files/parallel-worker/worker-skipped.phpt create mode 100644 tests/_files/parallel-worker/worker-with-clean.phpt create mode 100644 tests/end-to-end/parallel/phpt-with-classes/_files/SampleClassTest.php create mode 100644 tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt create mode 100644 tests/end-to-end/parallel/phpt-with-classes/_files/bbb-failing.phpt create mode 100644 tests/end-to-end/parallel/phpt-with-classes/phpt-runs-in-parallel-with-classes.phpt create mode 100644 tests/unit/Runner/Parallel/PhptRunnerTest.php diff --git a/phpunit.xml b/phpunit.xml index 8eea4772da..a3a5b6e44e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -50,6 +50,7 @@ tests/end-to-end/logging/_files tests/end-to-end/migration/_files tests/end-to-end/parallel/phpt/_files + tests/end-to-end/parallel/phpt-with-classes/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files tests/end-to-end/self-direct-indirect/_files diff --git a/src/Event/CollectingEmitter.php b/src/Event/CollectingEmitter.php new file mode 100644 index 0000000000..aa9d55b66a --- /dev/null +++ b/src/Event/CollectingEmitter.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Event; + +/** + * An emitter that collects the events emitted through it into an event + * collection that can later be flushed and replayed, without disturbing the + * global event facade. + * + * Several of these can be in use at the same time in one process, which is what + * lets the parallel test runner run several PHPT tests concurrently in the main + * process, each collecting its own events for deterministic, suite-ordered + * replay through Facade::forward(). + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class CollectingEmitter +{ + private Emitter $emitter; + private CollectingDispatcher $dispatcher; + + public function __construct(Emitter $emitter, CollectingDispatcher $dispatcher) + { + $this->emitter = $emitter; + $this->dispatcher = $dispatcher; + } + + public function emitter(): Emitter + { + return $this->emitter; + } + + public function flush(): EventCollection + { + return $this->dispatcher->flush(); + } +} diff --git a/src/Event/Facade.php b/src/Event/Facade.php index 349efbb1f3..7ce54255a1 100644 --- a/src/Event/Facade.php +++ b/src/Event/Facade.php @@ -111,6 +111,28 @@ public function initForIsolation(HRTime $offset): CollectingDispatcher return $dispatcher; } + /** + * Mint an emitter that collects its events into an independent collection, + * without sealing this facade or replacing its emitter. Unlike + * initForIsolation(), which can only establish one isolated emitter for the + * whole process, any number of these can be in use at once; the parallel + * test runner uses one per concurrently running PHPT test. + */ + public function collectingEmitter(): CollectingEmitter + { + $dispatcher = new CollectingDispatcher( + new DirectDispatcher($this->typeMap()), + ); + + return new CollectingEmitter( + new DispatchingEmitter( + $dispatcher, + $this->createTelemetrySystem(), + ), + $dispatcher, + ); + } + public function forward(EventCollection $events): void { $dispatcher = $this->deferredDispatcher(); diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index 41ec8684e9..dac29d0503 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -226,13 +226,9 @@ public function dispatch(WorkUnit $unit): void // @codeCoverageIgnoreEnd } - if ($unit instanceof PhptWorkUnit) { - $command = $this->phptCommand($unit, $offset, $resultFile, $nonce); - } else { - assert($unit instanceof TestClassWorkUnit); + assert($unit instanceof TestClassWorkUnit); - $command = $this->testClassCommand($unit, $offset, $resultFile, $nonce); - } + $command = $this->testClassCommand($unit, $offset, $resultFile, $nonce); $encodedCommand = json_encode($command); @@ -406,25 +402,6 @@ private function testClassCommand(TestClassWorkUnit $unit, array $offset, string ]; } - /** - * @param array{0: int, 1: int} $offset - * @param non-empty-string $resultFile - * @param non-empty-string $nonce - * - * @return array - */ - private function phptCommand(PhptWorkUnit $unit, array $offset, string $resultFile, string $nonce): array - { - return [ - 'command' => 'runPhpt', - 'file' => $unit->file(), - 'offsetSeconds' => $offset[0], - 'offsetNanoseconds' => $offset[1], - 'resultFile' => $resultFile, - 'nonce' => $nonce, - ]; - } - /** * Read the worker's control channel until it reports that the test * identified by the given nonce has finished. Any unrelated output the diff --git a/src/Runner/Parallel/PhptRunner.php b/src/Runner/Parallel/PhptRunner.php new file mode 100644 index 0000000000..7798142875 --- /dev/null +++ b/src/Runner/Parallel/PhptRunner.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_shift; +use function count; +use function usleep; +use Generator; +use PHPUnit\Event\CollectingEmitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Runner\Phpt\TestCase as PhptTestCase; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; +use PHPUnit\Util\PHP\Result; +use PHPUnit\Util\PHP\RunningJob; + +/** + * Runs PHPT tests concurrently, each as its own child process, in the main + * PHPUnit process. + * + * A PHPT test already runs its --SKIPIF--, --FILE--, and --CLEAN-- sections in + * child processes; routing it through a PersistentWorker would only wrap those + * children in a further process, and a child process spawned from within a + * worker hangs on Windows because it inherits the worker's control-channel + * handles. This runner therefore drives the PHPT tests directly: it advances up + * to a fixed number of them at a time, each represented by the generator that + * PhptTestCase::execute() returns, and starts the next section's child process + * for a test as soon as the previous one has finished. + * + * Because the events of a PHPT test must reach the parent's output and result + * subsystem in suite order — not in the order in which the concurrently running + * tests happen to finish — each test writes its events into its own collecting + * emitter. The collected events are handed to the caller, which replays them at + * the test's suite index through the same ResultAggregator that orders the + * worker units. + * + * The --FILE-- section's child redirects its standard error onto its standard + * output, which is captured in a file rather than a pipe; there is therefore no + * stream to wait on with stream_select(), so the runner polls each child's + * liveness instead, draining the pipes of the sections that do use them so that + * a child never blocks on a full pipe buffer. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class PhptRunner +{ + private readonly JobRunner $jobRunner; + + /** + * @var positive-int + */ + private readonly int $concurrency; + + /** + * @param positive-int $concurrency + */ + public function __construct(JobRunner $jobRunner, int $concurrency) + { + $this->jobRunner = $jobRunner; + $this->concurrency = $concurrency; + } + + /** + * Run the given PHPT units, invoking the callback once for each unit as it + * finishes (in completion order, not suite order) with its suite index and + * the events it collected. + * + * @param list $units + * @param callable(non-negative-int, EventCollection): void $onCompleted + */ + public function run(array $units, callable $onCompleted): void + { + $queue = $units; + $active = []; + $nextId = 0; + + while ($queue !== [] || $active !== []) { + while (count($active) < $this->concurrency && $queue !== []) { + $unit = array_shift($queue); + $collector = EventFacade::instance()->collectingEmitter(); + $generator = new PhptTestCase($unit->file())->execute($collector->emitter()); + + $generator->rewind(); + + if (!$generator->valid()) { + // The test produced its events without running any child + // process — a parse error, or a skip decided in-process. + $onCompleted($unit->index(), $collector->flush()); + + continue; + } + + $active[$nextId] = [ + 'unit' => $unit, + 'generator' => $generator, + 'collector' => $collector, + 'job' => $this->jobRunner->startAsync($generator->current()), + ]; + + $nextId++; + } + + if ($active === []) { + continue; + } + + $this->harvest($active, $onCompleted); + } + } + + /** + * @param array, collector: CollectingEmitter, job: RunningJob}> $active + * @param callable(non-negative-int, EventCollection): void $onCompleted + */ + private function harvest(array &$active, callable $onCompleted): void + { + $progressed = false; + + foreach ($active as $id => $task) { + // Drain whatever the child has produced on its pipes so that it + // never blocks writing into a full pipe buffer while we wait. + $task['job']->consume(); + + if ($task['job']->isRunning()) { + continue; + } + + $progressed = true; + + $generator = $task['generator']; + + $generator->send($task['job']->wait()); + + if ($generator->valid()) { + // The test's next section has to run in a child process too. + $active[$id]['job'] = $this->jobRunner->startAsync($generator->current()); + + continue; + } + + $onCompleted($task['unit']->index(), $task['collector']->flush()); + + unset($active[$id]); + } + + if (!$progressed) { + usleep(1000); + } + } +} diff --git a/src/Runner/Parallel/PhptWorkUnit.php b/src/Runner/Parallel/PhptWorkUnit.php index 44067e7b9c..5c2e9a346a 100644 --- a/src/Runner/Parallel/PhptWorkUnit.php +++ b/src/Runner/Parallel/PhptWorkUnit.php @@ -24,10 +24,15 @@ * @var non-negative-int */ private int $index; + + /** + * @var non-empty-string + */ private string $file; /** * @param non-negative-int $index + * @param non-empty-string $file */ public function __construct(int $index, string $file) { @@ -43,11 +48,17 @@ public function index(): int return $this->index; } + /** + * @return non-empty-string + */ public function file(): string { return $this->file; } + /** + * @return non-empty-string + */ public function name(): string { return $this->file; diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl index d1a78ccdff..23121450f6 100644 --- a/src/Runner/Parallel/templates/worker.tpl +++ b/src/Runner/Parallel/templates/worker.tpl @@ -194,49 +194,6 @@ function __phpunit_worker_run_unit(object $command): string return $result; } -function __phpunit_worker_run_phpt(object $command): string -{ - $dispatcher = Facade::instance()->initForIsolation( - PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( - $command->offsetSeconds, - $command->offsetNanoseconds - ), - ); - - $test = new PHPUnit\Runner\Phpt\TestCase($command->file); - - $test->run(); - - $codeCoverage = null; - - if (CodeCoverage::instance()->isActive()) { - $codeCoverage = CodeCoverage::instance()->codeCoverage(); - } - - $result = $command->nonce . serialize( - (object) [ - 'codeCoverage' => $codeCoverage, - 'events' => $dispatcher->flush(), - 'passedTests' => PassedTests::instance(), - ] - ); - - // Per-test code coverage has been collected for this command and is about - // to be shipped to the parent process. It is cleared here so that the next - // test executed by this worker does not ship it a second time. - if (CodeCoverage::instance()->isActive()) { - CodeCoverage::instance()->codeCoverage()->clear(); - } - - // Reset the stream that captures test output so that the next test does - // not inherit the output of the test that has just finished. - if (@rewind(STDOUT)) { - @ftruncate(STDOUT, 0); - } - - return $result; -} - $__phpunit_input = fopen('php://stdin', 'rb'); while (($__phpunit_line = fgets($__phpunit_input)) !== false) { @@ -254,8 +211,6 @@ while (($__phpunit_line = fgets($__phpunit_input)) !== false) { if ($__phpunit_command->command === 'runUnit') { $__phpunit_result = __phpunit_worker_run_unit($__phpunit_command); - } else if ($__phpunit_command->command === 'runPhpt') { - $__phpunit_result = __phpunit_worker_run_phpt($__phpunit_command); } else { $__phpunit_result = __phpunit_worker_run_test($__phpunit_command); } diff --git a/src/Runner/Phpt/TestCase.php b/src/Runner/Phpt/TestCase.php index f14c4d45b0..3eda0a6adb 100644 --- a/src/Runner/Phpt/TestCase.php +++ b/src/Runner/Phpt/TestCase.php @@ -40,8 +40,10 @@ use function trim; use function unlink; use function unserialize; +use Generator; use PHPUnit\Event\Code\Phpt; use PHPUnit\Event\Code\ThrowableBuilder; +use PHPUnit\Event\Emitter; use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Event\NoPreviousThrowableException; use PHPUnit\Framework\Assert; @@ -60,6 +62,7 @@ use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\Util\PHP\Job; use PHPUnit\Util\PHP\JobRunnerRegistry; +use PHPUnit\Util\PHP\Result; use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\InvalidArgumentException; use SebastianBergmann\CodeCoverage\ReflectionException; @@ -141,8 +144,43 @@ public function count(): int */ public function run(): void { - $emitter = EventFacade::emitter(); - $parser = new Parser; + $generator = $this->execute(EventFacade::emitter()); + + $generator->rewind(); + + while ($generator->valid()) { + $generator->send(JobRunnerRegistry::run($generator->current())); + } + } + + /** + * The staged execution of this PHPT test as a generator that yields a Job + * for each section that has to run in a child process and is resumed with + * that job's Result. + * + * Driving the generator synchronously, as run() does, runs the sections one + * after another and is equivalent to ordinary sequential execution. The + * parallel test runner instead drives the generators of several PHPT tests + * at once, each writing its events to its own emitter, so that the child + * processes of independent PHPT tests run concurrently without any of them + * being nested inside a worker process. + * + * @throws \PHPUnit\Framework\Exception + * @throws \SebastianBergmann\Template\InvalidArgumentException + * @throws Exception + * @throws InvalidArgumentException + * @throws NoPreviousThrowableException + * @throws ReflectionException + * @throws TestIdMissingException + * @throws UnintentionallyCoveredCodeException + * + * @return Generator + * + * @internal This method is not covered by the backward compatibility promise for PHPUnit + */ + public function execute(Emitter $emitter): Generator + { + $parser = new Parser; $emitter->testPreparationStarted( $this->valueObjectForEvents(), @@ -185,7 +223,7 @@ public function run(): void $environmentVariables = $parser->parseEnvSection($sections['ENV']); } - if ($this->shouldTestBeSkipped($sections, $phpSettings)) { + if (yield from $this->executeSkipIf($emitter, $sections, $phpSettings)) { return; } @@ -234,21 +272,19 @@ public function run(): void // @codeCoverageIgnoreEnd } - $jobResult = JobRunnerRegistry::run( - new Job( - $code, - $this->stringifyIni($phpSettings), - $environmentVariables, - $arguments, - $input, - true, - ), + $jobResult = yield new Job( + $code, + $this->stringifyIni($phpSettings), + $environmentVariables, + $arguments, + $input, + true, ); - EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); + $emitter->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); if (TestResultFacade::wasInterrupted()) { - $this->runClean($sections, CodeCoverage::instance()->isActive()); + yield from $this->executeClean($emitter, $sections, CodeCoverage::instance()->isActive()); $emitter->testFinished($this->valueObjectForEvents(), 0); @@ -356,7 +392,7 @@ public function run(): void $emitter->testPassed($this->valueObjectForEvents()); } - $this->runClean($sections, CodeCoverage::instance()->isActive()); + yield from $this->executeClean($emitter, $sections, CodeCoverage::instance()->isActive()); $emitter->testFinished($this->valueObjectForEvents(), 1); } @@ -371,6 +407,8 @@ public function getName(): string /** * Returns a string representation of the test case. + * + * @return non-empty-string */ public function toString(): string { @@ -476,8 +514,10 @@ private function assertPhptExpectation(array $sections, string $output): void /** * @param array $sections * @param array|string> $settings + * + * @return Generator */ - private function shouldTestBeSkipped(array $sections, array $settings): bool + private function executeSkipIf(Emitter $emitter, array $sections, array $settings): Generator { if (!isset($sections['SKIPIF']) || $sections['SKIPIF'] === '') { return false; @@ -486,21 +526,19 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool $skipIfCode = (new Renderer)->render($this->filename, $sections['SKIPIF']); if ($this->shouldRunInSubprocess($sections, $skipIfCode)) { - $jobResult = JobRunnerRegistry::run( - new Job( - $skipIfCode, - $this->stringifyIni($settings), - ), + $jobResult = yield new Job( + $skipIfCode, + $this->stringifyIni($settings), ); $output = $jobResult->stdout(); - EventFacade::emitter()->childProcessFinished($output, $jobResult->stderr()); + $emitter->childProcessFinished($output, $jobResult->stderr()); } else { $output = $this->runCodeInLocalSandbox($skipIfCode); } - $this->triggerRunnerWarningOnPhpErrors('SKIPIF', $output); + $this->triggerRunnerWarningOnPhpErrors($emitter, 'SKIPIF', $output); if (strncasecmp('skip', ltrim($output), 4) === 0) { $message = ''; @@ -513,12 +551,12 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool $message = 'Skipped'; } - EventFacade::emitter()->testSkipped( + $emitter->testSkipped( $this->valueObjectForEvents(), $message, ); - EventFacade::emitter()->testFinished($this->valueObjectForEvents(), 0); + $emitter->testFinished($this->valueObjectForEvents(), 0); return true; } @@ -529,7 +567,7 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool !str_contains($output, 'Fatal error:') && !in_array(SideEffect::STANDARD_OUTPUT, $sideEffects, true) && !in_array(SideEffect::SCOPE_POLLUTION, $sideEffects, true)) { - EventFacade::emitter()->testConsideredRisky( + $emitter->testConsideredRisky( $this->valueObjectForEvents(), 'SKIPIF section does not produce output that could result in the test being skipped', ); @@ -593,8 +631,10 @@ private function runCodeInLocalSandbox(string $code): string /** * @param array $sections + * + * @return Generator */ - private function runClean(array $sections, bool $collectCoverage): void + private function executeClean(Emitter $emitter, array $sections, bool $collectCoverage): Generator { if (!isset($sections['CLEAN']) || $sections['CLEAN'] === '') { return; @@ -603,21 +643,19 @@ private function runClean(array $sections, bool $collectCoverage): void $cleanCode = (new Renderer)->render($this->filename, $sections['CLEAN']); if ($this->shouldRunInSubprocess($sections, $cleanCode)) { - $jobResult = JobRunnerRegistry::run( - new Job( - $cleanCode, - $this->settings($collectCoverage), - ), + $jobResult = yield new Job( + $cleanCode, + $this->settings($collectCoverage), ); $output = $jobResult->stdout(); - EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); + $emitter->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); } else { $output = $this->runCodeInLocalSandbox($cleanCode); } - $this->triggerRunnerWarningOnPhpErrors('CLEAN', $output); + $this->triggerRunnerWarningOnPhpErrors($emitter, 'CLEAN', $output); } /** @@ -878,10 +916,10 @@ private function settings(bool $collectCoverage): array return $settings; } - private function triggerRunnerWarningOnPhpErrors(string $section, string $output): void + private function triggerRunnerWarningOnPhpErrors(Emitter $emitter, string $section, string $output): void { if (str_contains($output, 'Parse error:')) { - EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + $emitter->testRunnerTriggeredPhpunitWarning( sprintf( '%s section triggered a parse error: %s', $section, @@ -891,7 +929,7 @@ private function triggerRunnerWarningOnPhpErrors(string $section, string $output } if (str_contains($output, 'Fatal error:')) { - EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + $emitter->testRunnerTriggeredPhpunitWarning( sprintf( '%s section triggered a fatal error: %s', $section, diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php index 51c4e1e6eb..ce4c65c433 100644 --- a/src/TextUI/ParallelTestRunner.php +++ b/src/TextUI/ParallelTestRunner.php @@ -28,6 +28,7 @@ use PHPUnit\Runner\CodeCoverage; use PHPUnit\Runner\Parallel\CompletedWorkUnit; use PHPUnit\Runner\Parallel\PersistentWorker; +use PHPUnit\Runner\Parallel\PhptRunner; use PHPUnit\Runner\Parallel\PhptWorkUnit; use PHPUnit\Runner\Parallel\ResultAggregator; use PHPUnit\Runner\Parallel\TestClassWorkUnit; @@ -100,8 +101,8 @@ public function run(Configuration $configuration, ResultCache $resultCache, Test $collected = $this->collectUnits($suite); - if ($collected['units'] !== [] || $collected['standalone'] !== []) { - $this->execute($configuration, $collected['units'], $collected['standalone']); + if ($collected['units'] !== [] || $collected['phpt'] !== [] || $collected['standalone'] !== []) { + $this->execute($configuration, $collected['units'], $collected['phpt'], $collected['standalone']); } Event\Facade::emitter()->testRunnerExecutionFinished(); @@ -140,11 +141,12 @@ public function run(Configuration $configuration, ResultCache $resultCache, Test * run in the main process, each at its own suite index. * * @param list $units + * @param list $phpt * @param list $standalone * * @throws WorkerException */ - private function execute(Configuration $configuration, array $units, array $standalone): void + private function execute(Configuration $configuration, array $units, array $phpt, array $standalone): void { $aggregator = new ResultAggregator( Event\Facade::instance(), @@ -192,6 +194,15 @@ static function () use ($test): void ); } + // The PHPT tests run concurrently in the main process, each as its own + // child process rather than nested inside a worker. Every one is + // registered with the aggregator so that the events it collected are + // replayed at its suite index, interspersed in global suite order with + // the worker units and the in-process units. + if ($phpt !== []) { + $this->runPhpt($configuration, $phpt, $aggregator); + } + // Run any in-process units that precede the first worker unit, then the // worker units (whose completions drive the release of the in-process // units interspersed among them), then any that follow the last one. @@ -204,6 +215,54 @@ static function () use ($test): void $aggregator->flush(); } + /** + * Run the PHPT tests concurrently, each as its own child process in the + * main process, and register every one with the aggregator so that the + * events it collected are replayed at its suite index, in global suite + * order. + * + * @param non-empty-list $phpt + */ + private function runPhpt(Configuration $configuration, array $phpt, ResultAggregator $aggregator): void + { + $concurrency = $configuration->numberOfParallelWorkers(); + + if (CodeCoverage::instance()->isActive()) { + // The PHPT tests run in the main process and share its single code + // coverage instance, so they must not collect coverage at the same + // time; they are therefore run one at a time when coverage is on. + $concurrency = 1; + } + + $processor = new ChildProcessResultProcessor( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + new PhptRunner(new JobRunner($processor), $concurrency)->run( + $phpt, + static function (int $index, Event\EventCollection $events) use ($aggregator): void + { + $aggregator->registerInProcessUnit( + $index, + static function () use ($events): void + { + Event\Facade::instance()->forward($events); + }, + ); + + // Release everything that has become contiguous in suite order + // so that progress is reported as the PHPT tests finish, rather + // than buffered until the whole phase is done. PHPT tests finish + // out of order, so this releases nothing until the next test in + // suite order is among those that have finished. + $aggregator->flush(); + }, + ); + } + /** * @param non-empty-list $units * @param positive-int $numberOfWorkers @@ -409,14 +468,14 @@ private function createPool(int $numberOfWorkers): WorkerPool * the main process. All draw from one shared index sequence so that they * can be released together in global suite order. * - * @return array{units: list, standalone: list} + * @return array{units: list, phpt: list, standalone: list} */ private function collectUnits(TestSuite $suite): array { /** @var array, array{index: non-negative-int, tests: list}> $byClass */ $byClass = []; - /** @var list $phpt */ + /** @var list $phpt */ $phpt = []; /** @var list $standalone */ @@ -432,19 +491,22 @@ private function collectUnits(TestSuite $suite): array $units[] = new TestClassWorkUnit($group['index'], $className, $group['tests']); } + $phptUnits = []; + foreach ($phpt as $item) { - $units[] = new PhptWorkUnit($item['index'], $item['file']); + $phptUnits[] = new PhptWorkUnit($item['index'], $item['file']); } return [ 'units' => $units, + 'phpt' => $phptUnits, 'standalone' => $standalone, ]; } /** * @param array, array{index: non-negative-int, tests: list}> $byClass - * @param list $phpt + * @param list $phpt * @param list $standalone * @param non-negative-int $index */ diff --git a/src/Util/PHP/JobRunner.php b/src/Util/PHP/JobRunner.php index 47524b1048..a776354204 100644 --- a/src/Util/PHP/JobRunner.php +++ b/src/Util/PHP/JobRunner.php @@ -89,6 +89,61 @@ public function runTestJob(Job $job, string $processResultFile, Test $test, ?str * @throws PhpProcessException */ public function run(Job $job): Result + { + return $this->startAsync($job)->wait(); + } + + /** + * Spawn the code of the given job as a long-running worker process whose + * standard input remains open as a control channel. + * + * The job's code is written to a temporary file that is executed by the + * worker process; in contrast to run(), the code is not piped through + * standard input, leaving it available for the caller to send subsequent + * commands to the worker. + * + * @throws PhpProcessException + */ + public function start(Job $job): RunningJob + { + $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($temporaryFile === false || + file_put_contents($temporaryFile, $job->code()) === false) { + // @codeCoverageIgnoreStart + throw new PhpProcessException( + 'Unable to write temporary file', + ); + // @codeCoverageIgnoreEnd + } + + return $this->startProcess($job, $temporaryFile); + } + + /** + * Start the given job as an asynchronous one-shot process. + * + * The job's code is piped through the process' standard input — exactly as + * the synchronous run() does, of which this is the non-blocking half — and + * its input, if the job carries a --STDIN-- section, is run from a temporary + * file instead so that standard input remains free to deliver that section. + * Standard input is closed once written; the returned RunningJob can be + * polled for completion and reaped with wait(). + * + * Feeding the code through standard input rather than from a temporary file + * (as start() does for the persistent worker, whose standard input is its + * control channel) keeps the child's __FILE__ out of the filesystem, which + * matters to tests that, for instance, restrict open_basedir. + * + * Unlike start(), the process' standard input is not left open as a control + * channel; unlike run(), the caller is not made to block until the process + * has terminated. This lets a caller keep several such processes running at + * the same time — for instance the PHPT test runner, which runs the child + * processes of independent PHPT tests concurrently. + * + * @throws PhpProcessException + */ + public function startAsync(Job $job): RunningJob { $temporaryFile = null; @@ -122,34 +177,7 @@ public function run(Job $job): Result $running->write($job->code()); $running->closeStdin(); - return $running->wait(); - } - - /** - * Spawn the code of the given job as a long-running worker process whose - * standard input remains open as a control channel. - * - * The job's code is written to a temporary file that is executed by the - * worker process; in contrast to run(), the code is not piped through - * standard input, leaving it available for the caller to send subsequent - * commands to the worker. - * - * @throws PhpProcessException - */ - public function start(Job $job): RunningJob - { - $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_'); - - if ($temporaryFile === false || - file_put_contents($temporaryFile, $job->code()) === false) { - // @codeCoverageIgnoreStart - throw new PhpProcessException( - 'Unable to write temporary file', - ); - // @codeCoverageIgnoreEnd - } - - return $this->startProcess($job, $temporaryFile); + return $running; } /** @@ -309,9 +337,13 @@ private function buildCommand(Job $job, ?string $file): array } if ($job->hasArguments()) { - if ($file === null) { - $command[] = '--'; - } + // The arguments are separated from the PHP options by "--" so that + // the PHP CLI passes them to the script rather than trying to parse + // them as options of its own. This is required whether the code is + // read from a file (php -f file -- args) or from standard input + // (php -- args); without it, an argument such as "--columns=80" is + // rejected by PHP as an unknown option. + $command[] = '--'; foreach ($job->arguments() as $argument) { $command[] = trim($argument); diff --git a/src/Util/PHP/RunningJob.php b/src/Util/PHP/RunningJob.php index 8128cbb922..98fbf1e2ee 100644 --- a/src/Util/PHP/RunningJob.php +++ b/src/Util/PHP/RunningJob.php @@ -15,6 +15,7 @@ use function fwrite; use function is_resource; use function proc_close; +use function proc_get_status; use function rewind; use function stream_get_contents; use function stream_set_blocking; @@ -140,6 +141,22 @@ public function readableStreams(): array return $streams; } + /** + * Whether the worker process is still executing. Once it has terminated, + * wait() can be called to reap it without blocking. Intended for callers + * that cannot wait on the process' output streams — for instance because + * its output is redirected to a file rather than a pipe — and therefore + * poll its liveness instead. + */ + public function isRunning(): bool + { + if ($this->result !== null) { + return false; + } + + return proc_get_status($this->process)['running']; + } + /** * Read whatever output is currently available without blocking. Intended * to be called after stream_select() has reported one of the streams diff --git a/tests/_files/parallel-worker/worker-skipped.phpt b/tests/_files/parallel-worker/worker-skipped.phpt new file mode 100644 index 0000000000..ca7b8e9726 --- /dev/null +++ b/tests/_files/parallel-worker/worker-skipped.phpt @@ -0,0 +1,10 @@ +--TEST-- +A PHPT test that is skipped in-process, without running a child process +--SKIPIF-- + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelPhptWithClasses; + +use PHPUnit\Framework\TestCase; + +final class SampleClassTest extends TestCase +{ + public function testPasses(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt b/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt new file mode 100644 index 0000000000..e33c1ae3c3 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt @@ -0,0 +1,7 @@ +--TEST-- +A passing PHPT test run in parallel +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +..F 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) %sbbb-failing.phpt +Failed asserting that two strings are equal. +--- Expected ++++ Actual +@@ @@ +-'expected output' ++'actual output' + +%sbbb-failing.phpt:7 + +FAILURES! +Tests: 3, Assertions: 3, Failures: 1. diff --git a/tests/end-to-end/retry/retry-phpt-cli-option.phpt b/tests/end-to-end/retry/retry-phpt-cli-option.phpt index 85ada5a8d9..c089346e65 100644 --- a/tests/end-to-end/retry/retry-phpt-cli-option.phpt +++ b/tests/end-to-end/retry/retry-phpt-cli-option.phpt @@ -1,5 +1,6 @@ --TEST-- The --retry CLI option attempts a PHPT test up to N times, stopping at the first success +--DO_NOT_RUN_IN_PARALLEL-- --FILE-- + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_keys; +use function ksort; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Event\Test\Passed; +use PHPUnit\Event\Test\Skipped; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(PhptRunner::class)] +#[CoversClass(PhptWorkUnit::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class PhptRunnerTest extends TestCase +{ + public function testRunsPhptTestsConcurrentlyAndReportsEachWithItsCollectedEvents(): void + { + $file = __DIR__ . '/../../../_files/parallel-worker/worker.phpt'; + + $units = [ + new PhptWorkUnit(0, $file), + new PhptWorkUnit(1, $file), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0, 1], array_keys($collected)); + + foreach ($collected as $events) { + $this->assertTrue($this->contains($events, Passed::class)); + } + } + + public function testRunsAPhptTestWhoseSectionsEachNeedTheirOwnChildProcess(): void + { + // The --INI-- section forces the --CLEAN-- section to run in a child + // process of its own, so this test's generator yields a second job + // after the --FILE-- job and exercises the runner's handling of a unit + // that is not finished by its first child process. + $units = [ + new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker-with-clean.phpt'), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0], array_keys($collected)); + $this->assertTrue($this->contains($collected[0], Passed::class)); + } + + public function testReportsAPhptTestThatNeedsNoChildProcessAtAll(): void + { + // This test is skipped by a --SKIPIF-- section that runs in-process, so + // its generator produces its events without ever yielding a job; the + // runner must report it just the same. + $units = [ + new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker-skipped.phpt'), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0], array_keys($collected)); + $this->assertTrue($this->contains($collected[0], Skipped::class)); + } + + public function testRunsPhptTestsOneAtATimeWhenConcurrencyIsOne(): void + { + $file = __DIR__ . '/../../../_files/parallel-worker/worker.phpt'; + + $units = [ + new PhptWorkUnit(0, $file), + new PhptWorkUnit(1, $file), + new PhptWorkUnit(2, $file), + ]; + + $collected = $this->execute($units, 1); + + $this->assertSame([0, 1, 2], array_keys($collected)); + + foreach ($collected as $events) { + $this->assertTrue($this->contains($events, Passed::class)); + } + } + + /** + * @param list $units + * @param positive-int $concurrency + * + * @return array + */ + private function execute(array $units, int $concurrency): array + { + $processor = new ChildProcessResultProcessor( + Facade::instance(), + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + $collected = []; + + new PhptRunner(new JobRunner($processor), $concurrency)->run( + $units, + static function (int $index, EventCollection $events) use (&$collected): void + { + $collected[$index] = $events; + }, + ); + + ksort($collected); + + return $collected; + } + + /** + * @param class-string $eventClass + */ + private function contains(EventCollection $events, string $eventClass): bool + { + foreach ($events as $event) { + if ($event instanceof $eventClass) { + return true; + } + } + + return false; + } +} diff --git a/tests/unit/Runner/Parallel/WorkerPoolTest.php b/tests/unit/Runner/Parallel/WorkerPoolTest.php index b93729e578..3dcb9c96ba 100644 --- a/tests/unit/Runner/Parallel/WorkerPoolTest.php +++ b/tests/unit/Runner/Parallel/WorkerPoolTest.php @@ -26,7 +26,6 @@ #[CoversClass(WorkerPool::class)] #[CoversClass(TestClassWorkUnit::class)] -#[CoversClass(PhptWorkUnit::class)] #[CoversClass(CompletedWorkUnit::class)] #[CoversClass(PersistentWorker::class)] #[UsesClass(JobRunner::class)] @@ -34,20 +33,6 @@ #[Large] final class WorkerPoolTest extends TestCase { - public function testRunsAPhptTestInAWorker(): void - { - $units = [ - new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker.phpt'), - ]; - - $completed = $this->execute($this->pool(1), $units); - - $this->assertCount(1, $completed); - $this->assertFalse($completed[0]->crashed()); - $this->assertNotSame('', $completed[0]->serializedResult()); - $this->assertNotNull($completed[0]->nonce()); - } - public function testRunsUnitsAcrossWorkersAndReportsEachAsCompleted(): void { $units = [ From a2483bfad7a3dd2911ae60cc0e1978e47c650f12 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 27 Jun 2026 08:52:13 +0200 Subject: [PATCH 06/14] Signal worker-pool completion through the filesystem and poll for it instead of multiplexing the workers' output pipes with stream_select(), which hangs on Windows --- src/Runner/Parallel/PersistentWorker.php | 182 ++++++++--------------- src/Runner/Parallel/WorkerPool.php | 99 ++++-------- src/Runner/Parallel/templates/worker.tpl | 12 +- 3 files changed, 97 insertions(+), 196 deletions(-) diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index dac29d0503..a4e6939e87 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -13,24 +13,18 @@ use function base64_encode; use function bin2hex; use function defined; -use function feof; -use function fgets; use function file_get_contents; use function get_include_path; use function hrtime; -use function is_resource; +use function is_file; use function json_encode; use function random_bytes; use function serialize; use function sprintf; -use function str_contains; -use function str_ends_with; -use function stream_get_contents; -use function stream_set_blocking; use function sys_get_temp_dir; use function tempnam; -use function trim; use function unlink; +use function usleep; use function var_export; use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Framework\TestCase; @@ -68,11 +62,10 @@ final class PersistentWorker { /** - * Marker written by the worker to its control channel to signal that a - * test has finished and its result has been written to the result file. - * Must be kept in sync with templates/worker.tpl. + * How long to sleep between polls of a busy worker, in microseconds, so + * that waiting for a worker to finish does not spin the CPU. */ - private const string DONE_PREFIX = 'PHPUNIT_WORKER_DONE:'; + private const int POLL_INTERVAL_MICROSECONDS = 1000; private readonly JobRunner $jobRunner; private readonly ChildProcessResultProcessor $processor; private ?RunningJob $job = null; @@ -85,17 +78,23 @@ final class PersistentWorker /** * The unit currently being executed by this worker, together with the * bookkeeping needed to harvest its result once the worker reports that it - * has finished. These are set by dispatch() and cleared by tick() (or by a + * has finished. These are set by dispatch() and cleared by poll() (or by a * detected crash); a null currentUnit means the worker is idle. + * + * Completion is signalled through the filesystem rather than the worker's + * standard output: once the worker has written the result file it creates a + * sibling "done" file, whose appearance the parent detects by polling. This + * avoids stream_select() and non-blocking reads on the worker's output pipe, + * neither of which works on Windows. */ private ?WorkUnit $currentUnit = null; /** * @var ?non-empty-string */ - private ?string $currentNonce = null; - private ?string $currentResultFile = null; - private string $controlChannelBuffer = ''; + private ?string $currentNonce = null; + private ?string $currentResultFile = null; + private ?string $currentDoneFile = null; /** * @var non-negative-int @@ -152,6 +151,8 @@ public function run(TestCase $test): void // @codeCoverageIgnoreEnd } + $doneFile = $resultFile . '.done'; + $command = [ 'command' => 'run', 'file' => $file, @@ -167,6 +168,7 @@ public function run(TestCase $test): void 'offsetSeconds' => $offset[0], 'offsetNanoseconds' => $offset[1], 'resultFile' => $resultFile, + 'doneFile' => $doneFile, 'nonce' => $nonce, ]; @@ -176,20 +178,26 @@ public function run(TestCase $test): void $this->job->write($encodedCommand . "\n"); - if (!$this->awaitResult($nonce)) { - $result = $this->job->wait(); - $this->job = null; + while (!is_file($doneFile)) { + if (!$this->job->isRunning()) { + $result = $this->job->wait(); + $this->job = null; - @unlink($resultFile); + @unlink($resultFile); + @unlink($doneFile); - $this->processor->process($test, '', $result->stderr(), $nonce); + $this->processor->process($test, '', $result->stderr(), $nonce); - return; + return; + } + + usleep(self::POLL_INTERVAL_MICROSECONDS); } $serializedResult = file_get_contents($resultFile); @unlink($resultFile); + @unlink($doneFile); if ($serializedResult === false) { // @codeCoverageIgnoreStart @@ -203,11 +211,10 @@ public function run(TestCase $test): void /** * Send a unit of work to the worker without waiting for it to finish. * - * The command is written to the worker's control channel and dispatch() - * returns immediately; the caller is expected to multiplex this worker's - * standard output with stream_select() and to call tick() once output - * becomes available, so that a single thread of control can keep several - * workers busy at the same time. + * The command is written to the worker's standard input and dispatch() + * returns immediately; the caller is expected to poll this worker with + * poll() until it reports completion, so that a single thread of control can + * keep several workers busy at the same time. * * @throws WorkerException */ @@ -228,16 +235,18 @@ public function dispatch(WorkUnit $unit): void assert($unit instanceof TestClassWorkUnit); - $command = $this->testClassCommand($unit, $offset, $resultFile, $nonce); + $doneFile = $resultFile . '.done'; + + $command = $this->testClassCommand($unit, $offset, $resultFile, $doneFile, $nonce); $encodedCommand = json_encode($command); assert($encodedCommand !== false); - $this->currentUnit = $unit; - $this->currentNonce = $nonce; - $this->currentResultFile = $resultFile; - $this->controlChannelBuffer = ''; + $this->currentUnit = $unit; + $this->currentNonce = $nonce; + $this->currentResultFile = $resultFile; + $this->currentDoneFile = $doneFile; $this->job->write($encodedCommand . "\n"); } @@ -260,60 +269,24 @@ public function isAlive(): bool } /** - * The worker's standard output, to be passed to stream_select() so that - * the caller can wait for the worker to report progress without blocking. + * Check, without blocking, whether the dispatched unit has finished and, if + * so, harvest its result; if the worker has died without finishing it, + * report the unit as crashed instead. * - * @return ?resource + * Returns null while the unit is still running. The caller is expected to + * call this repeatedly, sleeping briefly between rounds, until it returns a + * completed unit. */ - public function controlChannel(): mixed - { - if ($this->job === null) { - return null; - } - - return $this->job->stdout(); - } - - /** - * Consume whatever the worker has written to its control channel since the - * last call and, if the dispatched unit has finished, harvest its result. - * - * Returns null while the unit is still running. Intended to be called after - * stream_select() has reported the stream returned by controlChannel() as - * ready. - */ - public function tick(): ?CompletedWorkUnit + public function poll(): ?CompletedWorkUnit { assert($this->currentUnit !== null); - assert($this->currentNonce !== null); - - if ($this->job === null) { - // @codeCoverageIgnoreStart - return $this->crashed(); - // @codeCoverageIgnoreEnd - } - - $stdout = $this->job->stdout(); + assert($this->currentDoneFile !== null); - if (!is_resource($stdout)) { - // @codeCoverageIgnoreStart - return $this->crashed(); - // @codeCoverageIgnoreEnd - } - - stream_set_blocking($stdout, false); - - $chunk = stream_get_contents($stdout); - - if ($chunk !== false && $chunk !== '') { - $this->controlChannelBuffer .= $chunk; - } - - if (str_contains($this->controlChannelBuffer, self::DONE_PREFIX . $this->currentNonce)) { + if (is_file($this->currentDoneFile)) { return $this->finished(); } - if (feof($stdout)) { + if ($this->job === null || !$this->job->isRunning()) { return $this->crashed(); } @@ -347,13 +320,14 @@ public function stop(): void /** * @param array{0: int, 1: int} $offset * @param non-empty-string $resultFile + * @param non-empty-string $doneFile * @param non-empty-string $nonce * * @throws WorkerException * * @return array */ - private function testClassCommand(TestClassWorkUnit $unit, array $offset, string $resultFile, string $nonce): array + private function testClassCommand(TestClassWorkUnit $unit, array $offset, string $resultFile, string $doneFile, string $nonce): array { $class = new ReflectionClass($unit->className()); $file = $class->getFileName(); @@ -398,45 +372,11 @@ private function testClassCommand(TestClassWorkUnit $unit, array $offset, string 'offsetSeconds' => $offset[0], 'offsetNanoseconds' => $offset[1], 'resultFile' => $resultFile, + 'doneFile' => $doneFile, 'nonce' => $nonce, ]; } - /** - * Read the worker's control channel until it reports that the test - * identified by the given nonce has finished. Any unrelated output the - * worker may have written to the channel is skipped. Returns false if the - * worker terminated before reporting completion. - * - * The marker is the last thing the worker writes on its line, followed by - * a newline. Stray output that the test produced on the control channel - * without a trailing newline therefore fuses with the marker as a prefix - * of the same line; matching the marker as a suffix tolerates this instead - * of being defeated by it. - */ - private function awaitResult(string $nonce): bool - { - assert($this->job !== null); - - $stdout = $this->job->stdout(); - - if (!is_resource($stdout)) { - // @codeCoverageIgnoreStart - return false; - // @codeCoverageIgnoreEnd - } - - $expected = self::DONE_PREFIX . $nonce; - - while (($line = fgets($stdout)) !== false) { - if (str_ends_with(trim($line), $expected)) { - return true; - } - } - - return false; - } - /** * Harvest the result of the unit the worker has just reported as finished. */ @@ -444,10 +384,12 @@ private function finished(): CompletedWorkUnit { assert($this->currentUnit !== null); assert($this->currentResultFile !== null); + assert($this->currentDoneFile !== null); $serializedResult = file_get_contents($this->currentResultFile); @unlink($this->currentResultFile); + @unlink($this->currentDoneFile); if ($serializedResult === false) { // @codeCoverageIgnoreStart @@ -479,6 +421,10 @@ private function crashed(): CompletedWorkUnit @unlink($this->currentResultFile); } + if ($this->currentDoneFile !== null) { + @unlink($this->currentDoneFile); + } + $completed = new CompletedWorkUnit($this->currentUnit, '', null, true); if ($this->job !== null) { @@ -494,10 +440,10 @@ private function crashed(): CompletedWorkUnit private function clearCurrentUnit(): void { - $this->currentUnit = null; - $this->currentNonce = null; - $this->currentResultFile = null; - $this->controlChannelBuffer = ''; + $this->currentUnit = null; + $this->currentNonce = null; + $this->currentResultFile = null; + $this->currentDoneFile = null; } /** diff --git a/src/Runner/Parallel/WorkerPool.php b/src/Runner/Parallel/WorkerPool.php index b60c69b98c..9371224ea9 100644 --- a/src/Runner/Parallel/WorkerPool.php +++ b/src/Runner/Parallel/WorkerPool.php @@ -10,8 +10,7 @@ namespace PHPUnit\Runner\Parallel; use function array_shift; -use function is_resource; -use function stream_select; +use function usleep; /** * A fixed-size pool of PersistentWorkers across which units of work are @@ -21,11 +20,13 @@ * pre-partitioning of the units: whenever a worker becomes idle it pulls the * next unit from the queue, which self-balances the load against stragglers. * - * A single thread of control keeps all of the workers busy by multiplexing - * their control channels with stream_select(): it blocks until at least one - * worker has reported progress, harvests every worker that is ready, hands the - * finished units to the caller-supplied callback, and tops the idle workers up - * with more work until the queue is drained. + * A single thread of control keeps all of the workers busy by polling them in + * rounds: each round it harvests every worker that has finished its unit, hands + * those finished units to the caller-supplied callback, tops the idle workers + * up with more work, and sleeps briefly before the next round if none finished. + * A worker signals completion through the filesystem (see PersistentWorker), + * which is polled rather than waited on with stream_select() because the latter + * does not work on the workers' output pipes on Windows. * * If a worker dies, the unit it was running is reported to the callback as a * crashed unit and the dead worker is dropped from the pool; the remaining @@ -39,6 +40,12 @@ */ final class WorkerPool { + /** + * How long to sleep, in microseconds, when a polling round finds that no + * worker has finished, so that waiting on the workers does not spin the CPU. + */ + private const int POLL_INTERVAL_MICROSECONDS = 1000; + /** * @var non-empty-list */ @@ -82,15 +89,25 @@ public function run(array $units, callable $onCompleted): void break; } - $readable = $this->awaitReadable($busy); + $progressed = false; - foreach ($readable as $worker) { - $completed = $worker->tick(); + foreach ($busy as $worker) { + $completed = $worker->poll(); if ($completed !== null) { $onCompleted($completed); + + $progressed = true; } } + + // No worker finished this round: sleep briefly before polling again + // so that waiting for the workers does not spin the CPU. A worker + // that did finish is not slept on, so its freed slot is refilled at + // once on the next iteration. + if (!$progressed) { + usleep(self::POLL_INTERVAL_MICROSECONDS); + } } // Every worker has died while units remain to be run: account for the @@ -154,66 +171,4 @@ private function busyWorkers(): array return $busy; } - - /** - * Block until at least one of the busy workers has produced output on its - * control channel, then return those workers. - * - * @param non-empty-list $busy - * - * @return list - */ - private function awaitReadable(array $busy): array - { - $streams = []; - $byId = []; - - foreach ($busy as $worker) { - $stream = $worker->controlChannel(); - - if (!is_resource($stream)) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd - } - - $streams[] = $stream; - $byId[] = $worker; - } - - if ($streams === []) { - // @codeCoverageIgnoreStart - return $busy; - // @codeCoverageIgnoreEnd - } - - $write = null; - $except = null; - - $ready = @stream_select($streams, $write, $except, 1); - - if ($ready === false || $ready === 0) { - // @codeCoverageIgnoreStart - // A signal interrupted the wait, or it timed out: re-examine every - // busy worker on the next iteration rather than risk missing one. - return $busy; - // @codeCoverageIgnoreEnd - } - - $readable = []; - - foreach ($streams as $stream) { - foreach ($byId as $index => $worker) { - if ($worker->controlChannel() === $stream) { - $readable[] = $worker; - - unset($byId[$index]); - - break; - } - } - } - - return $readable; - } } diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl index 23121450f6..931071e1cb 100644 --- a/src/Runner/Parallel/templates/worker.tpl +++ b/src/Runner/Parallel/templates/worker.tpl @@ -12,10 +12,6 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\PassedTests; use PHPUnit\Util\DifferBuilder; -// The real standard output is captured as the worker's control channel before -// STDOUT is potentially redirected to capture the output produced by tests. -$__phpunit_worker_control = fopen('php://fd/1', 'wb'); - // php://stdout does not obey output buffering. Any output would break // unserialization of child process results in the parent process. if (!defined('STDOUT')) { @@ -217,6 +213,10 @@ while (($__phpunit_line = fgets($__phpunit_input)) !== false) { file_put_contents($__phpunit_command->resultFile, $__phpunit_result); - fwrite($__phpunit_worker_control, 'PHPUNIT_WORKER_DONE:' . $__phpunit_command->nonce . "\n"); - fflush($__phpunit_worker_control); + // Signal completion through the filesystem rather than standard output: the + // parent polls for this file, and because it is created only after the + // result file has been fully written, its presence means the result is + // ready to be read. This avoids the parent having to read the worker's + // output pipe, which cannot be done without blocking on Windows. + file_put_contents($__phpunit_command->doneFile, $__phpunit_command->nonce); } From 1dfd8a5e7a7a2b4ab7d5c22e8cc6b226321314eb Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 28 Jun 2026 08:26:20 +0200 Subject: [PATCH 07/14] Ignore defensive paths that cannot be tested from code coverage --- src/TextUI/ParallelTestRunner.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php index ce4c65c433..3a6fee6723 100644 --- a/src/TextUI/ParallelTestRunner.php +++ b/src/TextUI/ParallelTestRunner.php @@ -107,12 +107,14 @@ public function run(Configuration $configuration, ResultCache $resultCache, Test Event\Facade::emitter()->testRunnerExecutionFinished(); Event\Facade::emitter()->testRunnerFinished(); + // @codeCoverageIgnoreStart } catch (Throwable $t) { throw new RuntimeException( $t->getMessage(), (int) $t->getCode(), $t, ); + // @codeCoverageIgnoreEnd } } @@ -564,12 +566,14 @@ private function collect(TestSuite $suite, array &$byClass, array &$phpt, array // Any other kind of test cannot be reconstructed in a worker and is // run as a standalone unit in the main process. + // @codeCoverageIgnoreStart $standalone[] = [ 'index' => $index, 'test' => $test, ]; $index++; + // @codeCoverageIgnoreEnd } } From 6143052634e8ff33f5c32fbe0704fb21fab8930f Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 28 Jun 2026 08:30:13 +0200 Subject: [PATCH 08/14] Add tests --- phpunit.xml | 2 + .../invalid-value/_files/PlainTest.php | 20 +++++++ .../parallel-cli-option-invalid-value.phpt | 32 ++++++++++++ .../phpt-coverage/_files/greeter.phpt | 9 ++++ .../phpt-coverage/_files/src/Greeter.php | 18 +++++++ ...hpt-runs-one-at-a-time-under-coverage.phpt | 40 ++++++++++++++ .../_files/sequential-phpt-test.phpt | 8 +++ .../phpt-do-not-run-in-parallel.phpt | 22 ++++++++ .../_files/AlphaRandomOrderTest.php | 25 +++++++++ .../_files/BetaRandomOrderTest.php | 25 +++++++++ .../parallel/random-order/random-order.phpt | 25 +++++++++ .../_files/ClassLevelSeparateProcessTest.php | 22 ++++++++ .../_files/MethodLevelSeparateProcessTest.php | 22 ++++++++ .../separate-processes.phpt | 29 +++++++++++ .../_files/ClosedResourceDataTest.php | 36 +++++++++++++ .../serialization/_files/ClosureDataTest.php | 29 +++++++++++ .../_files/ObjectHoldingAResourceTest.php | 38 ++++++++++++++ .../_files/PlainObjectDataTest.php | 34 ++++++++++++ .../parallel/serialization/serialization.phpt | 22 ++++++++ .../unit/Runner/Parallel/PhptWorkUnitTest.php | 39 ++++++++++++++ .../Runner/Parallel/TestClassWorkUnitTest.php | 52 +++++++++++++++++++ tests/unit/Runner/Parallel/WorkerPoolTest.php | 20 +++++++ .../TextUI/Configuration/Cli/BuilderTest.php | 20 +++++++ tests/unit/Util/PHP/RunningJobTest.php | 25 +++++++++ 24 files changed, 614 insertions(+) create mode 100644 tests/end-to-end/parallel/invalid-value/_files/PlainTest.php create mode 100644 tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt create mode 100644 tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt create mode 100644 tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php create mode 100644 tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt create mode 100644 tests/end-to-end/parallel/phpt-do-not-run/_files/sequential-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-do-not-run/phpt-do-not-run-in-parallel.phpt create mode 100644 tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php create mode 100644 tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php create mode 100644 tests/end-to-end/parallel/random-order/random-order.phpt create mode 100644 tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php create mode 100644 tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php create mode 100644 tests/end-to-end/parallel/separate-processes/separate-processes.phpt create mode 100644 tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php create mode 100644 tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php create mode 100644 tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php create mode 100644 tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php create mode 100644 tests/end-to-end/parallel/serialization/serialization.phpt create mode 100644 tests/unit/Runner/Parallel/PhptWorkUnitTest.php create mode 100644 tests/unit/Runner/Parallel/TestClassWorkUnitTest.php diff --git a/phpunit.xml b/phpunit.xml index a3a5b6e44e..c7ab35039e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -50,6 +50,8 @@ tests/end-to-end/logging/_files tests/end-to-end/migration/_files tests/end-to-end/parallel/phpt/_files + tests/end-to-end/parallel/phpt-coverage/_files + tests/end-to-end/parallel/phpt-do-not-run/_files tests/end-to-end/parallel/phpt-with-classes/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files diff --git a/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php b/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php new file mode 100644 index 0000000000..d3b4306908 --- /dev/null +++ b/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelInvalidValue; + +use PHPUnit\Framework\TestCase; + +final class PlainTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt b/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt new file mode 100644 index 0000000000..fcf99d154c --- /dev/null +++ b/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt @@ -0,0 +1,32 @@ +--TEST-- +--parallel with a value that is not a positive integer triggers a warning and falls back to sequential execution +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit Started (PHPUnit %s using %s) +Test Runner Triggered PHPUnit Warning (Option "--parallel 0" ignored because "0" is not a positive integer) +Test Runner Configured +Event Facade Sealed +Test Suite Loaded (1 test) +Test Runner Started +Test Suite Sorted +Test Runner Execution Started (1 test) +Test Suite Started (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Prepared (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Passed (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Finished (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Suite Finished (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest, 1 test) +Test Runner Execution Finished +Test Runner Finished +PHPUnit Finished (Shell Exit Code: 1) diff --git a/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt b/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt new file mode 100644 index 0000000000..1e308b15a1 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt @@ -0,0 +1,9 @@ +--TEST-- +A PHPT test that exercises a source file so that code coverage is collected for it +--FILE-- +greet(); +--EXPECT-- +Hello diff --git a/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php b/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php new file mode 100644 index 0000000000..8800b71eb2 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelPhptCoverage; + +final class Greeter +{ + public function greet(): string + { + return 'Hello'; + } +} diff --git a/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt b/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt new file mode 100644 index 0000000000..2daebd6a77 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt @@ -0,0 +1,40 @@ +--TEST-- +phpunit --parallel=2 runs PHPT tests one at a time when code coverage is active so they do not collide on the shared coverage +--SKIPIF-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 1 assertion) + + +Code Coverage Report: + %s + + Summary: + Classes: 100.00% (1/1) + Methods: 100.00% (1/1) + Lines: 100.00% (1/1) + +PHPUnit\TestFixture\ParallelPhptCoverage\Greeter + Methods: 100.00% ( 1/ 1) Lines: 100.00% ( 1/ 1) diff --git a/tests/end-to-end/parallel/phpt-do-not-run/_files/sequential-phpt-test.phpt b/tests/end-to-end/parallel/phpt-do-not-run/_files/sequential-phpt-test.phpt new file mode 100644 index 0000000000..b5b012c327 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-do-not-run/_files/sequential-phpt-test.phpt @@ -0,0 +1,8 @@ +--TEST-- +A PHPT test that opts out of parallel execution with a --DO_NOT_RUN_IN_PARALLEL-- section +--DO_NOT_RUN_IN_PARALLEL-- +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 1 assertion) diff --git a/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php b/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php new file mode 100644 index 0000000000..1c6c7179c2 --- /dev/null +++ b/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.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\ParallelRandomOrder; + +use PHPUnit\Framework\TestCase; + +final class AlphaRandomOrderTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } + + public function testTwo(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php b/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php new file mode 100644 index 0000000000..fd2b7d6342 --- /dev/null +++ b/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.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\ParallelRandomOrder; + +use PHPUnit\Framework\TestCase; + +final class BetaRandomOrderTest extends TestCase +{ + public function testThree(): void + { + $this->assertTrue(true); + } + + public function testFour(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/random-order/random-order.phpt b/tests/end-to-end/parallel/random-order/random-order.phpt new file mode 100644 index 0000000000..a51f5c2919 --- /dev/null +++ b/tests/end-to-end/parallel/random-order/random-order.phpt @@ -0,0 +1,25 @@ +--TEST-- +phpunit --parallel=2 --random-order seeds the randomizer and runs the reordered test classes across the worker pool +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s +Random Seed: 54321 + +.... 4 / 4 (100%) + +Time: %s, Memory: %s + +OK (4 tests, 4 assertions) diff --git a/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php b/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php new file mode 100644 index 0000000000..e8c12a2bdf --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSeparateProcesses; + +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[RunTestsInSeparateProcesses] +final class ClassLevelSeparateProcessTest extends TestCase +{ + public function testClassLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php b/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php new file mode 100644 index 0000000000..b09f82bfe0 --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSeparateProcesses; + +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; + +final class MethodLevelSeparateProcessTest extends TestCase +{ + #[RunInSeparateProcess] + public function testMethodLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/separate-processes/separate-processes.phpt b/tests/end-to-end/parallel/separate-processes/separate-processes.phpt new file mode 100644 index 0000000000..d849c76799 --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/separate-processes.phpt @@ -0,0 +1,29 @@ +--TEST-- +phpunit --parallel=2 runs tests requiring process isolation in the main process instead of a worker +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +Class Level Separate Process (PHPUnit\TestFixture\ParallelSeparateProcesses\ClassLevelSeparateProcess) + ✔ Class level + +Method Level Separate Process (PHPUnit\TestFixture\ParallelSeparateProcesses\MethodLevelSeparateProcess) + ✔ Method level + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php b/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php new file mode 100644 index 0000000000..b04f5c65e9 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use function fclose; +use function fopen; +use function is_resource; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class ClosedResourceDataTest extends TestCase +{ + public static function closedResourceProvider(): array + { + $resource = fopen('php://memory', 'r'); + + fclose($resource); + + return [ + [$resource], + ]; + } + + #[DataProvider('closedResourceProvider')] + public function testReceivesAClosedResource(mixed $value): void + { + $this->assertFalse(is_resource($value)); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php b/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php new file mode 100644 index 0000000000..f52ebde7cd --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class ClosureDataTest extends TestCase +{ + public static function closureProvider(): array + { + return [ + [static fn (): bool => true], + ]; + } + + #[DataProvider('closureProvider')] + public function testReceivesAClosure(mixed $value): void + { + $this->assertIsCallable($value); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php b/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php new file mode 100644 index 0000000000..034f399b17 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use function fopen; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use stdClass; + +final class ObjectHoldingAResourceTest extends TestCase +{ + public static function objectProvider(): array + { + $object = new stdClass; + + // A reference back to the object itself exercises the cycle guard that + // keeps the resource walk from recursing forever. + $object->self = $object; + $object->resource = fopen('php://memory', 'r'); + + return [ + [$object], + ]; + } + + #[DataProvider('objectProvider')] + public function testReceivesAnObjectHoldingAResource(object $value): void + { + $this->assertIsResource($value->resource); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php b/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php new file mode 100644 index 0000000000..a00cbd1100 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use stdClass; + +final class PlainObjectDataTest extends TestCase +{ + public static function objectProvider(): array + { + $object = new stdClass; + + $object->value = 'plain'; + + return [ + [$object], + ]; + } + + #[DataProvider('objectProvider')] + public function testReceivesAPlainObject(object $value): void + { + $this->assertSame('plain', $value->value); + } +} diff --git a/tests/end-to-end/parallel/serialization/serialization.phpt b/tests/end-to-end/parallel/serialization/serialization.phpt new file mode 100644 index 0000000000..833f01e974 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/serialization.phpt @@ -0,0 +1,22 @@ +--TEST-- +phpunit --parallel=2 runs tests whose data cannot be transported to a worker in the main process and the rest in workers +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.... 4 / 4 (100%) + +Time: %s, Memory: %s + +OK (4 tests, 4 assertions) diff --git a/tests/unit/Runner/Parallel/PhptWorkUnitTest.php b/tests/unit/Runner/Parallel/PhptWorkUnitTest.php new file mode 100644 index 0000000000..64655df232 --- /dev/null +++ b/tests/unit/Runner/Parallel/PhptWorkUnitTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; + +#[CoversClass(PhptWorkUnit::class)] +#[Small] +final class PhptWorkUnitTest extends TestCase +{ + public function testHasIndex(): void + { + $this->assertSame(5, $this->unit()->index()); + } + + public function testHasFile(): void + { + $this->assertSame('/path/to/test.phpt', $this->unit()->file()); + } + + public function testIsNamedAfterItsFile(): void + { + $this->assertSame('/path/to/test.phpt', $this->unit()->name()); + } + + private function unit(): PhptWorkUnit + { + return new PhptWorkUnit(5, '/path/to/test.phpt'); + } +} diff --git a/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php b/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php new file mode 100644 index 0000000000..fe65541a31 --- /dev/null +++ b/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; + +#[CoversClass(TestClassWorkUnit::class)] +#[Small] +final class TestClassWorkUnitTest extends TestCase +{ + public function testHasIndex(): void + { + $this->assertSame(3, $this->unit()->index()); + } + + public function testHasClassName(): void + { + $this->assertSame(WorkerFirstTest::class, $this->unit()->className()); + } + + public function testHasTests(): void + { + $tests = $this->unit()->tests(); + + $this->assertCount(1, $tests); + $this->assertInstanceOf(WorkerFirstTest::class, $tests[0]); + } + + public function testIsNamedAfterItsClass(): void + { + $this->assertSame(WorkerFirstTest::class, $this->unit()->name()); + } + + private function unit(): TestClassWorkUnit + { + return new TestClassWorkUnit( + 3, + WorkerFirstTest::class, + [new WorkerFirstTest('testStartsTheProcessLocalCounter')], + ); + } +} diff --git a/tests/unit/Runner/Parallel/WorkerPoolTest.php b/tests/unit/Runner/Parallel/WorkerPoolTest.php index 3dcb9c96ba..4be0a53b39 100644 --- a/tests/unit/Runner/Parallel/WorkerPoolTest.php +++ b/tests/unit/Runner/Parallel/WorkerPoolTest.php @@ -65,6 +65,26 @@ public function testReportsACrashedUnitWhenItsWorkerDies(): void $this->assertTrue($completed[0]->crashed()); } + public function testReportsRemainingUnitsAsCrashedWhenEveryWorkerHasDied(): void + { + // The only worker dies on the first unit, so the second unit can no + // longer be dispatched anywhere; it must still be reported as crashed + // rather than silently lost. + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + new TestClassWorkUnit(1, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(2, $completed); + $this->assertSame([0, 1], $this->indexesOf($completed)); + + foreach ($completed as $unit) { + $this->assertTrue($unit->crashed()); + } + } + public function testReportsAUnitWhoseDataCannotBeSerializedAsCrashedAndKeepsRunning(): void { $test = new WorkerFirstTest('testStartsTheProcessLocalCounter'); diff --git a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php index 76ace63f49..70b4a4f786 100644 --- a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php +++ b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php @@ -1473,6 +1473,26 @@ public function testProcessIsolationMayNotBeConfigured(): void $configuration->processIsolation(); } + #[TestDox('--parallel')] + public function testParallel(): void + { + $configuration = (new Builder)->fromParameters(['--parallel', '4']); + + $this->assertTrue($configuration->hasNumberOfParallelWorkers()); + $this->assertSame(4, $configuration->numberOfParallelWorkers()); + } + + public function testNumberOfParallelWorkersMayNotBeConfigured(): void + { + $configuration = (new Builder)->fromParameters([]); + + $this->assertFalse($configuration->hasNumberOfParallelWorkers()); + + $this->expectException(Exception::class); + + $configuration->numberOfParallelWorkers(); + } + #[TestDox('--stderr')] public function testStderr(): void { diff --git a/tests/unit/Util/PHP/RunningJobTest.php b/tests/unit/Util/PHP/RunningJobTest.php index b3d4b334cc..4c00d68b38 100644 --- a/tests/unit/Util/PHP/RunningJobTest.php +++ b/tests/unit/Util/PHP/RunningJobTest.php @@ -53,6 +53,31 @@ public function testCollectsOutputOfASingleStartedJob(): void $this->assertSame($result, $job->wait()); } + public function testReportsWhetherTheProcessIsStillRunning(): void + { + $job = $this->jobRunner()->start( + new Job( + <<<'EOT' +assertTrue($job->isRunning()); + + $job->write("go\n"); + $job->closeStdin(); + $job->wait(); + + // Once the job has been reaped, its memoized result short-circuits the + // liveness poll. + $this->assertFalse($job->isRunning()); + } + public function testWritesToTheStandardInputOfTheJob(): void { $job = $this->jobRunner()->start( From feb1f2378502413188736fded7fc0e28891a74a9 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 28 Jun 2026 09:26:21 +0200 Subject: [PATCH 09/14] Replace --DO_NOT_RUN_IN_PARALLEL-- interim solution with proper --CONFLICTS-- implementation --- phpunit.xml | 2 +- src/Runner/Parallel/PhptRunner.php | 123 ++++++++++++++++-- src/Runner/Parallel/PhptWorkUnit.php | 30 ++++- src/Runner/Phpt/Parser.php | 28 ++++ src/TextUI/ParallelTestRunner.php | 81 ++++++------ .../invalid-configuration.phpt | 3 +- .../valid-configuration.phpt | 3 +- ...ml-depends-defects-duration-ascending.phpt | 3 +- ...n-ascending-test-methods-with-defects.phpt | 3 +- ...y-defects-does-not-hoist-skipped-test.phpt | 3 +- ...fects-preserves-top-level-suite-order.phpt | 3 +- ...fects-but-result-cache-does-not-exist.phpt | 3 +- ...-by-defects-test-classes-with-defects.phpt | 3 +- ...fects-but-result-cache-does-not-exist.phpt | 3 +- ...-by-defects-test-methods-with-defects.phpt | 3 +- ...methods-with-dependencies-and-defects.phpt | 3 +- ...asses-but-result-cache-does-not-exist.phpt | 3 +- ...er-by-duration-ascending-test-classes.phpt | 3 +- ...thods-but-result-cache-does-not-exist.phpt | 3 +- ...er-by-duration-ascending-test-methods.phpt | 3 +- ...asses-but-result-cache-does-not-exist.phpt | 3 +- ...r-by-duration-descending-test-classes.phpt | 3 +- ...thods-but-result-cache-does-not-exist.phpt | 3 +- ...r-by-duration-descending-test-methods.phpt | 3 +- ...asses-but-result-cache-does-not-exist.phpt | 3 +- .../order-by-duration-test-classes.phpt | 3 +- ...thods-but-result-cache-does-not-exist.phpt | 3 +- .../order-by-duration-test-methods.phpt | 3 +- .../migration/migration-from-100.phpt | 3 +- .../migration/migration-from-110.phpt | 3 +- ...igration-from-85-with-custom-filename.phpt | 3 +- .../migration/migration-from-85.phpt | 3 +- .../migration/migration-from-92.phpt | 3 +- .../migration/migration-from-95.phpt | 3 +- .../no-migration-needed-current-schema.phpt | 3 +- ...migration-needed-relative-schema-path.phpt | 3 +- ...ility-to-migrate-from-100-is-detected.phpt | 3 +- ...bility-to-migrate-from-85-is-detected.phpt | 3 +- ...bility-to-migrate-from-92-is-detected.phpt | 3 +- ...bility-to-migrate-from-95-is-detected.phpt | 3 +- .../migration/unsupported-schema.phpt | 3 +- .../also-conflicts-with-all-phpt-test.phpt | 9 ++ .../_files/conflicts-with-all-phpt-test.phpt | 9 ++ .../_files/first-conflicting-phpt-test.phpt | 12 ++ .../_files/plain-phpt-test.phpt | 7 + .../_files/second-conflicting-phpt-test.phpt | 9 ++ .../phpt-conflicts-with-all.phpt | 24 ++++ .../phpt-conflicts-with-shared-key.phpt} | 9 +- .../_files/sequential-phpt-test.phpt | 8 -- .../retry/retry-phpt-cli-option.phpt | 3 +- .../end-to-end/retry/retry-phpt-summary.phpt | 3 +- .../unit/Runner/Parallel/PhptWorkUnitTest.php | 12 ++ tests/unit/Runner/Phpt/ParserTest.php | 38 ++++++ 53 files changed, 411 insertions(+), 104 deletions(-) create mode 100644 tests/end-to-end/parallel/phpt-conflicts/_files/also-conflicts-with-all-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-conflicts/_files/conflicts-with-all-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-conflicts/_files/first-conflicting-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-conflicts/_files/plain-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-conflicts/_files/second-conflicting-phpt-test.phpt create mode 100644 tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-all.phpt rename tests/end-to-end/parallel/{phpt-do-not-run/phpt-do-not-run-in-parallel.phpt => phpt-conflicts/phpt-conflicts-with-shared-key.phpt} (50%) delete mode 100644 tests/end-to-end/parallel/phpt-do-not-run/_files/sequential-phpt-test.phpt diff --git a/phpunit.xml b/phpunit.xml index c7ab35039e..21df6e6c9a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -51,7 +51,7 @@ tests/end-to-end/migration/_files tests/end-to-end/parallel/phpt/_files tests/end-to-end/parallel/phpt-coverage/_files - tests/end-to-end/parallel/phpt-do-not-run/_files + tests/end-to-end/parallel/phpt-conflicts/_files tests/end-to-end/parallel/phpt-with-classes/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files diff --git a/src/Runner/Parallel/PhptRunner.php b/src/Runner/Parallel/PhptRunner.php index 7798142875..b3019de037 100644 --- a/src/Runner/Parallel/PhptRunner.php +++ b/src/Runner/Parallel/PhptRunner.php @@ -9,8 +9,9 @@ */ namespace PHPUnit\Runner\Parallel; -use function array_shift; +use function array_merge; use function count; +use function in_array; use function usleep; use Generator; use PHPUnit\Event\CollectingEmitter; @@ -48,6 +49,13 @@ * liveness instead, draining the pipes of the sections that do use them so that * a child never blocks on a full pipe buffer. * + * A test may declare conflict keys with a --CONFLICTS-- section: while a test + * that conflicts with key K is running, no other test that conflicts with K is + * started. The reserved key "all" conflicts with every other test, so a test + * that declares it runs entirely on its own; such tests are ordered last and + * run once the others have drained, mirroring how run-tests.php defers them + * until a single worker remains. + * * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * * @internal This class is not covered by the backward compatibility promise for PHPUnit @@ -80,13 +88,45 @@ public function __construct(JobRunner $jobRunner, int $concurrency) */ public function run(array $units, callable $onCompleted): void { - $queue = $units; + // Tests that conflict with "all" run on their own; ordering them last + // lets the others start first, so an "all" test only runs once the + // queue ahead of it has drained. + $queue = []; + $deferred = []; + + foreach ($units as $unit) { + if (in_array('all', $unit->conflicts(), true)) { + $deferred[] = $unit; + + continue; + } + + $queue[] = $unit; + } + + $queue = array_merge($queue, $deferred); + $active = []; - $nextId = 0; + + // The conflict keys currently held by a running test, and whether a + // test that conflicts with "all" is running (which blocks every other + // test from starting). + $activeConflicts = []; + $exclusive = false; + $nextId = 0; while ($queue !== [] || $active !== []) { - while (count($active) < $this->concurrency && $queue !== []) { - $unit = array_shift($queue); + foreach ($queue as $position => $unit) { + if (count($active) >= $this->concurrency || $exclusive) { + break; + } + + if (!$this->canStart($unit, $active, $activeConflicts)) { + continue; + } + + unset($queue[$position]); + $collector = EventFacade::instance()->collectingEmitter(); $generator = new PhptTestCase($unit->file())->execute($collector->emitter()); @@ -94,12 +134,15 @@ public function run(array $units, callable $onCompleted): void if (!$generator->valid()) { // The test produced its events without running any child - // process — a parse error, or a skip decided in-process. + // process — a parse error, or a skip decided in-process. It + // is already finished, so it reserves no conflict keys. $onCompleted($unit->index(), $collector->flush()); continue; } + $this->reserve($unit, $activeConflicts, $exclusive); + $active[$nextId] = [ 'unit' => $unit, 'generator' => $generator, @@ -114,15 +157,77 @@ public function run(array $units, callable $onCompleted): void continue; } - $this->harvest($active, $onCompleted); + $this->harvest($active, $onCompleted, $activeConflicts, $exclusive); + } + } + + /** + * Whether the unit may be started right now: a unit that conflicts with + * "all" may start only when nothing else is running, and any other unit may + * start only when none of its conflict keys are currently held. + * + * @param array, collector: CollectingEmitter, job: RunningJob}> $active + * @param array $activeConflicts + */ + private function canStart(PhptWorkUnit $unit, array $active, array $activeConflicts): bool + { + if (in_array('all', $unit->conflicts(), true)) { + return $active === []; + } + + foreach ($unit->conflicts() as $key) { + if (isset($activeConflicts[$key])) { + return false; + } + } + + return true; + } + + /** + * Record the conflict keys the unit holds while it runs. The reserved key + * "all" is tracked through the exclusive flag rather than the key map, + * because it blocks every other test rather than one sharing its key. + * + * @param array $activeConflicts + */ + private function reserve(PhptWorkUnit $unit, array &$activeConflicts, bool &$exclusive): void + { + foreach ($unit->conflicts() as $key) { + if ($key === 'all') { + $exclusive = true; + + continue; + } + + $activeConflicts[$key] = true; + } + } + + /** + * Release the conflict keys the unit held once it has finished. + * + * @param array $activeConflicts + */ + private function release(PhptWorkUnit $unit, array &$activeConflicts, bool &$exclusive): void + { + foreach ($unit->conflicts() as $key) { + if ($key === 'all') { + $exclusive = false; + + continue; + } + + unset($activeConflicts[$key]); } } /** * @param array, collector: CollectingEmitter, job: RunningJob}> $active * @param callable(non-negative-int, EventCollection): void $onCompleted + * @param array $activeConflicts */ - private function harvest(array &$active, callable $onCompleted): void + private function harvest(array &$active, callable $onCompleted, array &$activeConflicts, bool &$exclusive): void { $progressed = false; @@ -150,6 +255,8 @@ private function harvest(array &$active, callable $onCompleted): void $onCompleted($task['unit']->index(), $task['collector']->flush()); + $this->release($task['unit'], $activeConflicts, $exclusive); + unset($active[$id]); } diff --git a/src/Runner/Parallel/PhptWorkUnit.php b/src/Runner/Parallel/PhptWorkUnit.php index 5c2e9a346a..aa5b97da18 100644 --- a/src/Runner/Parallel/PhptWorkUnit.php +++ b/src/Runner/Parallel/PhptWorkUnit.php @@ -31,13 +31,20 @@ private string $file; /** - * @param non-negative-int $index - * @param non-empty-string $file + * @var list */ - public function __construct(int $index, string $file) + private array $conflicts; + + /** + * @param non-negative-int $index + * @param non-empty-string $file + * @param list $conflicts + */ + public function __construct(int $index, string $file, array $conflicts = []) { - $this->index = $index; - $this->file = $file; + $this->index = $index; + $this->file = $file; + $this->conflicts = $conflicts; } /** @@ -56,6 +63,19 @@ public function file(): string return $this->file; } + /** + * The conflict keys declared by the test's --CONFLICTS-- section. While a + * test that conflicts with key K is running, no other test that conflicts + * with K may run. The reserved key "all" conflicts with every other test, + * so a test that declares it runs on its own. + * + * @return list + */ + public function conflicts(): array + { + return $this->conflicts; + } + /** * @return non-empty-string */ diff --git a/src/Runner/Phpt/Parser.php b/src/Runner/Phpt/Parser.php index 6e15c942b1..980f5a228e 100644 --- a/src/Runner/Phpt/Parser.php +++ b/src/Runner/Phpt/Parser.php @@ -20,6 +20,7 @@ use function is_readable; use function is_string; use function preg_match; +use function preg_replace; use function realpath; use function rtrim; use function str_contains; @@ -170,6 +171,33 @@ public function parseIniSection(array|string $content, array $ini = []): array return $ini; } + /** + * The conflict keys declared by a --CONFLICTS-- section: one key per line, + * with "#" starting a comment and blank lines ignored. + * + * @return list + */ + public function parseConflictsSection(string $content): array + { + $content = preg_replace('/#.*/', '', $content); + + assert($content !== null); + + $conflicts = []; + + foreach (explode("\n", trim($content)) as $line) { + $key = trim($line); + + if ($key === '') { + continue; + } + + $conflicts[] = $key; + } + + return $conflicts; + } + /** * @param non-empty-string $phptFile * @param array $sections diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php index 3a6fee6723..8a59e669cc 100644 --- a/src/TextUI/ParallelTestRunner.php +++ b/src/TextUI/ParallelTestRunner.php @@ -9,14 +9,12 @@ */ namespace PHPUnit\TextUI; -use function file; use function get_parent_class; use function gettype; use function is_array; use function is_object; use function is_resource; use function mt_srand; -use function preg_match; use function serialize; use function spl_object_id; use PHPUnit\Event; @@ -26,6 +24,7 @@ use PHPUnit\Framework\TestSuite; use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; use PHPUnit\Runner\CodeCoverage; +use PHPUnit\Runner\Exception as PhptException; use PHPUnit\Runner\Parallel\CompletedWorkUnit; use PHPUnit\Runner\Parallel\PersistentWorker; use PHPUnit\Runner\Parallel\PhptRunner; @@ -35,6 +34,7 @@ use PHPUnit\Runner\Parallel\WorkerException; use PHPUnit\Runner\Parallel\WorkerPool; use PHPUnit\Runner\Parallel\WorkUnit; +use PHPUnit\Runner\Phpt\Parser; use PHPUnit\Runner\Phpt\TestCase as PhptTestCase; use PHPUnit\Runner\ResultCache\ResultCache; use PHPUnit\Runner\TestSuiteSorter; @@ -138,9 +138,11 @@ public function run(Configuration $configuration, ResultCache $resultCache, Test * in the main process is ordinary execution that behaves exactly as it would * in sequential mode. * - * Standalone tests that are not PHPUnit\Framework\TestCase instances — most - * commonly PHPT tests — cannot be reconstructed in a worker and are likewise - * run in the main process, each at its own suite index. + * PHPT tests are not PHPUnit\Framework\TestCase instances and cannot run in + * a worker, so they run concurrently in the main process, each as its own + * child process, honouring the conflict keys of their --CONFLICTS-- section. + * Any remaining standalone test — one that is neither a TestCase nor a PHPT + * test — is run in the main process at its own suite index. * * @param list $units * @param list $phpt @@ -477,7 +479,7 @@ private function collectUnits(TestSuite $suite): array /** @var array, array{index: non-negative-int, tests: list}> $byClass */ $byClass = []; - /** @var list $phpt */ + /** @var list}> $phpt */ $phpt = []; /** @var list $standalone */ @@ -496,7 +498,7 @@ private function collectUnits(TestSuite $suite): array $phptUnits = []; foreach ($phpt as $item) { - $phptUnits[] = new PhptWorkUnit($item['index'], $item['file']); + $phptUnits[] = new PhptWorkUnit($item['index'], $item['file'], $item['conflicts']); } return [ @@ -507,10 +509,10 @@ private function collectUnits(TestSuite $suite): array } /** - * @param array, array{index: non-negative-int, tests: list}> $byClass - * @param list $phpt - * @param list $standalone - * @param non-negative-int $index + * @param array, array{index: non-negative-int, tests: list}> $byClass + * @param list}> $phpt + * @param list $standalone + * @param non-negative-int $index */ private function collect(TestSuite $suite, array &$byClass, array &$phpt, array &$standalone, int &$index): void { @@ -541,23 +543,15 @@ private function collect(TestSuite $suite, array &$byClass, array &$phpt, array if ($test instanceof PhptTestCase) { $file = $test->toString(); - if ($this->phptMustNotRunInParallel($file)) { - // A PHPT test cannot carry the #[DoNotRunInParallel] - // attribute, so it opts out with a --DO_NOT_RUN_IN_PARALLEL-- - // section, which routes it to the main process. - $standalone[] = [ - 'index' => $index, - 'test' => $test, - ]; - } else { - // A PHPT test is not a PHPUnit\Framework\TestCase, but a - // worker can reconstruct it from its file path alone and run - // it just like the main process would. - $phpt[] = [ - 'index' => $index, - 'file' => $file, - ]; - } + // A PHPT test cannot carry the #[DoNotRunInParallel] attribute, + // so it declares any tests it must not run alongside with a + // --CONFLICTS-- section. The runner honours those conflict keys + // while running the PHPT tests concurrently in the main process. + $phpt[] = [ + 'index' => $index, + 'file' => $file, + 'conflicts' => $this->phptConflicts($file), + ]; $index++; @@ -578,26 +572,33 @@ private function collect(TestSuite $suite, array &$byClass, array &$phpt, array } /** - * Whether a PHPT test declares, with a --DO_NOT_RUN_IN_PARALLEL-- section, - * that it must not run in parallel — typically because it relies on a shared - * resource such as a fixed temporary file or the result cache. + * The conflict keys a PHPT test declares with a --CONFLICTS-- section. While + * a test that conflicts with a key is running, no other test that conflicts + * with the same key may run; the reserved key "all" conflicts with every + * other test. A test with no such section declares no conflicts. + * + * @param non-empty-string $file + * + * @return list */ - private function phptMustNotRunInParallel(string $file): bool + private function phptConflicts(string $file): array { - $lines = @file($file); + $parser = new Parser; - if ($lines === false) { + try { + $sections = $parser->parse($file); // @codeCoverageIgnoreStart - return false; + } catch (PhptException) { + // A malformed PHPT cannot meaningfully declare conflicts; it is run + // anyway and reports its own parse error at its suite position. + return []; // @codeCoverageIgnoreEnd } - foreach ($lines as $line) { - if (preg_match('/^--DO_NOT_RUN_IN_PARALLEL--/', $line) === 1) { - return true; - } + if (!isset($sections['CONFLICTS'])) { + return []; } - return false; + return $parser->parseConflictsSection($sections['CONFLICTS']); } } diff --git a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt index 1a4c123e78..e7d1c39557 100644 --- a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt +++ b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt @@ -1,6 +1,7 @@ --TEST-- phpunit --validate-configuration with invalid configuration file ---DO_NOT_RUN_IN_PARALLEL-- +--CONFLICTS-- +all --FILE-- run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +... 3 / 3 (100%) + +Time: %s, Memory: %s + +OK (3 tests, 3 assertions) diff --git a/tests/end-to-end/parallel/phpt-do-not-run/phpt-do-not-run-in-parallel.phpt b/tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt similarity index 50% rename from tests/end-to-end/parallel/phpt-do-not-run/phpt-do-not-run-in-parallel.phpt rename to tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt index 35e10d2e33..4b2bb373f8 100644 --- a/tests/end-to-end/parallel/phpt-do-not-run/phpt-do-not-run-in-parallel.phpt +++ b/tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt @@ -1,11 +1,12 @@ --TEST-- -phpunit --parallel=2 runs a PHPT test marked --DO_NOT_RUN_IN_PARALLEL-- in the main process instead of distributing it +phpunit --parallel=2 does not run two PHPT tests that share a --CONFLICTS-- key at the same time, but still runs both --FILE-- assertSame('/path/to/test.phpt', $this->unit()->name()); } + public function testHasNoConflictsByDefault(): void + { + $this->assertSame([], $this->unit()->conflicts()); + } + + public function testHasConflicts(): void + { + $unit = new PhptWorkUnit(5, '/path/to/test.phpt', ['all']); + + $this->assertSame(['all'], $unit->conflicts()); + } + private function unit(): PhptWorkUnit { return new PhptWorkUnit(5, '/path/to/test.phpt'); diff --git a/tests/unit/Runner/Phpt/ParserTest.php b/tests/unit/Runner/Phpt/ParserTest.php index 4cc1d121d7..cc5d88fe00 100644 --- a/tests/unit/Runner/Phpt/ParserTest.php +++ b/tests/unit/Runner/Phpt/ParserTest.php @@ -149,6 +149,44 @@ public function testParseIniSectionHandlesZendExtensionAsArray(): void $this->assertSame(['opcache.so', 'xdebug.so'], $result['zend_extension']); } + #[TestDox('parseConflictsSection() returns one key per line')] + public function testParseConflictsSectionReturnsOneKeyPerLine(): void + { + $parser = new Parser; + + $this->assertSame( + ['shared-resource', 'another-resource'], + $parser->parseConflictsSection("shared-resource\nanother-resource"), + ); + } + + #[TestDox('parseConflictsSection() recognizes the reserved key all')] + public function testParseConflictsSectionRecognizesAll(): void + { + $parser = new Parser; + + $this->assertSame(['all'], $parser->parseConflictsSection('all')); + } + + #[TestDox('parseConflictsSection() strips comments and ignores blank lines')] + public function testParseConflictsSectionStripsCommentsAndBlankLines(): void + { + $parser = new Parser; + + $this->assertSame( + ['shared-resource', 'another-resource'], + $parser->parseConflictsSection("# a comment\nshared-resource\n\n # indented comment\nanother-resource\n"), + ); + } + + #[TestDox('parseConflictsSection() returns no keys for an empty section')] + public function testParseConflictsSectionReturnsNoKeysForEmptySection(): void + { + $parser = new Parser; + + $this->assertSame([], $parser->parseConflictsSection("\n# only a comment\n")); + } + #[TestDox('FILE_EXTERNAL sets FILE_EXTERNAL_PATH to resolved path of external file')] public function testFileExternalPathIsSet(): void { From af5f3735f73f54b1b100fa6f2ed3aa4d8a7a3a1e Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 28 Jun 2026 12:59:48 +0200 Subject: [PATCH 10/14] Expose PHPUNIT_WORKER_TOKEN alongside PHPUNIT_WORKER_ID for per-worker resource partitioning --- src/Runner/Parallel/PersistentWorker.php | 15 ++++++++- .../worker-id/_files/WorkerIdentityTest.php | 32 +++++++++++++++++++ .../parallel/worker-id/worker-id.phpt | 22 +++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php create mode 100644 tests/end-to-end/parallel/worker-id/worker-id.phpt diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index a4e6939e87..4d8e94daae 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -101,6 +101,11 @@ final class PersistentWorker */ private readonly int $id; + /** + * @var non-empty-string + */ + private readonly string $token; + /** * @param non-negative-int $id */ @@ -109,6 +114,7 @@ public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $p $this->jobRunner = $jobRunner; $this->processor = $processor; $this->id = $id; + $this->token = $id . '_' . bin2hex(random_bytes(16)); } /** @@ -120,8 +126,15 @@ public function start(): void // fixtures can partition shared resources (a database, a port, a // temporary directory, ...) per worker and thereby avoid colliding with // the tests running concurrently in the other workers. + // + // Two identifiers are provided: PHPUNIT_WORKER_ID is the small, stable + // ordinal (0, 1, 2, ...) that is ideal for indexing a fixed set of + // pre-provisioned resources, while PHPUNIT_WORKER_TOKEN adds a value + // that is unique across workers and across runs, for resources that must + // not collide with those left behind by a previous run. $environmentVariables = [ - 'PHPUNIT_WORKER_ID' => (string) $this->id, + 'PHPUNIT_WORKER_ID' => (string) $this->id, + 'PHPUNIT_WORKER_TOKEN' => $this->token, ]; $this->job = $this->jobRunner->start( diff --git a/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php b/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php new file mode 100644 index 0000000000..426d09fafa --- /dev/null +++ b/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorkerIdentity; + +use function getenv; +use function strlen; +use function substr; +use PHPUnit\Framework\TestCase; + +final class WorkerIdentityTest extends TestCase +{ + public function testWorkerIdentityIsExposedToTheTest(): void + { + $id = getenv('PHPUNIT_WORKER_ID'); + $token = getenv('PHPUNIT_WORKER_TOKEN'); + + $this->assertIsString($id); + $this->assertMatchesRegularExpression('/^\d+$/', $id); + + $this->assertIsString($token); + $this->assertMatchesRegularExpression('/^\d+_[0-9a-f]{32}$/', $token); + + $this->assertSame($id . '_', substr($token, 0, strlen($id) + 1)); + } +} diff --git a/tests/end-to-end/parallel/worker-id/worker-id.phpt b/tests/end-to-end/parallel/worker-id/worker-id.phpt new file mode 100644 index 0000000000..1fdf5ad7f6 --- /dev/null +++ b/tests/end-to-end/parallel/worker-id/worker-id.phpt @@ -0,0 +1,22 @@ +--TEST-- +A worker exposes its identity to the tests it runs through the PHPUNIT_WORKER_ID and PHPUNIT_WORKER_TOKEN environment variables +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 5 assertions) From f6021782ba177db9d350233f97f2246f6e669eb0 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Mon, 29 Jun 2026 07:16:49 +0200 Subject: [PATCH 11/14] Remove the unused single-test worker protocol --- src/Runner/Parallel/PersistentWorker.php | 103 ++------------- src/Runner/Parallel/templates/worker.tpl | 67 +--------- src/TextUI/ParallelTestRunner.php | 4 +- .../Runner/Parallel/PersistentWorkerTest.php | 118 +++++++++++++----- tests/unit/Runner/Parallel/WorkerPoolTest.php | 2 +- 5 files changed, 97 insertions(+), 197 deletions(-) diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index 4d8e94daae..b06a0aac6a 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -24,11 +24,8 @@ use function sys_get_temp_dir; use function tempnam; use function unlink; -use function usleep; use function var_export; use PHPUnit\Event\Facade as EventFacade; -use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\TextUI\Configuration\SourceMapper; @@ -42,18 +39,20 @@ /** * A worker process that boots PHPUnit once and then executes an arbitrary * number of tests, each in response to a command received on its control - * channel (its standard input). + * channel (its standard input). A unit of work is a whole test class; the + * worker reconstructs it from the dispatched command, runs it, and reports the + * result back through the filesystem (see dispatch() and poll()). * * Unlike the SeparateProcessTestRunner, which spawns one process per test for * isolation, a PersistentWorker amortizes the cost of bootstrapping PHPUnit - * across all of the tests it runs. The tests executed by a single worker share + * across all of the units it runs. The tests executed by a single worker share * one process and therefore do not get the per-test global-state isolation that * process isolation provides; this is the trade-off that makes the worker * suitable as a building block for parallel test execution. * - * The result of each test is transported back to the parent process using the - * very same serialized envelope that process isolation uses, so that the - * ChildProcessResultProcessor can reconstitute it unchanged. + * The result of each unit is transported back to the parent process as the same + * kind of serialized envelope that process isolation uses, which the + * ResultAggregator decodes and replays into the parent's event subsystem. * * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * @@ -61,13 +60,7 @@ */ final class PersistentWorker { - /** - * How long to sleep between polls of a busy worker, in microseconds, so - * that waiting for a worker to finish does not spin the CPU. - */ - private const int POLL_INTERVAL_MICROSECONDS = 1000; private readonly JobRunner $jobRunner; - private readonly ChildProcessResultProcessor $processor; private ?RunningJob $job = null; /** @@ -109,10 +102,9 @@ final class PersistentWorker /** * @param non-negative-int $id */ - public function __construct(JobRunner $jobRunner, ChildProcessResultProcessor $processor, int $id = 0) + public function __construct(JobRunner $jobRunner, int $id = 0) { $this->jobRunner = $jobRunner; - $this->processor = $processor; $this->id = $id; $this->token = $id . '_' . bin2hex(random_bytes(16)); } @@ -142,85 +134,6 @@ public function start(): void ); } - /** - * @throws WorkerException - */ - public function run(TestCase $test): void - { - assert($this->job !== null); - - $class = new ReflectionClass($test); - $file = $class->getFileName(); - - assert($file !== false); - - $offset = hrtime(); - $nonce = bin2hex(random_bytes(16)); - $resultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); - - if ($resultFile === false) { - // @codeCoverageIgnoreStart - throw new WorkerException('Unable to create temporary file for the worker result'); - // @codeCoverageIgnoreEnd - } - - $doneFile = $resultFile . '.done'; - - $command = [ - 'command' => 'run', - 'file' => $file, - 'className' => $class->getName(), - 'methodName' => $test->name(), - 'data' => base64_encode(serialize($test->providedData())), - 'dataName' => $test->dataName(), - 'dependencyInput' => base64_encode(serialize($test->dependencyInput())), - 'repetition' => $test->repetition(), - 'totalRepetitions' => $test->totalRepetitions(), - 'attempt' => $test->attempt(), - 'maxAttempts' => $test->maxAttempts(), - 'offsetSeconds' => $offset[0], - 'offsetNanoseconds' => $offset[1], - 'resultFile' => $resultFile, - 'doneFile' => $doneFile, - 'nonce' => $nonce, - ]; - - $encodedCommand = json_encode($command); - - assert($encodedCommand !== false); - - $this->job->write($encodedCommand . "\n"); - - while (!is_file($doneFile)) { - if (!$this->job->isRunning()) { - $result = $this->job->wait(); - $this->job = null; - - @unlink($resultFile); - @unlink($doneFile); - - $this->processor->process($test, '', $result->stderr(), $nonce); - - return; - } - - usleep(self::POLL_INTERVAL_MICROSECONDS); - } - - $serializedResult = file_get_contents($resultFile); - - @unlink($resultFile); - @unlink($doneFile); - - if ($serializedResult === false) { - // @codeCoverageIgnoreStart - $serializedResult = ''; - // @codeCoverageIgnoreEnd - } - - $this->processor->process($test, $serializedResult, '', $nonce); - } - /** * Send a unit of work to the worker without waiting for it to finish. * diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl index 931071e1cb..ed73f6818a 100644 --- a/src/Runner/Parallel/templates/worker.tpl +++ b/src/Runner/Parallel/templates/worker.tpl @@ -1,6 +1,5 @@ initForIsolation( - PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( - $command->offsetSeconds, - $command->offsetNanoseconds - ), - ); - - require_once $command->file; - - $className = $command->className; - $methodName = $command->methodName; - - $test = new $className($methodName); - - $test->setData($command->dataName, unserialize(base64_decode($command->data))); - $test->setDependencyInput(unserialize(base64_decode($command->dependencyInput))); - $test->setRepetition($command->repetition, $command->totalRepetitions); - $test->setAttempt($command->attempt, $command->maxAttempts); - $test->setInIsolation(true); - - $test->run(); - - $output = ChildProcessOutputCollector::collect($test); - - $codeCoverage = null; - - if (CodeCoverage::instance()->isActive()) { - $codeCoverage = CodeCoverage::instance()->codeCoverage(); - } - - $result = $command->nonce . serialize( - (object) [ - 'testResult' => $test->result(), - 'status' => $test->status(), - 'codeCoverage' => $codeCoverage, - 'numAssertions' => $test->numberOfAssertionsPerformed(), - 'output' => $output, - 'events' => $dispatcher->flush(), - 'passedTests' => PassedTests::instance(), - ] - ); - - // Per-test code coverage has been collected for this command and is about - // to be shipped to the parent process. It is cleared here so that the next - // test executed by this worker does not ship it a second time. - if (CodeCoverage::instance()->isActive()) { - CodeCoverage::instance()->codeCoverage()->clear(); - } - - // Reset the stream that captures test output so that the next test does - // not inherit the output of the test that has just finished. - if (@rewind(STDOUT)) { - @ftruncate(STDOUT, 0); - } - - return $result; -} - function __phpunit_worker_run_unit(object $command): string { $dispatcher = Facade::instance()->initForIsolation( @@ -205,11 +144,7 @@ while (($__phpunit_line = fgets($__phpunit_input)) !== false) { break; } - if ($__phpunit_command->command === 'runUnit') { - $__phpunit_result = __phpunit_worker_run_unit($__phpunit_command); - } else { - $__phpunit_result = __phpunit_worker_run_test($__phpunit_command); - } + $__phpunit_result = __phpunit_worker_run_unit($__phpunit_command); file_put_contents($__phpunit_command->resultFile, $__phpunit_result); diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php index 8a59e669cc..8ac8fb2566 100644 --- a/src/TextUI/ParallelTestRunner.php +++ b/src/TextUI/ParallelTestRunner.php @@ -451,10 +451,10 @@ private function createPool(int $numberOfWorkers): WorkerPool $jobRunner = new JobRunner($processor); - $workers = [new PersistentWorker($jobRunner, $processor, 0)]; + $workers = [new PersistentWorker($jobRunner, 0)]; for ($id = 1; $id < $numberOfWorkers; $id++) { - $workers[] = new PersistentWorker($jobRunner, $processor, $id); + $workers[] = new PersistentWorker($jobRunner, $id); } return new WorkerPool($workers); diff --git a/tests/unit/Runner/Parallel/PersistentWorkerTest.php b/tests/unit/Runner/Parallel/PersistentWorkerTest.php index 1b4855072a..f83f16383e 100644 --- a/tests/unit/Runner/Parallel/PersistentWorkerTest.php +++ b/tests/unit/Runner/Parallel/PersistentWorkerTest.php @@ -9,8 +9,15 @@ */ namespace PHPUnit\Runner\Parallel; +use function strlen; +use function substr; +use function unserialize; +use function usleep; use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; use PHPUnit\Event\Facade; +use PHPUnit\Event\Test\Errored; +use PHPUnit\Event\Test\Failed; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Large; use PHPUnit\Framework\Attributes\UsesClass; @@ -24,51 +31,55 @@ use PHPUnit\Util\PHP\JobRunner; #[CoversClass(PersistentWorker::class)] +#[UsesClass(TestClassWorkUnit::class)] +#[UsesClass(CompletedWorkUnit::class)] #[UsesClass(JobRunner::class)] #[UsesClass(Job::class)] #[Large] final class PersistentWorkerTest extends TestCase { - public function testRunsMultipleTestsFromDifferentClassesInOneProcess(): void + public function testReusesOneProcessAcrossSequentiallyDispatchedUnits(): void { $worker = $this->worker(); $worker->start(); - $first = new WorkerFirstTest('testStartsTheProcessLocalCounter'); - $second = new WorkerSecondTest('testSeesTheStateLeftBehindByTheFirstTest'); + $first = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ); - $worker->run($first); - $worker->run($second); + // The second unit only passes if it ran in the same process as the + // first one, which is what reusing a single worker provides. + $second = $this->runToCompletion( + $worker, + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testSeesTheStateLeftBehindByTheFirstTest')]), + ); $worker->stop(); - $this->assertTrue($first->status()->isSuccess()); - $this->assertSame(1, $first->numberOfAssertionsPerformed()); + $this->assertFalse($first->crashed()); + $this->assertFalse($this->failedOrErrored($first)); - // The second test only passes if it ran in the same process as the - // first one, which is what reusing a single worker provides. - $this->assertTrue($second->status()->isSuccess()); - $this->assertSame(2, $second->numberOfAssertionsPerformed()); + $this->assertFalse($second->crashed()); + $this->assertFalse($this->failedOrErrored($second)); } - public function testReportsFailingTestsBackToTheParentProcess(): void + public function testReportsAFailingTestThroughTheResultEnvelopeRatherThanAsACrash(): void { $worker = $this->worker(); $worker->start(); - $failing = new WorkerSecondTest('testThatFails'); - - $worker->run($failing); + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ); $worker->stop(); - $this->assertTrue($failing->status()->isFailure()); - $this->assertStringContainsString( - 'intentional failure inside a persistent worker', - $failing->status()->message(), - ); + $this->assertFalse($completed->crashed()); + $this->assertTrue($this->failedOrErrored($completed)); } public function testRecognizesCompletionEvenWhenATestLeavesStrayOutputOnTheControlChannel(): void @@ -77,31 +88,72 @@ public function testRecognizesCompletionEvenWhenATestLeavesStrayOutputOnTheContr $worker->start(); - $stray = new WorkerSecondTest('testThatWritesStrayOutputWithoutANewlineToTheControlChannel'); - - $worker->run($stray); + // The test writes stray bytes to file descriptor 1 without a trailing + // newline; the worker must still report the unit as finished and ship a + // result envelope that is not corrupted by that output. + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatWritesStrayOutputWithoutANewlineToTheControlChannel')]), + ); $worker->stop(); - // The completion marker fuses with the stray output the test wrote to - // the control channel without a trailing newline; the worker is only - // reported as succeeding if the parent still recognizes the marker. - $this->assertTrue($stray->status()->isSuccess()); + $this->assertFalse($completed->crashed()); + $this->assertFalse($this->failedOrErrored($completed)); } - public function testReportsAnErrorWhenTheWorkerDiesWhileRunningATest(): void + public function testReportsACrashWhenTheWorkerDiesWhileRunningAUnit(): void { $worker = $this->worker(); $worker->start(); - $crashing = new WorkerSecondTest('testThatKillsTheWorkerProcess'); - - $worker->run($crashing); + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + ); $worker->stop(); - $this->assertTrue($crashing->status()->isError()); + $this->assertTrue($completed->crashed()); + } + + private function runToCompletion(PersistentWorker $worker, TestClassWorkUnit $unit): CompletedWorkUnit + { + $worker->dispatch($unit); + + while (true) { + $completed = $worker->poll(); + + if ($completed !== null) { + return $completed; + } + + usleep(1000); + } + } + + private function failedOrErrored(CompletedWorkUnit $completed): bool + { + foreach ($this->eventsOf($completed) as $event) { + if ($event instanceof Failed || $event instanceof Errored) { + return true; + } + } + + return false; + } + + private function eventsOf(CompletedWorkUnit $completed): EventCollection + { + $nonce = (string) $completed->nonce(); + $serialized = substr($completed->serializedResult(), strlen($nonce)); + $envelope = unserialize($serialized); + + $this->assertIsObject($envelope); + $this->assertInstanceOf(EventCollection::class, $envelope->events); + + return $envelope->events; } private function worker(): PersistentWorker @@ -113,6 +165,6 @@ private function worker(): PersistentWorker new CodeCoverage, ); - return new PersistentWorker(new JobRunner($processor), $processor); + return new PersistentWorker(new JobRunner($processor)); } } diff --git a/tests/unit/Runner/Parallel/WorkerPoolTest.php b/tests/unit/Runner/Parallel/WorkerPoolTest.php index 4be0a53b39..48c012ea6a 100644 --- a/tests/unit/Runner/Parallel/WorkerPoolTest.php +++ b/tests/unit/Runner/Parallel/WorkerPoolTest.php @@ -180,7 +180,7 @@ private function pool(int $numberOfWorkers): WorkerPool $workers = []; for ($id = 0; $id < $numberOfWorkers; $id++) { - $workers[] = new PersistentWorker($jobRunner, $processor, $id); + $workers[] = new PersistentWorker($jobRunner, $id); } return new WorkerPool($workers); From f745c54e15dcee4a4a0a12fc2fc1ed4b1acbd8dc Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Mon, 29 Jun 2026 07:23:05 +0200 Subject: [PATCH 12/14] Move duplicated retry attempt-event helpers up into IterativeTestSuite --- src/Framework/IterativeTestSuite.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Framework/IterativeTestSuite.php b/src/Framework/IterativeTestSuite.php index 089c2fb967..fe705b1fb8 100644 --- a/src/Framework/IterativeTestSuite.php +++ b/src/Framework/IterativeTestSuite.php @@ -122,6 +122,12 @@ final protected function dependencies(): array */ abstract protected function execute(array $tests, Event\Emitter $emitter): void; + /** + * Synthesize a test attempt event from the events an attempt collected, so + * that a failed or errored attempt that is going to be retried is reported + * as an attempt rather than as the test's final outcome. Returns whether + * such an event could be determined from the collected events. + */ final protected function emitAttemptEvent(EventCollection $events, Event\Emitter $emitter): bool { $duration = $this->durationOf($events); From 949b556afa1f88e71b3b2e4f57a79d90734cecba Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Mon, 29 Jun 2026 07:54:10 +0200 Subject: [PATCH 13/14] Extract shared child-process result-envelope handling into one helper --- .../TestRunner/ChildProcessResultEnvelope.php | 80 +++++++++++++++++++ .../ChildProcessResultProcessor.php | 58 +++++--------- src/Runner/Parallel/ResultAggregator.php | 49 ++++-------- 3 files changed, 114 insertions(+), 73 deletions(-) create mode 100644 src/Framework/TestRunner/ChildProcessResultEnvelope.php diff --git a/src/Framework/TestRunner/ChildProcessResultEnvelope.php b/src/Framework/TestRunner/ChildProcessResultEnvelope.php new file mode 100644 index 0000000000..3a80951068 --- /dev/null +++ b/src/Framework/TestRunner/ChildProcessResultEnvelope.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\TestRunner; + +use function hash_equals; +use function strlen; +use function substr; +use PHPUnit\Runner\CodeCoverage; +use SebastianBergmann\CodeCoverage\CodeCoverage as CodeCoverageData; + +/** + * The mechanics shared by the two consumers of the serialized result envelope + * that a process-isolated child or a parallel worker writes back to the parent: + * the ChildProcessResultProcessor and the parallel ResultAggregator. + * + * Only the parts that are identical between the two live here. The shape each + * one requires of the decoded envelope, and the way each reports a malformed + * result, differ and stay with each consumer. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ChildProcessResultEnvelope +{ + /** + * Verify the nonce that prefixes the serialized result and strip it, + * returning the bare payload. + * + * A null nonce or an empty payload is passed through unverified — the caller + * then fails on the empty or unexpected payload when it tries to decode it. + * Null is returned only when a nonce was expected but the prefix does not + * match it, which means the result was written by an unexpected process or + * was tampered with. + * + * @param ?non-empty-string $nonce + */ + public static function verifyAndStripNonce(string $serialized, ?string $nonce): ?string + { + if ($nonce === null || $serialized === '') { + return $serialized; + } + + $length = strlen($nonce); + + if (strlen($serialized) < $length || + !hash_equals($nonce, substr($serialized, 0, $length))) { + return null; + } + + return substr($serialized, $length); + } + + /** + * Merge the code coverage carried by a decoded result envelope into the + * parent's coverage, when coverage collection is active and the envelope + * actually carries it. + */ + public static function mergeCodeCoverage(object $result, CodeCoverage $codeCoverage): void + { + if (!$codeCoverage->isActive()) { + return; + } + + // @codeCoverageIgnoreStart + if (!isset($result->codeCoverage) || !$result->codeCoverage instanceof CodeCoverageData) { + return; + } + + CodeCoverage::instance()->codeCoverage()->merge($result->codeCoverage); + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Framework/TestRunner/ChildProcessResultProcessor.php b/src/Framework/TestRunner/ChildProcessResultProcessor.php index 9e3418b8ef..4bad22a2dd 100644 --- a/src/Framework/TestRunner/ChildProcessResultProcessor.php +++ b/src/Framework/TestRunner/ChildProcessResultProcessor.php @@ -10,11 +10,8 @@ namespace PHPUnit\Framework\TestRunner; use function assert; -use function hash_equals; use function is_int; use function property_exists; -use function strlen; -use function substr; use function trim; use function unserialize; use PHPUnit\Event\Code\TestMethodBuilder; @@ -81,38 +78,33 @@ public function process(Test $test, string $serializedProcessResult, string $std return; } - if ($processResultNonce !== null && $serializedProcessResult !== '') { - $nonceLength = strlen($processResultNonce); + $verifiedProcessResult = ChildProcessResultEnvelope::verifyAndStripNonce($serializedProcessResult, $processResultNonce); - if (strlen($serializedProcessResult) < $nonceLength || - !hash_equals($processResultNonce, substr($serializedProcessResult, 0, $nonceLength))) { - $this->emitter->childProcessErrored(); - - $exception = new AssertionFailedError( - 'Test was run in child process and the result file was tampered with or written by an unexpected process', - ); + if ($verifiedProcessResult === null) { + $this->emitter->childProcessErrored(); - assert($test instanceof TestCase); + $exception = new AssertionFailedError( + 'Test was run in child process and the result file was tampered with or written by an unexpected process', + ); - $test->setStatus(TestStatus::error($exception->getMessage())); + assert($test instanceof TestCase); - $this->emitter->testErrored( - TestMethodBuilder::fromTestCase($test), - ThrowableBuilder::from($exception), - ); + $test->setStatus(TestStatus::error($exception->getMessage())); - $this->emitter->testFinished( - TestMethodBuilder::fromTestCase($test), - 0, - ); + $this->emitter->testErrored( + TestMethodBuilder::fromTestCase($test), + ThrowableBuilder::from($exception), + ); - return; - } + $this->emitter->testFinished( + TestMethodBuilder::fromTestCase($test), + 0, + ); - $serializedProcessResult = substr($serializedProcessResult, $nonceLength); + return; } - $childResult = @unserialize($serializedProcessResult); + $childResult = @unserialize($verifiedProcessResult); if (!$childResult instanceof stdClass || !property_exists($childResult, 'events') || @@ -155,18 +147,6 @@ public function process(Test $test, string $serializedProcessResult, string $std $test->setStatus($childResult->status); $test->addToAssertionCount($childResult->numAssertions); - if (!$this->codeCoverage->isActive()) { - return; - } - - // @codeCoverageIgnoreStart - if (!isset($childResult->codeCoverage) || !$childResult->codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - return; - } - - CodeCoverage::instance()->codeCoverage()->merge( - $childResult->codeCoverage, - ); - // @codeCoverageIgnoreEnd + ChildProcessResultEnvelope::mergeCodeCoverage($childResult, $this->codeCoverage); } } diff --git a/src/Runner/Parallel/ResultAggregator.php b/src/Runner/Parallel/ResultAggregator.php index ae65cd3c73..a4316081bc 100644 --- a/src/Runner/Parallel/ResultAggregator.php +++ b/src/Runner/Parallel/ResultAggregator.php @@ -9,15 +9,13 @@ */ namespace PHPUnit\Runner\Parallel; -use function hash_equals; use function property_exists; use function sprintf; -use function strlen; -use function substr; use function unserialize; use PHPUnit\Event\Emitter; use PHPUnit\Event\EventCollection; use PHPUnit\Event\Facade; +use PHPUnit\Framework\TestRunner\ChildProcessResultEnvelope; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TestRunner\TestResult\PassedTests; use stdClass; @@ -164,26 +162,21 @@ private function forward(CompletedWorkUnit $completed): void return; } - $serializedResult = $completed->serializedResult(); - $nonce = $completed->nonce(); - - if ($nonce !== null && $serializedResult !== '') { - $nonceLength = strlen($nonce); - - if (strlen($serializedResult) < $nonceLength || - !hash_equals($nonce, substr($serializedResult, 0, $nonceLength))) { - $this->emitter->childProcessErrored(); - $this->emitter->testRunnerTriggeredPhpunitWarning( - sprintf( - 'The result of the worker process running %s was tampered with or written by an unexpected process', - $completed->unit()->name(), - ), - ); + $serializedResult = ChildProcessResultEnvelope::verifyAndStripNonce( + $completed->serializedResult(), + $completed->nonce(), + ); - return; - } + if ($serializedResult === null) { + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning( + sprintf( + 'The result of the worker process running %s was tampered with or written by an unexpected process', + $completed->unit()->name(), + ), + ); - $serializedResult = substr($serializedResult, $nonceLength); + return; } $childResult = @unserialize($serializedResult); @@ -207,18 +200,6 @@ private function forward(CompletedWorkUnit $completed): void $this->eventFacade->forward($childResult->events); $this->passedTests->import($childResult->passedTests); - if (!$this->codeCoverage->isActive()) { - return; - } - - // @codeCoverageIgnoreStart - if (!isset($childResult->codeCoverage) || !$childResult->codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - return; - } - - CodeCoverage::instance()->codeCoverage()->merge( - $childResult->codeCoverage, - ); - // @codeCoverageIgnoreEnd + ChildProcessResultEnvelope::mergeCodeCoverage($childResult, $this->codeCoverage); } } From cf8f973bbfa0592744a38dbc2e288eb6eefac845 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 2 Jul 2026 10:32:55 +0200 Subject: [PATCH 14/14] Run the repetitions of a repeated test and the attempts of a retried test through their IterativeTestSuite when tests are run in parallel --- phpunit.xml | 1 + src/Event/Dispatcher/CollectingDispatcher.php | 33 ++++++ src/Event/Facade.php | 32 +++++- src/Framework/RepeatTestSuite.php | 8 ++ src/Runner/Parallel/PersistentWorker.php | 103 ++++++++++++++---- src/Runner/Parallel/TestClassWorkUnit.php | 16 ++- src/Runner/Parallel/templates/worker.tpl | 72 ++++++++++-- src/TextUI/ParallelTestRunner.php | 86 +++++++++++++-- .../repeat-retry/_files/FlakyTest.php | 35 ++++++ .../_files/FlakyWithDataProviderTest.php | 47 ++++++++ .../_files/RepeatFailingPhpt.phpt | 7 ++ .../repeat-retry/_files/RepeatFailureTest.php | 28 +++++ .../repeat-retry/repeat-in-worker.phpt | 32 ++++++ .../repeat-phpt-in-main-process.phpt | 37 +++++++ .../repeat-retry/retry-in-worker.phpt | 27 +++++ .../retry-with-data-provider-in-worker.phpt | 27 +++++ 16 files changed, 540 insertions(+), 51 deletions(-) create mode 100644 tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php create mode 100644 tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php create mode 100644 tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt create mode 100644 tests/end-to-end/parallel/repeat-retry/_files/RepeatFailureTest.php create mode 100644 tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt create mode 100644 tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt create mode 100644 tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt create mode 100644 tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt diff --git a/phpunit.xml b/phpunit.xml index 21df6e6c9a..035f504a73 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -53,6 +53,7 @@ tests/end-to-end/parallel/phpt-coverage/_files tests/end-to-end/parallel/phpt-conflicts/_files tests/end-to-end/parallel/phpt-with-classes/_files + tests/end-to-end/parallel/repeat-retry/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files tests/end-to-end/self-direct-indirect/_files diff --git a/src/Event/Dispatcher/CollectingDispatcher.php b/src/Event/Dispatcher/CollectingDispatcher.php index e3e9462ee6..49dc016b5d 100644 --- a/src/Event/Dispatcher/CollectingDispatcher.php +++ b/src/Event/Dispatcher/CollectingDispatcher.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\Event; +use function assert; use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector; use PHPUnit\Runner\DeprecationCollector\TestTriggeredDeprecationSubscriber; @@ -21,6 +22,7 @@ final class CollectingDispatcher implements Dispatcher { private EventCollection $events; private DirectDispatcher $isolatedDirectDispatcher; + private ?EventCollection $collectedEvents = null; public function __construct(DirectDispatcher $directDispatcher) { @@ -32,6 +34,12 @@ public function __construct(DirectDispatcher $directDispatcher) public function dispatch(Event $event): void { + if ($this->collectedEvents !== null) { + $this->collectedEvents->add($event); + + return; + } + $this->events->add($event); try { @@ -41,6 +49,31 @@ public function dispatch(Event $event): void } } + /** + * Open a collection window: until stopCollectingEvents() is called, events + * are diverted into a separate collection instead of being recorded and + * dispatched. This mirrors the collection window of the DeferringDispatcher + * so that a RetryTestSuite can suppress the events of a failed attempt when + * it runs in a process whose event facade was initialized for isolation. + */ + public function startCollectingEvents(): void + { + assert($this->collectedEvents === null); + + $this->collectedEvents = new EventCollection; + } + + public function stopCollectingEvents(): EventCollection + { + assert($this->collectedEvents !== null); + + $events = $this->collectedEvents; + + $this->collectedEvents = null; + + return $events; + } + public function flush(): EventCollection { $events = $this->events; diff --git a/src/Event/Facade.php b/src/Event/Facade.php index 7ce54255a1..a821a7cd9d 100644 --- a/src/Event/Facade.php +++ b/src/Event/Facade.php @@ -24,9 +24,10 @@ final class Facade { private static ?self $instance = null; private Emitter $emitter; - private ?TypeMap $typeMap = null; - private ?DeferringDispatcher $deferringDispatcher = null; - private bool $sealed = false; + private ?TypeMap $typeMap = null; + private ?DeferringDispatcher $deferringDispatcher = null; + private ?CollectingDispatcher $isolationDispatcher = null; + private bool $sealed = false; public static function instance(): self { @@ -108,6 +109,8 @@ public function initForIsolation(HRTime $offset): CollectingDispatcher $this->sealed = true; + $this->isolationDispatcher = $dispatcher; + return $dispatcher; } @@ -135,20 +138,41 @@ public function collectingEmitter(): CollectingEmitter public function forward(EventCollection $events): void { - $dispatcher = $this->deferredDispatcher(); + if ($this->isolationDispatcher !== null) { + $dispatcher = $this->isolationDispatcher; + } else { + $dispatcher = $this->deferredDispatcher(); + } foreach ($events as $event) { $dispatcher->dispatch($event); } } + /** + * In a process whose event facade was initialized for isolation — a + * parallel test runner worker, for example — the collection window is + * opened on the isolation dispatcher, because that is the dispatcher the + * emitter dispatches to there; the deferring dispatcher is unused in such + * a process. + */ public function startCollectingEvents(): void { + if ($this->isolationDispatcher !== null) { + $this->isolationDispatcher->startCollectingEvents(); + + return; + } + $this->deferredDispatcher()->startCollectingEvents(); } public function stopCollectingEvents(): EventCollection { + if ($this->isolationDispatcher !== null) { + return $this->isolationDispatcher->stopCollectingEvents(); + } + return $this->deferredDispatcher()->stopCollectingEvents(); } diff --git a/src/Framework/RepeatTestSuite.php b/src/Framework/RepeatTestSuite.php index b25a9ea34e..8fe431457a 100644 --- a/src/Framework/RepeatTestSuite.php +++ b/src/Framework/RepeatTestSuite.php @@ -46,6 +46,14 @@ public static function fromTests(string $name, array $tests, int $failureThresho return $suite; } + /** + * @return positive-int + */ + public function failureThreshold(): int + { + return $this->failureThreshold; + } + /** * @param list $tests */ diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php index b06a0aac6a..a7e722f66e 100644 --- a/src/Runner/Parallel/PersistentWorker.php +++ b/src/Runner/Parallel/PersistentWorker.php @@ -12,6 +12,7 @@ use function assert; use function base64_encode; use function bin2hex; +use function count; use function defined; use function file_get_contents; use function get_include_path; @@ -26,6 +27,9 @@ use function unlink; use function var_export; use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Framework\RepeatTestSuite; +use PHPUnit\Framework\RetryTestSuite; +use PHPUnit\Framework\TestCase; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\TextUI\Configuration\SourceMapper; @@ -263,31 +267,43 @@ private function testClassCommand(TestClassWorkUnit $unit, array $offset, string $tests = []; foreach ($unit->tests() as $test) { - try { - $data = base64_encode(serialize($test->providedData())); - $dependencyInput = base64_encode(serialize($test->dependencyInput())); - } catch (Throwable $t) { - @unlink($resultFile); - - throw new WorkerException( - sprintf( - 'The tests of class %s cannot be run in parallel because their data cannot be serialized: %s', - $unit->className(), - $t->getMessage(), - ), - ); + if ($test instanceof RetryTestSuite) { + $aggregated = $test->tests(); + + assert(count($aggregated) === 1 && $aggregated[0] instanceof TestCase); + + $tests[] = [ + 'type' => 'retry', + 'name' => $test->name(), + 'maxAttempts' => $test->maxAttempts(), + 'test' => $this->testDescriptor($aggregated[0], $unit->className(), $resultFile), + ]; + + continue; + } + + if ($test instanceof RepeatTestSuite) { + $repetitions = []; + + foreach ($test->tests() as $repetition) { + assert($repetition instanceof TestCase); + + $repetitions[] = $this->testDescriptor($repetition, $unit->className(), $resultFile); + } + + $tests[] = [ + 'type' => 'repeat', + 'name' => $test->name(), + 'failureThreshold' => $test->failureThreshold(), + 'tests' => $repetitions, + ]; + + continue; } - $tests[] = [ - 'methodName' => $test->name(), - 'data' => $data, - 'dataName' => $test->dataName(), - 'dependencyInput' => $dependencyInput, - 'repetition' => $test->repetition(), - 'totalRepetitions' => $test->totalRepetitions(), - 'attempt' => $test->attempt(), - 'maxAttempts' => $test->maxAttempts(), - ]; + assert($test instanceof TestCase); + + $tests[] = $this->testDescriptor($test, $unit->className(), $resultFile); } return [ @@ -303,6 +319,47 @@ private function testClassCommand(TestClassWorkUnit $unit, array $offset, string ]; } + /** + * The transportable description of a single test case, from which the + * worker reconstructs it. + * + * @param class-string $className + * @param non-empty-string $resultFile + * + * @throws WorkerException + * + * @return array + */ + private function testDescriptor(TestCase $test, string $className, string $resultFile): array + { + try { + $data = base64_encode(serialize($test->providedData())); + $dependencyInput = base64_encode(serialize($test->dependencyInput())); + } catch (Throwable $t) { + @unlink($resultFile); + + throw new WorkerException( + sprintf( + 'The tests of class %s cannot be run in parallel because their data cannot be serialized: %s', + $className, + $t->getMessage(), + ), + ); + } + + return [ + 'type' => 'test', + 'methodName' => $test->name(), + 'data' => $data, + 'dataName' => $test->dataName(), + 'dependencyInput' => $dependencyInput, + 'repetition' => $test->repetition(), + 'totalRepetitions' => $test->totalRepetitions(), + 'attempt' => $test->attempt(), + 'maxAttempts' => $test->maxAttempts(), + ]; + } + /** * Harvest the result of the unit the worker has just reported as finished. */ diff --git a/src/Runner/Parallel/TestClassWorkUnit.php b/src/Runner/Parallel/TestClassWorkUnit.php index 4e5fb1164d..ca6ad234cc 100644 --- a/src/Runner/Parallel/TestClassWorkUnit.php +++ b/src/Runner/Parallel/TestClassWorkUnit.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\Runner\Parallel; +use PHPUnit\Framework\IterativeTestSuite; use PHPUnit\Framework\TestCase; /** @@ -17,6 +18,11 @@ * #[AfterClass]) and intra-class ordering are preserved when the unit is run by * a single worker. * + * A member of the unit is either a single test case or an IterativeTestSuite + * that aggregates the repetitions of a repeated test method or the attempts of + * a retried test method; such a suite travels as one atomic member so that its + * repetition and retry orchestration runs inside the worker. + * * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * * @internal This class is not covered by the backward compatibility promise for PHPUnit @@ -34,14 +40,14 @@ private string $className; /** - * @var list + * @var list */ private array $tests; /** - * @param non-negative-int $index - * @param class-string $className - * @param list $tests + * @param non-negative-int $index + * @param class-string $className + * @param list $tests */ public function __construct(int $index, string $className, array $tests) { @@ -67,7 +73,7 @@ public function className(): string } /** - * @return list + * @return list */ public function tests(): array { diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl index ed73f6818a..eee11bd447 100644 --- a/src/Runner/Parallel/templates/worker.tpl +++ b/src/Runner/Parallel/templates/worker.tpl @@ -1,5 +1,7 @@ methodName); + + $test->setData($descriptor->dataName, unserialize(base64_decode($descriptor->data))); + $test->setDependencyInput(unserialize(base64_decode($descriptor->dependencyInput))); + $test->setRepetition($descriptor->repetition, $descriptor->totalRepetitions); + $test->setAttempt($descriptor->attempt, $descriptor->maxAttempts); + + return $test; +} + function __phpunit_worker_run_unit(object $command): string { $dispatcher = Facade::instance()->initForIsolation( @@ -84,17 +98,53 @@ function __phpunit_worker_run_unit(object $command): string $suite = TestSuite::empty($command->className); foreach ($command->tests as $__phpunit_test) { - $className = $command->className; - $methodName = $__phpunit_test->methodName; - - $test = new $className($methodName); - - $test->setData($__phpunit_test->dataName, unserialize(base64_decode($__phpunit_test->data))); - $test->setDependencyInput(unserialize(base64_decode($__phpunit_test->dependencyInput))); - $test->setRepetition($__phpunit_test->repetition, $__phpunit_test->totalRepetitions); - $test->setAttempt($__phpunit_test->attempt, $__phpunit_test->maxAttempts); - - $suite->addTest($test); + $className = $command->className; + + // A retried test method travels as its RetryTestSuite so that the + // retry orchestration runs inside the worker; additional attempts are + // built here, from the same descriptor as the first one. + if ($__phpunit_test->type === 'retry') { + $descriptor = $__phpunit_test->test; + + $factory = static function () use ($className, $descriptor): PHPUnit\Framework\TestCase + { + return __phpunit_worker_build_test($className, $descriptor); + }; + + $suite->addTest( + RetryTestSuite::fromTestCase( + $__phpunit_test->name, + $factory(), + $__phpunit_test->maxAttempts, + $factory, + ), + ); + + continue; + } + + // A repeated test method travels as its RepeatTestSuite so that the + // repetition orchestration (failure threshold, skipping of remaining + // repetitions) runs inside the worker. + if ($__phpunit_test->type === 'repeat') { + $repetitions = []; + + foreach ($__phpunit_test->tests as $descriptor) { + $repetitions[] = __phpunit_worker_build_test($className, $descriptor); + } + + $suite->addTest( + RepeatTestSuite::fromTests( + $__phpunit_test->name, + $repetitions, + $__phpunit_test->failureThreshold, + ), + ); + + continue; + } + + $suite->addTest(__phpunit_worker_build_test($className, $__phpunit_test)); } $suite->run(); diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php index 8ac8fb2566..fee86b34f1 100644 --- a/src/TextUI/ParallelTestRunner.php +++ b/src/TextUI/ParallelTestRunner.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\TextUI; +use function assert; use function get_parent_class; use function gettype; use function is_array; @@ -18,6 +19,8 @@ use function serialize; use function spl_object_id; use PHPUnit\Event; +use PHPUnit\Framework\IterativeTestSuite; +use PHPUnit\Framework\PhptIterativeTestSuite; use PHPUnit\Framework\Test; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; @@ -322,7 +325,7 @@ private function runInProcess(TestClassWorkUnit $unit): void */ private function canBeSerialized(TestClassWorkUnit $unit): bool { - foreach ($unit->tests() as $test) { + foreach ($this->testCasesOf($unit) as $test) { try { serialize($test->providedData()); serialize($test->dependencyInput()); @@ -401,7 +404,7 @@ private function mustNotRunInParallel(TestClassWorkUnit $unit): bool } } while (($class = get_parent_class($class)) !== false); - foreach ($unit->tests() as $test) { + foreach ($this->testCasesOf($unit) as $test) { if (MetadataRegistry::parser()->forMethod($className, $test->name())->isDoNotRunInParallel()->isNotEmpty()) { return true; } @@ -428,7 +431,7 @@ private function requiresProcessIsolation(TestClassWorkUnit $unit): bool } } while (($class = get_parent_class($class)) !== false); - foreach ($unit->tests() as $test) { + foreach ($this->testCasesOf($unit) as $test) { if (MetadataRegistry::parser()->forMethod($className, $test->name())->isRunInSeparateProcess()->isNotEmpty()) { return true; } @@ -437,6 +440,33 @@ private function requiresProcessIsolation(TestClassWorkUnit $unit): bool return false; } + /** + * The test cases of a unit, with the test cases aggregated by an + * IterativeTestSuite member enumerated in its place. + * + * @return list + */ + private function testCasesOf(TestClassWorkUnit $unit): array + { + $testCases = []; + + foreach ($unit->tests() as $test) { + if ($test instanceof IterativeTestSuite) { + foreach ($test->tests() as $aggregated) { + assert($aggregated instanceof TestCase); + + $testCases[] = $aggregated; + } + + continue; + } + + $testCases[] = $test; + } + + return $testCases; + } + /** * @param positive-int $numberOfWorkers */ @@ -476,7 +506,7 @@ private function createPool(int $numberOfWorkers): WorkerPool */ private function collectUnits(TestSuite $suite): array { - /** @var array, array{index: non-negative-int, tests: list}> $byClass */ + /** @var array, array{index: non-negative-int, tests: list}> $byClass */ $byClass = []; /** @var list}> $phpt */ @@ -509,14 +539,54 @@ private function collectUnits(TestSuite $suite): array } /** - * @param array, array{index: non-negative-int, tests: list}> $byClass - * @param list}> $phpt - * @param list $standalone - * @param non-negative-int $index + * @param array, array{index: non-negative-int, tests: list}> $byClass + * @param list}> $phpt + * @param list $standalone + * @param non-negative-int $index */ private function collect(TestSuite $suite, array &$byClass, array &$phpt, array &$standalone, int &$index): void { foreach ($suite as $test) { + // The repetitions of a repeated PHPT test and the attempts of a + // retried PHPT test are orchestrated by their suite's runTests() + // method and must run sequentially, so the suite runs as one unit + // in the main process at its suite index. + if ($test instanceof PhptIterativeTestSuite) { + $standalone[] = [ + 'index' => $index, + 'test' => $test, + ]; + + $index++; + + continue; + } + + // The repetitions of a repeated test method and the attempts of a + // retried test method are orchestrated by their suite's runTests() + // method; the suite therefore travels as one atomic member of its + // class' work unit instead of being flattened into its tests. + if ($test instanceof IterativeTestSuite) { + $tests = $test->tests(); + + assert($tests !== [] && $tests[0] instanceof TestCase); + + $className = $tests[0]::class; + + if (!isset($byClass[$className])) { + $byClass[$className] = [ + 'index' => $index, + 'tests' => [], + ]; + + $index++; + } + + $byClass[$className]['tests'][] = $test; + + continue; + } + if ($test instanceof TestSuite) { $this->collect($test, $byClass, $phpt, $standalone, $index); diff --git a/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php b/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php new file mode 100644 index 0000000000..a7e3e212ae --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\Attributes\Retry; +use PHPUnit\Framework\TestCase; + +final class FlakyTest extends TestCase +{ + private static int $attempts = 0; + + #[Retry(3)] + public function testFlaky(): void + { + self::$attempts++; + + if (self::$attempts < 2) { + $this->fail('Flaky failure on attempt ' . self::$attempts); + } + + $this->assertTrue(true); + } + + public function testStable(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php b/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php new file mode 100644 index 0000000000..dceb1f6675 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Retry; +use PHPUnit\Framework\TestCase; + +final class FlakyWithDataProviderTest extends TestCase +{ + /** + * @var array + */ + private static array $attempts = []; + + public static function provider(): array + { + return [ + 'one' => [1], + 'two' => [2], + ]; + } + + #[DataProvider('provider')] + #[Retry(3)] + public function testFlaky(int $value): void + { + if (!isset(self::$attempts[$value])) { + self::$attempts[$value] = 0; + } + + self::$attempts[$value]++; + + if ($value === 2 && self::$attempts[$value] < 3) { + $this->fail('Flaky failure on attempt ' . self::$attempts[$value]); + } + + $this->assertSame($value, $value); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt b/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt new file mode 100644 index 0000000000..640aa67b89 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt @@ -0,0 +1,7 @@ +--TEST-- +PHPT that always fails +--FILE-- + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\TestCase; + +final class RepeatFailureTest extends TestCase +{ + private static int $runs = 0; + + public function testFailsOnSecondRepetition(): void + { + self::$runs++; + + if (self::$runs === 2) { + $this->fail('Failure on repetition ' . self::$runs); + } + + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt new file mode 100644 index 0000000000..2e0a73a074 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt @@ -0,0 +1,32 @@ +--TEST-- +phpunit --repeat 3 --parallel=2 runs the repetitions of a test method inside the worker that runs its class and skips the remaining repetitions after a failure +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.FS 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\RepeatFailureTest::testFailsOnSecondRepetition (repetition 2 of 3) +Failure on repetition 2 + +%sRepeatFailureTest.php:%d + +FAILURES! +Tests: 3, Assertions: 2, Failures: 1, Skipped: 1. diff --git a/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt b/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt new file mode 100644 index 0000000000..363e73d836 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt @@ -0,0 +1,37 @@ +--TEST-- +phpunit --repeat 2 --parallel=2 runs the repetitions of a PHPT test sequentially in the main process and skips the remaining repetitions after a failure +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +FS 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) %sRepeatFailingPhpt.phpt (repetition 1 of 2) +Failed asserting that two strings are equal. +--- Expected ++++ Actual +@@ @@ +-'OK' ++'FAIL' + +%sRepeatFailingPhpt.phpt:%d + +FAILURES! +Tests: 2, Assertions: 1, Failures: 1, Skipped: 1. diff --git a/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt new file mode 100644 index 0000000000..a0eae5752a --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 retries a test method annotated with #[Retry] inside the worker that runs its class +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 retried test: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\FlakyTest::testFlaky +1 failed attempt + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt new file mode 100644 index 0000000000..d628f5e884 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 retries each data set of a test method annotated with #[Retry] individually inside the worker that runs its class +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 retried test: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\FlakyWithDataProviderTest::testFlaky#two +2 failed attempts + +OK (2 tests, 2 assertions)