Skip to content

Commit 639b4d4

Browse files
ondrejmirtesclaude
andcommitted
Add pcntl_fork() parallel worker path
When PHPSTAN_PARALLEL_FORK=1 is set and OPcache + JIT are off, ParallelAnalyser forks workers from the already-booted process (ForkedProcess) instead of spawning fresh ones — the fork inherits the DI container, skipping the per-worker re-boot. Forked workers still speak the same TCP + NDJSON protocol. ForkParallelChecker gates the path; the full analysed-files list is now threaded through ParallelAnalyser::analyse() so the forked child can set it on NodeScopeResolver — its two callers (AnalyserRunner, FixerWorkerCommand) are updated accordingly. ext-pcntl/ext-posix added to composer.json suggest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 606bf02 commit 639b4d4

6 files changed

Lines changed: 267 additions & 2 deletions

File tree

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
"phpstan/phpstan": "2.1.x",
6565
"symfony/polyfill-php73": "*"
6666
},
67+
"suggest": {
68+
"ext-pcntl": "Enables forking parallel analysis workers from the already-booted process (experimental, skips the per-worker re-boot)",
69+
"ext-posix": "Used together with ext-pcntl for forked parallel analysis workers"
70+
},
6771
"require-dev": {
6872
"cweagans/composer-patches": "^1.7.3",
6973
"php-parallel-lint/php-parallel-lint": "^1.2.0",

src/Command/AnalyserRunner.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function runAnalyser(
8383
if ($mainScript !== null && $schedule->getNumberOfProcesses() > 0) {
8484
$loop = new StreamSelectLoop();
8585
$result = null;
86-
$promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null);
86+
$promise = $this->parallelAnalyser->analyse($loop, $schedule, $allAnalysedFiles, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null);
8787
$promise->then(static function (AnalyserResult $tmp) use (&$result): void {
8888
$result = $tmp;
8989
});

src/Command/FixerWorkerCommand.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
212212
$loop,
213213
$container,
214214
$filesToAnalyse,
215+
$inceptionFiles,
215216
$configuration,
216217
$input,
217218
function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ($out, $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles): void {
@@ -381,10 +382,11 @@ private function filterErrors(array $errors, IgnoredErrorHelperResult $ignoredEr
381382

382383
/**
383384
* @param string[] $files
385+
* @param string[] $allAnalysedFiles
384386
* @param callable(list<Error>, list<Error>, string[]): void $onFileAnalysisHandler
385387
* @return PromiseInterface<AnalyserResult>
386388
*/
387-
private function runAnalyser(LoopInterface $loop, Container $container, array $files, ?string $configuration, InputInterface $input, callable $onFileAnalysisHandler): PromiseInterface
389+
private function runAnalyser(LoopInterface $loop, Container $container, array $files, array $allAnalysedFiles, ?string $configuration, InputInterface $input, callable $onFileAnalysisHandler): PromiseInterface
388390
{
389391
/** @var ParallelAnalyser $parallelAnalyser */
390392
$parallelAnalyser = $container->getByType(ParallelAnalyser::class);
@@ -423,6 +425,7 @@ private function runAnalyser(LoopInterface $loop, Container $container, array $f
423425
return $parallelAnalyser->analyse(
424426
$loop,
425427
$schedule,
428+
$allAnalysedFiles,
426429
$mainScript,
427430
null,
428431
$configuration,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parallel;
4+
5+
use PHPStan\DependencyInjection\AutowiredService;
6+
use function function_exists;
7+
use function getenv;
8+
use function opcache_get_status;
9+
10+
/**
11+
* Decides whether parallel analysis should fork workers via pcntl_fork()
12+
* (see ForkedProcess) instead of spawning fresh PHP processes (see SpawnedProcess).
13+
*
14+
* Experimental and opt-in: enabled only when PHPSTAN_PARALLEL_FORK=1 is set,
15+
* the pcntl/posix functions exist, and OPcache + JIT are both off — their
16+
* shared memory is not safe to populate concurrently from forked children and
17+
* doing so corrupts analysis results.
18+
*/
19+
#[AutowiredService]
20+
final class ForkParallelChecker
21+
{
22+
23+
public function isSupported(): bool
24+
{
25+
if (
26+
!function_exists('pcntl_fork')
27+
|| !function_exists('pcntl_waitpid')
28+
|| !function_exists('pcntl_wifexited')
29+
|| !function_exists('pcntl_wexitstatus')
30+
|| !function_exists('posix_kill')
31+
) {
32+
return false;
33+
}
34+
35+
if (getenv('PHPSTAN_PARALLEL_FORK') !== '1') {
36+
return false;
37+
}
38+
39+
// OPcache's shared memory and the JIT buffer are not safe to populate
40+
// concurrently from multiple forked children — doing so corrupts
41+
// analysis results. Forked workers require OPcache and JIT to be off.
42+
if ($this->isOpcacheOrJitEnabled()) {
43+
return false;
44+
}
45+
46+
return true;
47+
}
48+
49+
private function isOpcacheOrJitEnabled(): bool
50+
{
51+
if (!function_exists('opcache_get_status')) {
52+
return false;
53+
}
54+
55+
$status = opcache_get_status(false);
56+
if ($status === false) {
57+
return false;
58+
}
59+
60+
if (($status['opcache_enabled'] ?? false) === true) {
61+
return true;
62+
}
63+
64+
return ($status['jit']['enabled'] ?? false) === true;
65+
}
66+
67+
}

src/Parallel/ForkedProcess.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parallel;
4+
5+
use PHPStan\ShouldNotHappenException;
6+
use React\EventLoop\LoopInterface;
7+
use React\EventLoop\TimerInterface;
8+
use React\Socket\TcpServer;
9+
use Symfony\Component\Console\Output\StreamOutput;
10+
use Throwable;
11+
use function fclose;
12+
use function pcntl_fork;
13+
use function pcntl_waitpid;
14+
use function pcntl_wexitstatus;
15+
use function pcntl_wifexited;
16+
use function rewind;
17+
use function stream_get_contents;
18+
use function tmpfile;
19+
use const WNOHANG;
20+
21+
/**
22+
* Parallel worker backed by pcntl_fork(): the worker is forked from the
23+
* already-booted main process, so it inherits the DI container for free and
24+
* skips the application re-boot that a {@see SpawnedProcess} pays.
25+
*
26+
* The forked child still talks to ParallelAnalyser over the same TCP + NDJSON
27+
* protocol — only the process-creation mechanism differs.
28+
*/
29+
final class ForkedProcess extends ProcessBase
30+
{
31+
32+
private const WAITPID_POLL_INTERVAL = 0.01;
33+
34+
/** @var resource|null */
35+
private $stdOut = null;
36+
37+
private ?TimerInterface $waitTimer = null;
38+
39+
/**
40+
* @param string[] $analysedFiles
41+
*/
42+
public function __construct(
43+
LoopInterface $loop,
44+
float $timeoutSeconds,
45+
private WorkerRunner $workerRunner,
46+
private TcpServer $server,
47+
private int $serverPort,
48+
private string $identifier,
49+
private array $analysedFiles,
50+
private ?string $tmpFile,
51+
private ?string $insteadOfFile,
52+
)
53+
{
54+
parent::__construct($loop, $timeoutSeconds);
55+
}
56+
57+
/**
58+
* @param callable(mixed[] $json) : void $onData
59+
* @param callable(Throwable $exception): void $onError
60+
* @param callable(?int $exitCode, string $output) : void $onExit
61+
*/
62+
public function start(callable $onData, callable $onError, callable $onExit): void
63+
{
64+
$this->setCallbacks($onData, $onError);
65+
66+
// Created before the fork so the parent can read what the child wrote.
67+
$tmpStdOut = tmpfile();
68+
if ($tmpStdOut === false) {
69+
throw new ShouldNotHappenException('Failed creating temp file for stdout.');
70+
}
71+
$this->stdOut = $tmpStdOut;
72+
73+
$pid = pcntl_fork();
74+
75+
if ($pid === -1) {
76+
fclose($this->stdOut);
77+
$this->stdOut = null;
78+
// Deferred so it runs after ParallelAnalyser has attached this
79+
// process to the pool — otherwise tryQuitProcess() would no-op.
80+
$this->loop->futureTick(static function () use ($onExit): void {
81+
$onExit(null, 'pcntl_fork() failed.');
82+
});
83+
return;
84+
}
85+
86+
if ($pid === 0) {
87+
// Child: drop the inherited listening socket immediately, then run
88+
// the worker on its own fresh event loop and never return.
89+
$this->server->close();
90+
$output = new StreamOutput($this->stdOut);
91+
$exitCode = $this->workerRunner->run(
92+
$output,
93+
$this->analysedFiles,
94+
$this->serverPort,
95+
$this->identifier,
96+
$this->tmpFile,
97+
$this->insteadOfFile,
98+
);
99+
exit($exitCode);
100+
}
101+
102+
// Parent: poll for the child to exit and report it through $onExit.
103+
$this->waitTimer = $this->loop->addPeriodicTimer(self::WAITPID_POLL_INTERVAL, function () use ($pid, $onExit): void {
104+
$status = 0;
105+
$result = pcntl_waitpid($pid, $status, WNOHANG);
106+
if ($result === 0) {
107+
return;
108+
}
109+
110+
$this->cancelWaitTimer();
111+
$this->cancelTimer();
112+
113+
$exitCode = null;
114+
if ($result > 0 && pcntl_wifexited($status)) {
115+
$exitStatus = pcntl_wexitstatus($status);
116+
if ($exitStatus !== false) {
117+
$exitCode = $exitStatus;
118+
}
119+
}
120+
121+
$output = '';
122+
if ($this->stdOut !== null) {
123+
rewind($this->stdOut);
124+
$output = (string) stream_get_contents($this->stdOut);
125+
fclose($this->stdOut);
126+
$this->stdOut = null;
127+
}
128+
129+
$onExit($exitCode, $output);
130+
});
131+
}
132+
133+
public function quit(): void
134+
{
135+
// Ending the connection makes the child's event loop drain and the
136+
// child exit; the waitpid poll timer must keep running until then so
137+
// the child is actually reaped (otherwise: zombie + hang).
138+
$this->endConnection();
139+
}
140+
141+
private function cancelWaitTimer(): void
142+
{
143+
if ($this->waitTimer === null) {
144+
return;
145+
}
146+
147+
$this->loop->cancelTimer($this->waitTimer);
148+
$this->waitTimer = null;
149+
}
150+
151+
}

src/Parallel/ParallelAnalyser.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@
2727
use function count;
2828
use function defined;
2929
use function escapeshellarg;
30+
use function fwrite;
3031
use function ini_get;
3132
use function max;
3233
use function memory_get_usage;
3334
use function parse_url;
3435
use function sprintf;
3536
use function str_contains;
3637
use const PHP_URL_PORT;
38+
use const STDERR;
3739

3840
#[AutowiredService]
3941
final class ParallelAnalyser
@@ -52,19 +54,23 @@ public function __construct(
5254
float $processTimeout,
5355
#[AutowiredParameter(ref: '%parallel.buffer%')]
5456
private int $decoderBufferSize,
57+
private ForkParallelChecker $forkParallelChecker,
58+
private WorkerRunner $workerRunner,
5559
)
5660
{
5761
$this->processTimeout = max($processTimeout, self::DEFAULT_TIMEOUT);
5862
}
5963

6064
/**
65+
* @param string[] $allAnalysedFiles
6166
* @param Closure(int, list<string>=): void|null $postFileCallback
6267
* @param (callable(list<Error>, list<Error>, string[]): void)|null $onFileAnalysisHandler
6368
* @return PromiseInterface<AnalyserResult>
6469
*/
6570
public function analyse(
6671
LoopInterface $loop,
6772
Schedule $schedule,
73+
array $allAnalysedFiles,
6874
string $mainScript,
6975
?Closure $postFileCallback,
7076
?string $projectConfigFile,
@@ -170,6 +176,11 @@ public function analyse(
170176
$this->processPool->quitAll();
171177
};
172178

179+
$useFork = $this->forkParallelChecker->isSupported();
180+
if ($useFork && $input->hasParameterOption(['-v', '-vv', '-vvv', '--verbose'], true)) {
181+
fwrite(STDERR, "Note: using pcntl_fork() for parallel workers (experimental).\n");
182+
}
183+
173184
for ($i = 0; $i < $numberOfProcesses; $i++) {
174185
if (count($jobs) === 0) {
175186
break;
@@ -191,10 +202,17 @@ public function analyse(
191202
}
192203

193204
$process = $this->createProcess(
205+
$useFork,
194206
$loop,
207+
$server,
208+
$serverPort,
209+
$processIdentifier,
210+
$allAnalysedFiles,
195211
$mainScript,
196212
$projectConfigFile,
197213
$commandOptions,
214+
$tmpFile,
215+
$insteadOfFile,
198216
$input,
199217
);
200218
$process->start(function (array $json) use ($process, &$internalErrors, &$errors, &$filteredPhpErrors, &$allPhpErrors, &$locallyIgnoredErrors, &$linesToIgnore, &$unmatchedLineIgnores, &$collectedData, &$dependencies, &$usedTraitDependencies, &$exportedNodes, &$peakMemoryUsages, &$jobs, $postFileCallback, &$internalErrorsCount, &$reachedInternalErrorsCountLimit, $processIdentifier, $onFileAnalysisHandler, &$allProcessedFiles): void {
@@ -356,16 +374,38 @@ public function analyse(
356374
}
357375

358376
/**
377+
* @param string[] $allAnalysedFiles
359378
* @param string[] $commandOptions
360379
*/
361380
private function createProcess(
381+
bool $useFork,
362382
LoopInterface $loop,
383+
TcpServer $server,
384+
int $serverPort,
385+
string $processIdentifier,
386+
array $allAnalysedFiles,
363387
string $mainScript,
364388
?string $projectConfigFile,
365389
array $commandOptions,
390+
?string $tmpFile,
391+
?string $insteadOfFile,
366392
InputInterface $input,
367393
): Process
368394
{
395+
if ($useFork) {
396+
return new ForkedProcess(
397+
$loop,
398+
$this->processTimeout,
399+
$this->workerRunner,
400+
$server,
401+
$serverPort,
402+
$processIdentifier,
403+
$allAnalysedFiles,
404+
$tmpFile,
405+
$insteadOfFile,
406+
);
407+
}
408+
369409
return new SpawnedProcess(ProcessHelper::getWorkerCommand(
370410
$mainScript,
371411
'worker',

0 commit comments

Comments
 (0)