Skip to content

Commit 37a4aee

Browse files
committed
Merge pull request #6260 from phpstan/fork-parallel-turbo
2 parents 4db7884 + 7e8c131 commit 37a4aee

13 files changed

Lines changed: 527 additions & 15 deletions

‎bin/phpstan‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use PHPStan\Command\FixerWorkerCommand;
1010
use PHPStan\Command\WorkerCommand;
1111
use PHPStan\Internal\ComposerHelper;
1212
use PHPStan\Turbo\TurboExtensionEnabler;
13+
use PHPStan\Turbo\TurboProcessRestarter;
1314
use Symfony\Component\Console\Helper\ProgressBar;
1415

1516
(function () {
@@ -24,6 +25,9 @@ use Symfony\Component\Console\Helper\ProgressBar;
2425
define('__PHPSTAN_RUNNING__', true);
2526

2627
require_once __DIR__ . '/../src/Turbo/TurboExtensionEnabler.php';
28+
require_once __DIR__ . '/../src/Turbo/TurboExtensionSelector.php';
29+
require_once __DIR__ . '/../src/Turbo/TurboProcessRestarter.php';
30+
TurboProcessRestarter::restartIfSuitable($_SERVER['argv']);
2731
TurboExtensionEnabler::enableIfLoaded();
2832

2933
$analysisStartTime = microtime(true);

‎build/phpstan.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ parameters:
149149
identifier: shipmonk.deadMethod
150150
path: PHPStan/Build/TurboAttributeCollector.php
151151
reportUnmatched: false
152+
-
153+
# called from bin/phpstan before the autoloader, outside the analysed paths
154+
identifier: shipmonk.deadMethod
155+
message: '#^Unused PHPStan\\Turbo\\TurboProcessRestarter::restartIfSuitable#'
156+
reportUnmatched: false
152157
-
153158
# the turbo extension shadows this seam class; the nullable returns
154159
# are the contract of the pair, the PHP twin honestly never hits

‎src/Parallel/ForkParallelChecker.php‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,31 @@
55
use PHPStan\Command\Output;
66
use PHPStan\DependencyInjection\AutowiredService;
77
use PHPStan\Diagnose\DiagnoseExtension;
8+
use PHPStan\Turbo\TurboExtensionEnabler;
89
use function function_exists;
9-
use function getenv;
1010
use function opcache_get_status;
1111
use function sprintf;
1212

1313
/**
1414
* Decides whether parallel analysis should fork workers via pcntl_fork()
1515
* (see ForkedProcess) instead of spawning fresh PHP processes (see SpawnedProcess).
1616
*
17-
* Experimental and opt-in: enabled only when PHPSTAN_PARALLEL_FORK=1 is set,
18-
* the pcntl/posix functions exist, and OPcache + JIT are both off — their
19-
* shared memory is not safe to populate concurrently from forked children and
20-
* doing so corrupts analysis results.
17+
* Fork is used whenever possible — a forked worker inherits the booted
18+
* process and skips the application re-boot a spawned one pays. It requires:
19+
*
20+
* - pcntl/posix functions (not available on Windows — always spawn there)
21+
* - the turbo extension, active with the expected version. A forked worker
22+
* inherits the parent's extensions, so the spawn-time `-d extension=`
23+
* injection cannot reach it — TurboProcessRestarter re-executes the main
24+
* process with the distributed binary loaded when needed. The extension
25+
* also arms the phar-fork-guard: libphar serves phar:// reads through one
26+
* shared per-archive fd whose seek cursor forked processes race on,
27+
* corrupting reads — the guard gives each forked child a private cursor.
28+
* Without it, running from a phar would corrupt analysis, so no turbo
29+
* means spawn.
30+
* - OPcache + JIT off — their shared memory is not safe to populate
31+
* concurrently from forked children and doing so corrupts analysis
32+
* results.
2133
*/
2234
#[AutowiredService]
2335
final class ForkParallelChecker implements DiagnoseExtension
@@ -34,15 +46,13 @@ public function print(Output $output): void
3446

3547
$reason = $this->getDisabledReason();
3648
if ($reason === null) {
37-
$output->writeLineFormatted('Mechanism: fork (pcntl_fork — experimental)');
49+
$output->writeLineFormatted('Mechanism: fork (pcntl_fork)');
3850
$output->writeLineFormatted('');
3951
return;
4052
}
4153

4254
$output->writeLineFormatted('Mechanism: spawn (react/child-process)');
43-
if (getenv('PHPSTAN_PARALLEL_FORK') === '1') {
44-
$output->writeLineFormatted(sprintf('Reason fork not used: %s', $reason));
45-
}
55+
$output->writeLineFormatted(sprintf('Reason fork not used: %s', $reason));
4656
$output->writeLineFormatted('');
4757
}
4858

@@ -58,8 +68,8 @@ private function getDisabledReason(): ?string
5868
return 'pcntl/posix functions are not available';
5969
}
6070

61-
if (getenv('PHPSTAN_PARALLEL_FORK') !== '1') {
62-
return 'PHPSTAN_PARALLEL_FORK environment variable is not set to "1"';
71+
if (!TurboExtensionEnabler::isActive()) {
72+
return 'the turbo extension is not active (see the Turbo extension section)';
6373
}
6474

6575
if ($this->isOpcacheOrJitEnabled()) {

‎src/Turbo/TurboDiagnoseExtension.php‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ public function print(Output $output): void
4343
));
4444

4545
if (TurboExtensionEnabler::isLoaded()) {
46-
$workerBinaryLine = 'loaded via php.ini, workers inherit it';
46+
$restartPath = TurboProcessRestarter::getRestartExtensionPath();
47+
if ($restartPath !== null) {
48+
$workerBinaryLine = sprintf('%s (loaded via process restart)', $restartPath);
49+
} else {
50+
$workerBinaryLine = 'loaded via php.ini, workers inherit it';
51+
}
4752
} else {
4853
$workerBinaryLine = $workerBinary ?? 'none found';
4954
}

‎src/Turbo/TurboExtensionEnabler.php‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace PHPStan\Turbo;
44

5+
use Phar;
56
use PHPStanTurbo\Runtime;
7+
use function class_exists;
68
use function dirname;
79
use function extension_loaded;
810
use function file_get_contents;
@@ -20,7 +22,7 @@ final class TurboExtensionEnabler
2022
* version is the short SHA of the last commit touching turbo-ext/src/,
2123
* enforced by the phar.yml turbo-version job.
2224
*/
23-
public const EXPECTED_EXTENSION_VERSION = '83f3b7a';
25+
public const EXPECTED_EXTENSION_VERSION = 'f529bad';
2426

2527
private static bool $typeCombinatorCacheEnabled = false;
2628

@@ -150,6 +152,18 @@ public static function enableIfLoaded(): void
150152
// resolve to them.
151153
require_once $stubsFile;
152154

155+
// When running from a phar, arm the pthread_atfork hooks that keep
156+
// phar:// reads safe in pcntl_fork()ed workers — libphar serves them
157+
// through one shared archive fd whose seek cursor forked processes
158+
// would otherwise race on. Fork mode requires the guard, and
159+
// ForkParallelChecker only allows fork with the extension active.
160+
if (class_exists('Phar', false)) {
161+
$pharPath = Phar::running(false);
162+
if ($pharPath !== '') {
163+
Runtime::enablePharForkGuard($pharPath);
164+
}
165+
}
166+
153167
self::$typeCombinatorCacheEnabled = true;
154168
self::$enabled = true;
155169
}

‎src/Turbo/TurboExtensionSelector.php‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
/**
2020
* Locates the distributed turbo extension binary matching the current runtime
21-
* so worker processes can load it via `-d extension=`.
21+
* so it can be loaded via `-d extension=` — into spawned worker processes
22+
* (ProcessHelper), or into the restarted main process (TurboProcessRestarter)
23+
* whose pcntl_fork()ed workers then inherit it.
2224
*
2325
* The binaries are committed to the phpstan/phpstan repository next to
2426
* phpstan.phar (turbo-ext/<platform>/phpstan_turbo-<minor>.so, .dll on
@@ -37,12 +39,30 @@ final class TurboExtensionSelector
3739
public static function findExtensionForWorkers(): ?string
3840
{
3941
if (TurboExtensionEnabler::isLoaded()) {
42+
$restartPath = TurboProcessRestarter::getRestartExtensionPath();
43+
if ($restartPath !== null) {
44+
// loaded through the restart's own -d flag (see
45+
// TurboProcessRestarter) — spawned workers do not inherit
46+
// command-line -d flags, so they need it passed explicitly
47+
return $restartPath;
48+
}
49+
4050
// loaded through php.ini — workers inherit the ini file
4151
return null;
4252
}
4353
if (getenv('PHPSTAN_TURBO') === '0') {
4454
return null;
4555
}
56+
57+
return self::findExtension();
58+
}
59+
60+
/**
61+
* Locates the distributed extension binary for the current platform —
62+
* present only next to a phar-based installation.
63+
*/
64+
public static function findExtension(): ?string
65+
{
4666
if ((bool) PHP_DEBUG) {
4767
return null;
4868
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Turbo;
4+
5+
use function extension_loaded;
6+
use function function_exists;
7+
use function get_cfg_var;
8+
use function getenv;
9+
use function ini_get;
10+
use function is_string;
11+
use function pcntl_exec;
12+
use function php_ini_loaded_file;
13+
use const PHP_BINARY;
14+
15+
/**
16+
* Restarts the main PHPStan process via pcntl_exec() with the distributed
17+
* turbo extension loaded through -d extension=.
18+
*
19+
* Parallel analysis prefers pcntl_fork()ed workers over spawned ones (see
20+
* ForkParallelChecker) — a forked worker inherits the booted process instead
21+
* of paying a full re-boot. But a forked worker also inherits the parent's
22+
* loaded extensions: the spawn-time `-d extension=` injection
23+
* (ProcessHelper) cannot reach it, and dl() cannot load a binary from next
24+
* to the phar. So when the extension binary is available but not loaded,
25+
* the whole process is re-executed with it before anything else runs —
26+
* forked workers then inherit the extension, its stub shadowing, and (when
27+
* running from a phar) the phar-fork-guard that keeps phar:// reads safe
28+
* across fork.
29+
*
30+
* The restart carries a marker ini entry with the extension path. It doubles
31+
* as the retry stop (a binary that failed to load leaves the extension
32+
* unloaded — without the marker the restart would loop) and tells
33+
* TurboExtensionSelector that spawned workers still need the -d flag
34+
* (command-line -d flags, unlike php.ini, are not inherited).
35+
*/
36+
final class TurboProcessRestarter
37+
{
38+
39+
public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath';
40+
41+
/**
42+
* The extension path this process was restarted with, null when the
43+
* process was not restarted.
44+
*/
45+
public static function getRestartExtensionPath(): ?string
46+
{
47+
$path = get_cfg_var(self::EXTENSION_PATH_INI);
48+
if (!is_string($path) || $path === '') {
49+
return null;
50+
}
51+
52+
return $path;
53+
}
54+
55+
/**
56+
* On success the call never returns — the process image is replaced.
57+
*
58+
* @param list<string> $argv
59+
*/
60+
public static function restartIfSuitable(array $argv): void
61+
{
62+
if (extension_loaded('phpstan_turbo')) {
63+
return;
64+
}
65+
if (getenv('PHPSTAN_TURBO') === '0') {
66+
return;
67+
}
68+
if (self::getRestartExtensionPath() !== null) {
69+
// already restarted and the extension still did not load
70+
return;
71+
}
72+
if (
73+
!function_exists('pcntl_exec')
74+
|| !function_exists('pcntl_fork')
75+
|| !function_exists('pcntl_waitpid')
76+
|| !function_exists('pcntl_wifexited')
77+
|| !function_exists('pcntl_wexitstatus')
78+
|| !function_exists('posix_kill')
79+
) {
80+
// fork mode is impossible here (see ForkParallelChecker) and
81+
// spawned workers get the extension from ProcessHelper already
82+
return;
83+
}
84+
85+
$extensionPath = TurboExtensionSelector::findExtension();
86+
if ($extensionPath === null) {
87+
return;
88+
}
89+
90+
$args = [];
91+
$phpIni = php_ini_loaded_file();
92+
if ($phpIni !== false) {
93+
$args[] = '-c';
94+
$args[] = $phpIni;
95+
}
96+
$args[] = '-d';
97+
$args[] = 'memory_limit=' . ini_get('memory_limit');
98+
$args[] = '-d';
99+
$args[] = 'extension=' . $extensionPath;
100+
$args[] = '-d';
101+
$args[] = self::EXTENSION_PATH_INI . '=' . $extensionPath;
102+
foreach ($argv as $arg) {
103+
$args[] = $arg;
104+
}
105+
106+
pcntl_exec(PHP_BINARY, $args);
107+
// pcntl_exec() returns only on failure — continue without the extension
108+
}
109+
110+
}

‎turbo-ext/analysis-stub.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ public static function configure(array $classMap): void
1919
{
2020
}
2121

22+
public static function enablePharForkGuard(string $pharPath): void
23+
{
24+
}
25+
2226
}

‎turbo-ext/config.w32‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ if (PHP_PHPSTAN_TURBO != "no") {
2929
// directory by splitting on backslashes only — with forward slashes the
3030
// objects compile flat while the link list expects the subpath
3131
EXTENSION("phpstan_turbo",
32-
"src\\main.cpp src\\support.cpp src\\ArenaCache.cpp src\\CombinationsHelper.cpp src\\ConditionalExpressionHolder.cpp src\\ExpressionResultStorage.cpp src\\ExpressionTypeHolder.cpp src\\NodeScanner.cpp src\\NodeTraverser.cpp src\\ScopeOps.cpp src\\TrinaryLogic.cpp src\\TypeCombinatorCache.cpp",
32+
"src\\main.cpp src\\support.cpp src\\ArenaCache.cpp src\\CombinationsHelper.cpp src\\ConditionalExpressionHolder.cpp src\\ExpressionResultStorage.cpp src\\ExpressionTypeHolder.cpp src\\NodeScanner.cpp src\\NodeTraverser.cpp src\\PharForkGuard.cpp src\\ScopeOps.cpp src\\TrinaryLogic.cpp src\\TypeCombinatorCache.cpp",
3333
true,
3434
flags);
3535
ADD_SOURCES(configure_module_dirname + "/src/parser",

0 commit comments

Comments
 (0)