Skip to content

Commit 0aaaa47

Browse files
ondrejmirtesclaude
andcommitted
Add pcntl_fork() PHPStan Pro worker path
When PHPSTAN_PARALLEL_FORK=1 is set and OPcache + JIT are off, FixerApplication forks the fixer:worker from the already-booted process (ForkedProcessPromise) instead of spawning a fresh one — the fork inherits the DI container, skipping the re-boot. The forked child still talks to FixerApplication over the same TCP + NDJSON protocol. FixerApplication::run() now receives the InceptionResult so the forked child has the analysed files, project config and error output without re-deriving them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 009038d commit 0aaaa47

3 files changed

Lines changed: 230 additions & 5 deletions

File tree

src/Command/AnalyseCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,7 @@ private function runFixer(InceptionResult $inceptionResult, Container $container
840840
$fixerApplication = $container->getByType(FixerApplication::class);
841841

842842
return $fixerApplication->run(
843-
$inceptionResult->getProjectConfigFile(),
843+
$inceptionResult,
844844
$input,
845845
$output,
846846
count($files),

src/Command/FixerApplication.php

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@
1919
use PHPStan\File\FileMonitorResult;
2020
use PHPStan\File\FileReader;
2121
use PHPStan\File\FileWriter;
22+
use PHPStan\File\PathNotFoundException;
2223
use PHPStan\Internal\ComposerHelper;
2324
use PHPStan\Internal\DirectoryCreator;
2425
use PHPStan\Internal\DirectoryCreatorException;
2526
use PHPStan\Internal\HttpClientFactory;
27+
use PHPStan\Parallel\ForkParallelChecker;
2628
use PHPStan\PhpDoc\StubFilesProvider;
29+
use PHPStan\Process\ForkedProcessPromise;
2730
use PHPStan\Process\ProcessCanceledException;
2831
use PHPStan\Process\ProcessCrashedException;
2932
use PHPStan\Process\ProcessHelper;
@@ -95,18 +98,25 @@ public function __construct(
9598
#[AutowiredParameter]
9699
private string $usedLevel,
97100
private HttpClientFactory $httpClientFactory,
101+
private ForkParallelChecker $forkParallelChecker,
102+
private FixerWorkerRunner $fixerWorkerRunner,
98103
)
99104
{
100105
}
101106

102107
public function run(
103-
?string $projectConfigFile,
108+
InceptionResult $inceptionResult,
104109
InputInterface $input,
105110
OutputInterface $output,
106111
int $filesCount,
107112
string $mainScript,
108113
): int
109114
{
115+
$projectConfigFile = $inceptionResult->getProjectConfigFile();
116+
if ($this->forkParallelChecker->isSupported() && $output->isVerbose()) {
117+
$output->writeln('Note: using pcntl_fork() for the PHPStan Pro worker (experimental).');
118+
}
119+
110120
$loop = new StreamSelectLoop();
111121
$server = new TcpServer('127.0.0.1:0', $loop);
112122
/** @var string $serverAddress */
@@ -115,7 +125,7 @@ public function run(
115125
/** @var int<0, 65535> $serverPort */
116126
$serverPort = parse_url($serverAddress, PHP_URL_PORT);
117127

118-
$server->on('connection', function (ConnectionInterface $connection) use ($loop, $projectConfigFile, $input, $output, $mainScript, $filesCount): void {
128+
$server->on('connection', function (ConnectionInterface $connection) use ($loop, $inceptionResult, $projectConfigFile, $input, $output, $mainScript, $filesCount): void {
119129
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
120130
$jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0;
121131
// phpcs:enable
@@ -158,14 +168,15 @@ public function run(
158168

159169
$this->analyse(
160170
$loop,
171+
$inceptionResult,
161172
$mainScript,
162173
$projectConfigFile,
163174
$input,
164175
$output,
165176
$encoder,
166177
);
167178

168-
$this->monitorFileChanges($loop, function (FileMonitorResult $changes) use ($loop, $mainScript, $projectConfigFile, $input, $encoder, $output): void {
179+
$this->monitorFileChanges($loop, function (FileMonitorResult $changes) use ($loop, $inceptionResult, $mainScript, $projectConfigFile, $input, $encoder, $output): void {
169180
if ($this->processInProgress !== null) {
170181
$this->processInProgress->cancel();
171182
$this->processInProgress = null;
@@ -179,6 +190,7 @@ public function run(
179190

180191
$this->analyse(
181192
$loop,
193+
$inceptionResult,
182194
$mainScript,
183195
$projectConfigFile,
184196
$input,
@@ -418,6 +430,7 @@ private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCal
418430

419431
private function analyse(
420432
LoopInterface $loop,
433+
InceptionResult $inceptionResult,
421434
string $mainScript,
422435
?string $projectConfigFile,
423436
InputInterface $input,
@@ -447,7 +460,16 @@ private function analyse(
447460
});
448461
});
449462

450-
$process = $this->createProcessPromise($loop, $mainScript, $projectConfigFile, $input, $serverPort);
463+
$process = $this->createProcessPromise(
464+
$this->forkParallelChecker->isSupported(),
465+
$loop,
466+
$server,
467+
$mainScript,
468+
$projectConfigFile,
469+
$input,
470+
$serverPort,
471+
$inceptionResult,
472+
);
451473
$this->processInProgress = $process->run();
452474

453475
$this->processInProgress->then(function () use ($server): void {
@@ -562,13 +584,37 @@ private function getStubFiles(): array
562584
* @param int<0, 65535> $serverPort
563585
*/
564586
private function createProcessPromise(
587+
bool $useFork,
565588
LoopInterface $loop,
589+
TcpServer $server,
566590
string $mainScript,
567591
?string $projectConfigFile,
568592
InputInterface $input,
569593
int $serverPort,
594+
InceptionResult $inceptionResult,
570595
): ProcessPromise
571596
{
597+
if ($useFork) {
598+
try {
599+
[$inceptionFiles, $isOnlyFiles] = $inceptionResult->getFiles();
600+
} catch (PathNotFoundException | InceptionNotSuccessfulException) {
601+
throw new ShouldNotHappenException();
602+
}
603+
604+
return new ForkedProcessPromise(
605+
$loop,
606+
$this->fixerWorkerRunner,
607+
$server,
608+
$inceptionResult->getErrorOutput(),
609+
$inceptionFiles,
610+
$isOnlyFiles,
611+
$inceptionResult->getProjectConfigArray(),
612+
$projectConfigFile,
613+
$serverPort,
614+
$input,
615+
);
616+
}
617+
572618
return new SpawnedProcessPromise($loop, ProcessHelper::getWorkerCommand(
573619
$mainScript,
574620
'fixer:worker',
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Process;
4+
5+
use PHPStan\Command\FixerWorkerRunner;
6+
use PHPStan\Command\Output;
7+
use PHPStan\ShouldNotHappenException;
8+
use React\EventLoop\LoopInterface;
9+
use React\EventLoop\TimerInterface;
10+
use React\Promise\Deferred;
11+
use React\Promise\PromiseInterface;
12+
use React\Socket\TcpServer;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use function fclose;
15+
use function pcntl_fork;
16+
use function pcntl_waitpid;
17+
use function pcntl_wexitstatus;
18+
use function pcntl_wifexited;
19+
use function posix_kill;
20+
use function rewind;
21+
use function stream_get_contents;
22+
use function tmpfile;
23+
use const SIGTERM;
24+
use const WNOHANG;
25+
26+
/**
27+
* ProcessPromise backed by pcntl_fork(): the PHPStan Pro worker is forked from
28+
* the already-booted main process, so it inherits the DI container for free
29+
* and skips the application re-boot that a {@see SpawnedProcessPromise} pays.
30+
*
31+
* The forked child still talks to FixerApplication over the same TCP + NDJSON
32+
* protocol — only the process-creation mechanism differs.
33+
*/
34+
final class ForkedProcessPromise implements ProcessPromise
35+
{
36+
37+
private const WAITPID_POLL_INTERVAL = 0.01;
38+
39+
/** @var Deferred<string> */
40+
private Deferred $deferred;
41+
42+
private ?int $childPid = null;
43+
44+
/** @var resource|null */
45+
private $stdOut = null;
46+
47+
private ?TimerInterface $waitTimer = null;
48+
49+
private bool $canceled = false;
50+
51+
/**
52+
* @param string[] $inceptionFiles
53+
* @param mixed[]|null $projectConfigArray
54+
*/
55+
public function __construct(
56+
private LoopInterface $loop,
57+
private FixerWorkerRunner $fixerWorkerRunner,
58+
private TcpServer $server,
59+
private Output $errorOutput,
60+
private array $inceptionFiles,
61+
private bool $isOnlyFiles,
62+
private ?array $projectConfigArray,
63+
private ?string $configuration,
64+
private int $serverPort,
65+
private InputInterface $input,
66+
)
67+
{
68+
$this->deferred = new Deferred(function (): void {
69+
$this->cancel();
70+
});
71+
}
72+
73+
/**
74+
* @return PromiseInterface<string>
75+
*/
76+
public function run(): PromiseInterface
77+
{
78+
// Created before the fork so the parent can read what the child wrote.
79+
$tmpStdOut = tmpfile();
80+
if ($tmpStdOut === false) {
81+
throw new ShouldNotHappenException('Failed creating temp file for stdout.');
82+
}
83+
$this->stdOut = $tmpStdOut;
84+
85+
$pid = pcntl_fork();
86+
87+
if ($pid === -1) {
88+
fclose($this->stdOut);
89+
$this->stdOut = null;
90+
// Deferred so it runs after FixerApplication has stored the promise.
91+
$this->loop->futureTick(function (): void {
92+
$this->deferred->reject(new ProcessCrashedException('pcntl_fork() failed.'));
93+
});
94+
95+
return $this->deferred->promise();
96+
}
97+
98+
if ($pid === 0) {
99+
// Child: drop the inherited listening socket immediately, then run
100+
// the worker on its own fresh event loop and never return.
101+
$this->server->close();
102+
$exitCode = $this->fixerWorkerRunner->run(
103+
$this->errorOutput,
104+
$this->inceptionFiles,
105+
$this->isOnlyFiles,
106+
$this->projectConfigArray,
107+
$this->configuration,
108+
$this->serverPort,
109+
$this->input,
110+
);
111+
exit($exitCode);
112+
}
113+
114+
// Parent: poll for the child to exit and resolve/reject accordingly.
115+
$this->childPid = $pid;
116+
$this->waitTimer = $this->loop->addPeriodicTimer(self::WAITPID_POLL_INTERVAL, function () use ($pid): void {
117+
$status = 0;
118+
$result = pcntl_waitpid($pid, $status, WNOHANG);
119+
if ($result === 0) {
120+
return;
121+
}
122+
123+
$this->cancelWaitTimer();
124+
125+
$output = '';
126+
if ($this->stdOut !== null) {
127+
rewind($this->stdOut);
128+
$output = (string) stream_get_contents($this->stdOut);
129+
fclose($this->stdOut);
130+
$this->stdOut = null;
131+
}
132+
133+
if ($this->canceled) {
134+
// cancel() already rejected the promise; just reap the child.
135+
return;
136+
}
137+
138+
$exitCode = null;
139+
if ($result > 0 && pcntl_wifexited($status)) {
140+
$exitStatus = pcntl_wexitstatus($status);
141+
if ($exitStatus !== false) {
142+
$exitCode = $exitStatus;
143+
}
144+
}
145+
146+
if ($exitCode === 0) {
147+
$this->deferred->resolve($output);
148+
return;
149+
}
150+
151+
$this->deferred->reject(new ProcessCrashedException($output));
152+
});
153+
154+
return $this->deferred->promise();
155+
}
156+
157+
private function cancel(): void
158+
{
159+
if ($this->childPid === null) {
160+
throw new ShouldNotHappenException('Cancelling process before running');
161+
}
162+
$this->canceled = true;
163+
// SIGTERM the child; the waitpid poll timer keeps running so it still
164+
// gets reaped (otherwise: zombie).
165+
posix_kill($this->childPid, SIGTERM);
166+
$this->deferred->reject(new ProcessCanceledException());
167+
}
168+
169+
private function cancelWaitTimer(): void
170+
{
171+
if ($this->waitTimer === null) {
172+
return;
173+
}
174+
175+
$this->loop->cancelTimer($this->waitTimer);
176+
$this->waitTimer = null;
177+
}
178+
179+
}

0 commit comments

Comments
 (0)