diff --git a/phpunit.xml b/phpunit.xml
index 10017aaae9d..035f504a732 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -35,6 +35,7 @@
tests/end-to-end/logging/testdoxtests/end-to-end/metadatatests/end-to-end/migration
+ tests/end-to-end/paralleltests/end-to-end/phpttests/end-to-end/regressiontests/end-to-end/repeat
@@ -48,6 +49,11 @@
tests/end-to-end/groups-from-configuration/_filestests/end-to-end/logging/_filestests/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-conflicts/_files
+ tests/end-to-end/parallel/phpt-with-classes/_files
+ tests/end-to-end/parallel/repeat-retry/_filestests/end-to-end/repeat/_filestests/end-to-end/retry/_filestests/end-to-end/self-direct-indirect/_files
diff --git a/src/Event/CollectingEmitter.php b/src/Event/CollectingEmitter.php
new file mode 100644
index 00000000000..aa9d55b66a6
--- /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/Dispatcher/CollectingDispatcher.php b/src/Event/Dispatcher/CollectingDispatcher.php
index e3e9462ee6a..49dc016b5d3 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 349efbb1f3d..a821a7cd9d1 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,25 +109,70 @@ public function initForIsolation(HRTime $offset): CollectingDispatcher
$this->sealed = true;
+ $this->isolationDispatcher = $dispatcher;
+
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();
+ 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/Attributes/DoNotRunInParallel.php b/src/Framework/Attributes/DoNotRunInParallel.php
new file mode 100644
index 00000000000..10bef641389
--- /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/Framework/IterativeTestSuite.php b/src/Framework/IterativeTestSuite.php
index 089c2fb9672..fe705b1fb8d 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);
diff --git a/src/Framework/RepeatTestSuite.php b/src/Framework/RepeatTestSuite.php
index b25a9ea34e3..8fe431457ac 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/Framework/TestRunner/ChildProcessResultEnvelope.php b/src/Framework/TestRunner/ChildProcessResultEnvelope.php
new file mode 100644
index 00000000000..3a80951068a
--- /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 9e3418b8efb..4bad22a2dd1 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/Metadata/DoNotRunInParallel.php b/src/Metadata/DoNotRunInParallel.php
new file mode 100644
index 00000000000..038a7a44ee1
--- /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 5249ab1f78f..d79358d50f1 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 4a6c4311d72..055f6bf00cf 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 d0409eaf167..5425b369b42 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 00000000000..8d3230d0564
--- /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/Exception/WorkerException.php b/src/Runner/Parallel/Exception/WorkerException.php
new file mode 100644
index 00000000000..2780d7089ea
--- /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 00000000000..a7e722f66e7
--- /dev/null
+++ b/src/Runner/Parallel/PersistentWorker.php
@@ -0,0 +1,546 @@
+
+ *
+ * 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 count;
+use function defined;
+use function file_get_contents;
+use function get_include_path;
+use function hrtime;
+use function is_file;
+use function json_encode;
+use function random_bytes;
+use function serialize;
+use function sprintf;
+use function sys_get_temp_dir;
+use function tempnam;
+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;
+use PHPUnit\Util\PHP\Job;
+use PHPUnit\Util\PHP\JobRunner;
+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
+ * number of tests, each in response to a command received on its control
+ * 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 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 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
+ *
+ * @internal This class is not covered by the backward compatibility promise for PHPUnit
+ */
+final class PersistentWorker
+{
+ private readonly JobRunner $jobRunner;
+ private ?RunningJob $job = null;
+
+ /**
+ * @var list
+ */
+ private array $temporaryFiles = [];
+
+ /**
+ * 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 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 $currentDoneFile = null;
+
+ /**
+ * @var non-negative-int
+ */
+ private readonly int $id;
+
+ /**
+ * @var non-empty-string
+ */
+ private readonly string $token;
+
+ /**
+ * @param non-negative-int $id
+ */
+ public function __construct(JobRunner $jobRunner, int $id = 0)
+ {
+ $this->jobRunner = $jobRunner;
+ $this->id = $id;
+ $this->token = $id . '_' . bin2hex(random_bytes(16));
+ }
+
+ /**
+ * @throws WorkerException
+ */
+ public function start(): void
+ {
+ // 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.
+ //
+ // 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_TOKEN' => $this->token,
+ ];
+
+ $this->job = $this->jobRunner->start(
+ new Job($this->buildWorkerCode(), [], $environmentVariables),
+ );
+ }
+
+ /**
+ * Send a unit of work to the worker without waiting for it to finish.
+ *
+ * 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
+ */
+ 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
+ }
+
+ assert($unit instanceof TestClassWorkUnit);
+
+ $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->currentDoneFile = $doneFile;
+
+ $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;
+ }
+
+ /**
+ * 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.
+ *
+ * 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 poll(): ?CompletedWorkUnit
+ {
+ assert($this->currentUnit !== null);
+ assert($this->currentDoneFile !== null);
+
+ if (is_file($this->currentDoneFile)) {
+ return $this->finished();
+ }
+
+ if ($this->job === null || !$this->job->isRunning()) {
+ return $this->crashed();
+ }
+
+ return null;
+ }
+
+ 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 = [];
+ }
+
+ /**
+ * @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 $doneFile, string $nonce): array
+ {
+ $class = new ReflectionClass($unit->className());
+ $file = $class->getFileName();
+
+ assert($file !== false);
+
+ $tests = [];
+
+ foreach ($unit->tests() as $test) {
+ 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;
+ }
+
+ assert($test instanceof TestCase);
+
+ $tests[] = $this->testDescriptor($test, $unit->className(), $resultFile);
+ }
+
+ return [
+ 'command' => 'runUnit',
+ 'file' => $file,
+ 'className' => $unit->className(),
+ 'tests' => $tests,
+ 'offsetSeconds' => $offset[0],
+ 'offsetNanoseconds' => $offset[1],
+ 'resultFile' => $resultFile,
+ 'doneFile' => $doneFile,
+ 'nonce' => $nonce,
+ ];
+ }
+
+ /**
+ * 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.
+ */
+ 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
+ $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);
+ }
+
+ if ($this->currentDoneFile !== null) {
+ @unlink($this->currentDoneFile);
+ }
+
+ $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->currentDoneFile = null;
+ }
+
+ /**
+ * @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 {
+ // @codeCoverageIgnoreStart
+ $composerAutoload = '\'\'';
+ // @codeCoverageIgnoreEnd
+ }
+
+ if (defined('__PHPUNIT_PHAR__')) {
+ // @codeCoverageIgnoreStart
+ $phar = var_export(__PHPUNIT_PHAR__, true);
+ // @codeCoverageIgnoreEnd
+ } else {
+ $phar = '\'\'';
+ }
+
+ if (CodeCoverage::instance()->isActive()) {
+ $coverage = 'true';
+ } else {
+ // @codeCoverageIgnoreStart
+ $coverage = 'false';
+ // @codeCoverageIgnoreEnd
+ }
+
+ $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()) {
+ // @codeCoverageIgnoreStart
+ return '';
+ // @codeCoverageIgnoreEnd
+ }
+
+ $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/PhptRunner.php b/src/Runner/Parallel/PhptRunner.php
new file mode 100644
index 00000000000..b3019de0377
--- /dev/null
+++ b/src/Runner/Parallel/PhptRunner.php
@@ -0,0 +1,267 @@
+
+ *
+ * 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_merge;
+use function count;
+use function in_array;
+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.
+ *
+ * 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
+ */
+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
+ {
+ // 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 = [];
+
+ // 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 !== []) {
+ 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());
+
+ $generator->rewind();
+
+ if (!$generator->valid()) {
+ // The test produced its events without running any child
+ // 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,
+ 'collector' => $collector,
+ 'job' => $this->jobRunner->startAsync($generator->current()),
+ ];
+
+ $nextId++;
+ }
+
+ if ($active === []) {
+ continue;
+ }
+
+ $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, array &$activeConflicts, bool &$exclusive): 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());
+
+ $this->release($task['unit'], $activeConflicts, $exclusive);
+
+ unset($active[$id]);
+ }
+
+ if (!$progressed) {
+ usleep(1000);
+ }
+ }
+}
diff --git a/src/Runner/Parallel/PhptWorkUnit.php b/src/Runner/Parallel/PhptWorkUnit.php
new file mode 100644
index 00000000000..aa5b97da18d
--- /dev/null
+++ b/src/Runner/Parallel/PhptWorkUnit.php
@@ -0,0 +1,86 @@
+
+ *
+ * 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;
+
+ /**
+ * @var non-empty-string
+ */
+ private string $file;
+
+ /**
+ * @var list
+ */
+ 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->conflicts = $conflicts;
+ }
+
+ /**
+ * @return non-negative-int
+ */
+ public function index(): int
+ {
+ return $this->index;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ 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
+ */
+ 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 00000000000..a4316081bc2
--- /dev/null
+++ b/src/Runner/Parallel/ResultAggregator.php
@@ -0,0 +1,205 @@
+
+ *
+ * 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 property_exists;
+use function sprintf;
+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;
+
+/**
+ * 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 = ChildProcessResultEnvelope::verifyAndStripNonce(
+ $completed->serializedResult(),
+ $completed->nonce(),
+ );
+
+ 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(),
+ ),
+ );
+
+ return;
+ }
+
+ $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);
+
+ ChildProcessResultEnvelope::mergeCodeCoverage($childResult, $this->codeCoverage);
+ }
+}
diff --git a/src/Runner/Parallel/TestClassWorkUnit.php b/src/Runner/Parallel/TestClassWorkUnit.php
new file mode 100644
index 00000000000..ca6ad234cc1
--- /dev/null
+++ b/src/Runner/Parallel/TestClassWorkUnit.php
@@ -0,0 +1,87 @@
+
+ *
+ * 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\IterativeTestSuite;
+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.
+ *
+ * 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
+ */
+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 00000000000..d46cbd93ede
--- /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 00000000000..9371224ea9d
--- /dev/null
+++ b/src/Runner/Parallel/WorkerPool.php
@@ -0,0 +1,174 @@
+
+ *
+ * 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 usleep;
+
+/**
+ * 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 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
+ * 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
+{
+ /**
+ * 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
+ */
+ 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;
+ }
+
+ $progressed = false;
+
+ 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
+ // 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;
+ }
+}
diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl
new file mode 100644
index 00000000000..eee11bd447a
--- /dev/null
+++ b/src/Runner/Parallel/templates/worker.tpl
@@ -0,0 +1,207 @@
+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);
+
+// 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_build_test(string $className, object $descriptor): PHPUnit\Framework\TestCase
+{
+ $test = new $className($descriptor->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(
+ 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;
+
+ // 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();
+
+ $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;
+}
+
+$__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_unit($__phpunit_command);
+
+ file_put_contents($__phpunit_command->resultFile, $__phpunit_result);
+
+ // 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);
+}
diff --git a/src/Runner/Phpt/Parser.php b/src/Runner/Phpt/Parser.php
index 6e15c942b1a..980f5a228eb 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/Runner/Phpt/TestCase.php b/src/Runner/Phpt/TestCase.php
index f14c4d45b0c..3eda0a6adbf 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/Application.php b/src/TextUI/Application.php
index 7c5611bccc1..b5a2ac0dae0 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 75506f032c2..be6822338e3 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 9f7a0fcead2..ca0769cd3c1 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 74e39129bce..d0d4e75a378 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 bbb0f308a95..6bd645a2f07 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 6ac5d2458ac..da39ff52339 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 00000000000..fee86b34f10
--- /dev/null
+++ b/src/TextUI/ParallelTestRunner.php
@@ -0,0 +1,674 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace PHPUnit\TextUI;
+
+use function assert;
+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 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;
+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;
+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\Parser;
+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['phpt'] !== [] || $collected['standalone'] !== []) {
+ $this->execute($configuration, $collected['units'], $collected['phpt'], $collected['standalone']);
+ }
+
+ Event\Facade::emitter()->testRunnerExecutionFinished();
+ Event\Facade::emitter()->testRunnerFinished();
+ // @codeCoverageIgnoreStart
+ } catch (Throwable $t) {
+ throw new RuntimeException(
+ $t->getMessage(),
+ (int) $t->getCode(),
+ $t,
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * 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
+ * @param list $standalone
+ *
+ * @throws WorkerException
+ */
+ private function execute(Configuration $configuration, array $units, array $phpt, 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();
+ },
+ );
+ }
+
+ // 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.
+ $aggregator->flush();
+
+ if ($parallel !== []) {
+ $this->runInParallel($parallel, $aggregator, $configuration->numberOfParallelWorkers());
+ }
+
+ $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
+ *
+ * @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 ($this->testCasesOf($unit) 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