Skip to content

Parallel test execution - #6784

Draft
sebastianbergmann wants to merge 63 commits into
mainfrom
feature/parallel-test-execution
Draft

Parallel test execution#6784
sebastianbergmann wants to merge 63 commits into
mainfrom
feature/parallel-test-execution

Conversation

@sebastianbergmann

@sebastianbergmann sebastianbergmann commented Jun 26, 2026

Copy link
Copy Markdown
Owner

PHPUnit can now execute a test suite across several worker processes concurrently instead of one test after another. Parallel execution is opt-in via a new --parallel=<n> command-line option and changes nothing when it is not used: the sequential TextUI\TestRunner remains the default and TextUI\ParallelTestRunner is selected only when <n> > 1.

Native parallelism here is a generalization of process isolation rather than a new subsystem: a worker reconstructs and runs a unit of work and ships its outcome home in the very same serialized envelope that process isolation already uses, and the parent replays that envelope through its normal event pipeline. As a result the parent process remains the single source of truth for all output, logging, results, and code coverage, which are produced exactly as in sequential mode.

How it works

  • Distribution unit = one test class. All selected tests of a class are run together by a single worker, preserving #[BeforeClass]/#[AfterClass] and intra-class ordering. A DataProviderTestSuite, and the IterativeTestSuite that carries the repetitions of a repeated test or the attempts of a retried test, travel to the worker as atomic members of their class' unit, so their suite envelopes nest in logger output exactly as they do sequentially.
  • Chunks. The top-level <testsuite> elements of an XML configuration are run one after another, just as in sequential mode: only tests that belong to the same top-level test suite ever run concurrently.
  • Worker pool. Runner\Parallel\WorkerPool owns n persistent workers and pulls from a dynamic work-stealing queue, so the load self-balances against stragglers. A worker signals completion through the filesystem, which the parent polls; stream_select() is not used, because it does not work on the workers' output pipes on Windows.
  • Longest-first dispatch. Within a chunk, Runner\Parallel\Scheduler dispatches units in the order of the durations recorded by the test run history, longest first, so that the longest-running work starts as early as possible instead of becoming the straggler the pool waits for. A unit with no recorded duration goes first. Only the dispatch order is affected; results are released in suite order either way.
  • Live progress. While a unit is still running, its worker streams the events of every test that finishes, as length-prefixed frames appended to a stream file that the parent reads incrementally. Progress output therefore appears per finished test rather than stalling until a whole test class is done.
  • Ordered result aggregation. Workers finish out of order, but Runner\Parallel\ResultAggregator buffers each unit and releases it only once every preceding unit (in suite order) has been released, so the event stream, and therefore every output format, including the default progress output, is byte-for-byte what sequential mode produces. Streamed events of the unit that is next in suite order are forwarded immediately; those of later units are buffered until their turn.
  • PHPT tests run concurrently in the main process. They are not PHPUnit\Framework\TestCase instances and cannot be reconstructed in a worker (and nesting their child processes inside workers hung on Windows), so Runner\Parallel\PhptRunner runs them side by side in the main process, each as its own child process.
  • One budget of concurrent processes. The worker pool and the PHPT runner are advanced side by side in a single polling loop and share one ProcessBudget, so --parallel <n> never executes more than <n> units at once, no matter how a chunk is composed.
  • Suite envelopes. In a parallel run nothing produces the "test suite started"/"test suite finished" events of the root suite and the top-level test suites, so the runner emits them around the chunks. The loggers that reconstruct the suite hierarchy — JUnit XML, Open Test Reporting, TeamCity — depend on them.

Running in the main process

Some tests cannot, or must not, run in a worker. These run in the main process at their correct suite position — the aggregator invokes them while releasing results, so global ordering is preserved — and their execution there is ordinary sequential execution:

  • Tests attributed with #[DoNotRunInParallel] — a new attribute, valid on classes and methods, for tests that must not run alongside others (for instance because they share a machine-global resource). Such a unit runs alone: the worker pool and the PHPT runner first finish what they are executing and start nothing new until it is done.
  • Tests that require process isolation (#[RunInSeparateProcess], #[RunTestsInSeparateProcesses], or global --process-isolation) — a shared worker cannot provide isolation, but the main process spawns the isolated child as usual.
  • Tests with a cross-class #[Depends] — the result they depend on is produced by a different unit and is only visible in the main process, once that unit has been released.
  • Tests whose data cannot be serialized for transport to a worker — closures and other non-serializable values (which make serialize() throw) as well as resources (which serialize() silently degrades to 0).
  • Repeated or retried PHPT tests, whose iterations are orchestrated by their suite and must run one after another.
  • Any other test that is neither a TestCase nor a PHPT test.

Because a work unit is a whole test class, a single test method carrying #[DoNotRunInParallel], requiring isolation, depending on another class, or providing non-serializable data takes its entire class out of the parallel phase.

PHPT tests declare their concurrency constraints with a --CONFLICTS-- section instead, since a PHPT file cannot carry PHP attributes: while a test holding conflict key K runs, no other test declaring K is started, and the reserved key all means the test runs entirely on its own.

Robustness

  • Crashed workers. If a worker dies, its unit is retried once on a freshly booted worker process — the crash may have been caused by state the dead process accumulated, so the retry gets a pristine environment. The retry is vetoed when some of the unit's results were already streamed and forwarded; in that case, and when the retry crashes as well, every test of the unit is reported as errored (The worker process running X ended unexpectedly), the remaining units are redistributed across the surviving workers, and the run accounts for all of them.
  • Corrupted event streams. A worker whose event stream fails verification is terminated and reaped, so its unit's retry boots a fresh process instead of aborting the run or leaking the compromised one.
  • --stop-on-*. As soon as the collected results call for a stop, the aggregator releases nothing further and the units still executing are terminated (a grace period, then a forced kill). A PHPT test whose FILE section was terminated still runs its CLEAN section, and one terminated during SKIPIF does not start its FILE section.
  • Stray worker output. A worker's output is redirected to a file, so a test that writes more than the operating system buffers in a pipe cannot deadlock the run.

New public API

  • --parallel=<n> CLI option (also listed in --help); a value that is not a positive integer is ignored with a test runner warning.
  • #[PHPUnit\Framework\Attributes\DoNotRunInParallel] (TARGET_CLASS | TARGET_METHOD), with full metadata-layer support.
  • The --CONFLICTS-- PHPT section, as supported by the PHP project's run-tests.php: one conflict key per line, # starts a comment, blank lines are ignored, and all is reserved.
  • Two worker-identity environment variables, exposed to the tests a worker runs so that fixtures can partition shared resources (a database, a port, a temporary directory, ...) per worker:
    • PHPUNIT_WORKER_ID — the small, stable ordinal (0, 1, 2, ...), ideal for indexing a fixed set of pre-provisioned resources. A worker restarted after a crash keeps its ordinal.
    • PHPUNIT_WORKER_TOKEN — a value of the form <id>_<random> that is unique across workers and across runs, for resources that must not collide with those left behind by a previous run. A restarted worker gets a fresh token.
  • Event\TestRunner\ChildProcessReason::ParallelWorker, so that the events about child processes say why one was used.

Shared with the sequential runner

Rather than duplicating the sequential runner, behavior the two must perform identically was extracted so it is expressed exactly once:

  • TextUI\TestRunnerLifecycle — the test runner lifecycle both runners drive.
  • Event\Dispatcher\CollectionWindow and the facade's dispatcher selection — the window during which events are collected instead of dispatched.
  • Framework\TestRunner\ChildProcessBootstrap plus two templates — the boot code every kind of child process shares; the configuration and source map are written once per run for all of them, so isolated processes and parallel workers cannot drift apart.
  • Framework\TestRunner\ChildProcessResultEnvelope — the result envelope's encoding and decoding, shared by its consumers.
  • Test run history is now persisted once, on TestRunner\ExecutionFinished, instead of every time an outermost test suite finishes.

Results for PHPUnit's own test suite

Measured on a 6-core machine:

Suite Sequential --parallel 10
--testsuite unit (5813 tests) 11.0s 6.7s 1.6×
--testsuite end-to-end (1257 tests) 249.5s 48.5s 5.1×

Both suites report the same numbers of tests, failures, and skipped tests in both modes, stable across runs. The end-to-end suite benefits the most because it is dominated by child processes rather than by CPU work in the main process.

Which worker runs which test class is not deterministic; the work-stealing scheduler assigns classes by timing, but the aggregated output is identical regardless.

Notes and limitations (possible follow-ups)

  • Parallel execution is configurable only via the CLI option; there is no XML configuration setting yet.
  • An in-process unit that is not exclusive may temporally overlap workers still running later units; this is invisible in the output (worker events stay buffered) and harmless for process-global state (separate processes), but a test contending on an external shared resource should use #[DoNotRunInParallel].
  • Chunk boundaries are a synchronization point: the units of the next top-level test suite are not dispatched before the current chunk has drained.

@sebastianbergmann sebastianbergmann self-assigned this Jun 26, 2026
@sebastianbergmann sebastianbergmann added type/enhancement A new idea that should be implemented feature/test-runner CLI test runner labels Jun 26, 2026
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

API Surface Changes

If any of the additions below are not intended as public API, mark them with @internal in the docblock.

New API Surface

Classes

Methods

Modified API Surface

Methods

  • PHPUnit\TextUI\Configuration\Configuration::__construct
    - public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $recordTestRunHistory, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testRunHistoryFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, bool $coverageHtmlClassView, bool $coverageHtmlFileView, 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 $coverageJsonl, ?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 $failOnSelfDeprecation, bool $failOnDirectDeprecation, bool $failOnIndirectDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnSelfDeprecation, bool $doNotFailOnDirectDeprecation, bool $doNotFailOnIndirectDeprecation, 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, bool $warnWhenPhpIsNotConfiguredForDevelopment)
    + public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $recordTestRunHistory, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testRunHistoryFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, bool $coverageHtmlClassView, bool $coverageHtmlFileView, 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 $coverageJsonl, ?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 $failOnSelfDeprecation, bool $failOnDirectDeprecation, bool $failOnIndirectDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnSelfDeprecation, bool $doNotFailOnDirectDeprecation, bool $doNotFailOnIndirectDeprecation, 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, bool $warnWhenPhpIsNotConfiguredForDevelopment, int $numberOfParallelWorkers)

@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch 3 times, most recently from 9a08909 to efcd7ee Compare June 26, 2026 10:52
@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.51456% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.49%. Comparing base (0fbd494) to head (7757ead).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/TextUI/ParallelTestRunner.php 97.84% 8 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##               main    #6784     +/-   ##
===========================================
  Coverage     99.48%   99.49%             
- Complexity     9436     9918    +482     
===========================================
  Files           916      939     +23     
  Lines         28798    30250   +1452     
===========================================
+ Hits          28650    30097   +1447     
- Misses          148      153      +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@sebastianbergmann sebastianbergmann changed the title Native parallel test execution Parallel test execution Jun 27, 2026
@Slamdunk

Slamdunk commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

My 2 cents on the topic:

  1. There is no logical possibility that Extensions support parallelization natively: I suggest to require extensions to explicitely provide support to it (in some way) and to stop the run if an extension without parallel support is detected

  2. I've been requested to provide both simple worker ID like 1, 2, 3 as well as unique worker ID like uniqid('1_'), uniqid('2_'), uniqid('3_')

  3. Coordinated bootstraping operations are crucial to the users, and many asked to be able to have bootstrap running both before the whole execution and within each worker, with a flag to distinguish between the two cases (see https://github.com/paratestphp/paratest#initial-setup-for-all-tests). For example users want to CREATE DATABASE in the parent process and DELETE FROM table in the workers to speed I/O up

  4. I see that you chose Distribution unit = one test class which is sensible: be prepared to reply to a mass of users that wants/needs Distribution unit = one test method, what is ParaTest we provide with --functional

  • --testsuite unit --parallel 10 matches sequential exactly (same test and assertion counts), stable across runs.

Parallel execution can't and will never be deterministic, a tiny difference between how much a unit lasts results in the following test being run in worker X instead of Y.
Many test suites are not well isolated, and if the developer is unaware of that a test run may fail if the aforementioned test runs in worker X instead of Y.
To be able to debug such situations, the developers needs an option to replay the previous run with exactly the same test order in the exact same worker ID, with no concern on the test speed since you (correctly) chose to pulling from a dynamic work-stealing queue so the load self-balances against stragglers

@sebastianbergmann

Copy link
Copy Markdown
Owner Author

Thank you, @Slamdunk, for taking the time to write all of this up. There's a lot of hard-won experience in here, and I appreciate it.

I want to set expectations honestly, though: for me, native parallel test execution in PHPUnit is still just an idea, and this pull request is a (remarkably well-working) proof of concept rather than a roadmap commitment.

If I ever decide to be serious about this and actually ship it, the feature will have limitations. And those limitations must (and will) be very well documented. Complaints about them will then be kindly refused. 🙂

Even in that case, I neither intend nor expect this to replace dedicated solutions such as ParaTest or Paraunit. The scope I have in mind right now is roughly:

PHPUnit supports parallel execution for well-architected test suites that properly deal with resource-usage conflicts like databases, etc.

So several of the needs you describe, coordinated bootstrapping, functional/method-level distribution, deterministic replay of a run, are exactly the kind of thing that lives outside that scope and is better served by the tools built specifically for it.

That said, your notes are genuinely useful for thinking about where the boundaries of that scope should sit, so thank you again.

@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch 3 times, most recently from 4e03c18 to 5053a2d Compare July 3, 2026 20:06
@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch from 5053a2d to cf8f973 Compare July 7, 2026 05:40
@sebastianbergmann

sebastianbergmann commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

I have briefly looked into whether the new I/O polling API that PHP 8.6 introduces (RFC: poll_api) could replace the file-based completion polling used by the parallel test runner. The conclusion is that it cannot, because it has the exact limitation that forced the file-based design in the first place: on Windows, it cannot wait on proc_open() pipes.

WorkerPool and PhptRunner do not wait on the worker processes' output pipes with stream_select(), because stream_select() does not work on pipes on Windows (it only works on sockets there). Instead, a worker signals completion through the filesystem: after writing its result file, it creates a sibling .done file, whose appearance the parent detects by polling is_file(), sleeping 1000 microseconds between rounds.

Io\Poll looks like the natural replacement at first glance: a Context monitors multiple StreamPollHandles and wait() blocks until one of them becomes readable, using epoll/kqueue/event ports/poll as the platform backend.

However:

  • On Windows, the only backend that is registered is WSAPoll (main/poll/poll_core.c, the #ifdef PHP_WIN32 branch). WSAPoll() is a Winsock function and accepts sockets only.
  • StreamPollHandle obtains its descriptor via php_stream_cast(PHP_STREAM_AS_FD_FOR_SELECT) (ext/standard/io_poll.c). For a proc_open() pipe on Windows, this yields a CRT file descriptor, not a SOCKET, which WSAPoll immediately rejects with POLLNVAL. Readiness on a pipe can therefore never be waited on.
  • The RFC's future scope lists SocketPollHandle, CurlPollHandle, TimerHandle, and SignalHandle, but no process or pipe handle.

In other words, Io\Poll is a unified, modern interface to the same platform primitives whose Windows limitation this branch already had to work around.

Therefore, the uniform file-based completion polling stays. This decision should be revisited only if a future PHP version ships a poll handle that can wait on pipes or process handles on Windows, which is what this feature actually needs.

@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch from cf8f973 to 1e97c6b Compare July 14, 2026 16:28
@Slamdunk

Copy link
Copy Markdown
Contributor

I see that it's up to the user to select how many worker to use.

The auto flag has been a big request by the community, and currently https://github.com/theofidry/cpu-core-counter is serving ParaTest as well as PHP-CS-Fixer, PHPStan and Infection for that very purpose.

You might be interested in giving it a try, so PHPUnit can provide --parallel=auto out of the box.

@sebastianbergmann

Copy link
Copy Markdown
Owner Author

I see that it's up to the user to select how many worker to use.

The auto flag has been a big request by the community, and currently https://github.com/theofidry/cpu-core-counter is serving ParaTest as well as PHP-CS-Fixer, PHPStan and Infection for that very purpose.

You might be interested in giving it a try, so PHPUnit can provide --parallel=auto out of the box.

I am aware of fidry/cpu-core-counter, but thank you for your suggestion. Should I decide to implement --parallel=auto I will look into using that library.

@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch 2 times, most recently from 04cb38d to 43341d8 Compare July 19, 2026 08:56
@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch from 908d59e to 4b04c6a Compare August 1, 2026 13:15
@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch from 2c7a98a to c034df8 Compare August 8, 2026 05:06
@sebastianbergmann

Copy link
Copy Markdown
Owner Author

I see that it's up to the user to select how many worker to use.
The auto flag has been a big request by the community, and currently https://github.com/theofidry/cpu-core-counter is serving ParaTest as well as PHP-CS-Fixer, PHPStan and Infection for that very purpose.
You might be interested in giving it a try, so PHPUnit can provide --parallel=auto out of the box.

I am aware of fidry/cpu-core-counter, but thank you for your suggestion. Should I decide to implement --parallel=auto I will look into using that library.

--parallel auto is now implemented.

@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch 3 times, most recently from e9e9866 to 7c6f829 Compare August 17, 2026 04:35
sebastianbergmann and others added 29 commits August 19, 2026 11:55
…atch with a telling exception when the worker command cannot be encoded, so that a data provider key that is not valid UTF-8 cannot abort a parallel run
…d, and do not start the FILE section of one terminated during SKIPIF, when a parallel run stops early, so that an abandoned test still cleans up after itself
…onflicts with every other test entirely on their own, draining the worker pool and the PHPT runner first, so that the exclusivity these declarations promise actually holds
…'s classes with named constructors, derivation methods, and shared helpers, so that each invariant lives in one place
…es in one pass through one metadata traversal, and poll a worker's event stream with a stat instead of a re-read, so that a parallel run wastes less work as suites grow
…e helper and two template fragments, writing the configuration and source map once per run for all of them, so that isolated processes and parallel workers cannot drift apart
…e the result envelope's decoding between its two consumers, and let a work unit report its own recorded duration, so that each half of the parallel runner's data model lives in exactly one place
…and the facade's dispatcher selection, and record at collection time which chunk envelopes the units emit, so that behavior the sequential and parallel runners must perform identically is expressed exactly once
…let those descriptors travel in a serialized command instead of a JSON-encoded one, so that a member's encoding and decoding live in one place and no value it carries needs a base64 shim to survive the trip to a worker
The tests of a data provider method, the repetitions of a repeated test
method, and the attempts of a retried test method travel to a worker as
the suite that aggregates them. The descriptor of such a suite asked the
suite for its tests, which returns all of them: test selection (--filter,
--group, --exclude-group, --filter-test-id, …) is a filter iterator that
the selection injects into the suite, and it applies only when the suite
is iterated, as TestSuite::takeTests() does in a sequential run.

Every test of a selected test class was therefore executed in a parallel
run, no matter which of them the selection had actually selected.

The descriptors now iterate the suite, and the collection walk skips a
suite that the selection has emptied, for which TestSuite::run() returns
early in a sequential run.
A test case that runs in a worker process is not built by TestBuilder but
recreated from its descriptor, which carries only the name of the test
method and the data that distinguishes one invocation of it from another.
The settings that TestBuilder derives from metadata and from the
configuration of the test runner -- process isolation, the preservation of
global state, and the backup of global variables and static properties --
were therefore never applied to it.

As a result, no snapshot of global state was taken for such a test case and
state leaked from one test of a test class to the next inside a worker,
regardless of the backupGlobals and backupStaticProperties configuration
settings, of the --globals-backup and --static-backup CLI options, and of
the #[BackupGlobals] and #[BackupStaticProperties] attributes.

Derive those settings again in the worker process, where the metadata and
the configuration they are derived from are available as well, by letting
the descriptor delegate to TestBuilder.
The tests in tests/end-to-end/parallel/selection come in pairs: one runs
the fixture sequentially, the other runs the same fixture with
--parallel=2, and both pin the same --EXPECTF-- output. The sequential
test of a pair documents which tests --filter, --group, --exclude-group
and --repeat select, the parallel test asserts that a parallel run
selects exactly the same ones. Should the two ever diverge again, the
parallel test of the pair fails.

The fixture is driven by data providers on purpose. Test selection is a
lazy filter that only takes effect while a test suite is iterated, so a
plain test method is selected correctly no matter how the parallel
runner reads a suite, whereas the tests of a data provider suite, a
repeat suite or a retry suite are not. Only a data provider driven
fixture therefore covers the case that went unnoticed.
A test case that a parallel worker recreates from its descriptor is not
built by TestBuilder::build(), so the settings that build() derives from
the metadata of the test method and from the configuration of the test
runner are applied to it by TestBuilder::configure() instead.

Cover that method: for a test class with class-level metadata for
isolation and for a test class with metadata for excluding global
variables and static properties from the backup, assert the settings it
applies, and for every kind of test class the fixtures provide, assert
that it leaves a manually instantiated test case configured exactly as
build() leaves the test case it builds for the same method.
The filter that test selection injects into a test suite accepts every suite it is asked about and applies the selection to the tests inside it instead. Iterating a data provider suite therefore still yields the repetitions of a repeated data set and the attempts of a retried one as a suite, an empty one, when the selection excluded that data set.

The descriptor of the data provider suite described such a member as if it aggregated something, and the descriptors of a repeat suite and of a retry suite assert that it does: combining --repeat or #[Retry] with a --filter that selects a single data set ended the run with "An error occurred inside PHPUnit" instead of running the selected data set. With assertions disabled, the retry descriptor passed null where a test case is required and the repeat descriptor sent an empty repeat suite to the worker. A sequential run runs the selected data set and reports nothing else, because TestSuite::run() returns early for a suite the selection emptied.

Skip such a member where the members of a data provider suite are described, as the collection of the work units already skips it at the level above.
When a worker dies or its result envelope fails verification, the tests of its unit that never reported a result are reported as errored, so that the event stream stays complete. The tests to report were collected by walking each member of the unit with TestSuite::tests(), which answers with every test the suite holds, not with the tests that test selection left in it.

Now that a unit dispatches only the tests the selection selected, the two no longer agree: with --filter selecting one data set of a data provider method whose worker dies, the run reported the other data sets as errored as well (tests that were never dispatched and that a sequential run would not have run at all) and counted them among the tests it ran.

Iterate the member instead, as the descriptors that dispatch it already do, so that the tests reported as missing a result are exactly the tests the worker was asked to produce one for.
…ored

The tests of a unit whose worker died and that never reported a result are reported with ChildProcessErrored, TestErrored, and TestFinished: the events the sequential runner emits for a test whose child process ended unexpectedly, but without the TestPrepared that precedes them there, because the parent never prepared a test the worker was to run.

A consumer that learns of a test only when it errors treats it as a test outside a test method and reports it in its own right: the TestDox result collector processes the test on TestErrored when no TestPrepared opened it, and again on TestFinished. With --parallel and --testdox, a crashed unit therefore listed every test whose result never arrived twice, while the progress printer, which knows about ChildProcessErrored, showed the correct number.

Emit TestPrepared for such a test as well, so that the events a crashed unit reports are the events a sequential run reports for the same test.
The scheduler dispatches the units of a chunk longest-recorded-duration first, so that the longest-running unit starts while there is still work to overlap it with. What a unit is estimated to cost is the sum of the durations a previous run recorded for its tests, and the members that aggregate tests (the tests of a data provider method, the attempts of a retried test method, the repetitions of a repeated one) were summed up by walking them with TestSuite::tests().

That answers with every test the member holds, so a unit that test selection has narrowed was estimated by what it would cost without the selection, and the units of a filtered run were ordered by durations they will not spend.

Iterate the member instead, as everything else that reads a member of a unit now does.
…worker

TestBuilder::configure() applies four settings to a test case that a parallel worker recreates from its descriptor: process isolation, the preservation of global state, and the backup of global variables and of static properties. The test that asserts it leaves such a test case configured exactly as build() leaves the one it builds compared the preservation of global state as well, but no fixture carried metadata for it, so the comparison only ever saw the default on both sides: dropping the propagation of that setting from TestBuilder left every test of TestBuilderTest passing.
…they share the temporary files named after that file when code coverage is collected
…ose events go to the destination the caller provides, so that a caller can advance several PHPT tests at the same time
…in the groups its tests form, stopping where the collected results call for the run to stop, so that such a unit is not reported past the point at which a sequential run would have stopped
… standalone unit at its suite index, so that --parallel does not serialize every PHPT test when --repeat or --retry is used
@sebastianbergmann
sebastianbergmann force-pushed the feature/parallel-test-execution branch from 6be790b to 7757ead Compare August 19, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/test-runner CLI test runner type/enhancement A new idea that should be implemented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants