diff --git a/build/spl-autoload-functions-php-8.neon b/build/spl-autoload-functions-php-8.neon index 36693218894..ee64febde20 100644 --- a/build/spl-autoload-functions-php-8.neon +++ b/build/spl-autoload-functions-php-8.neon @@ -2,5 +2,9 @@ parameters: ignoreErrors: - message: "#^PHPDoc tag @var with type list\\\\|false is not subtype of native type list\\\\.$#" - count: 2 + count: 1 path: ../src/Command/CommandHelper.php + - + message: "#^PHPDoc tag @var with type list\\\\|false is not subtype of native type list\\\\.$#" + count: 2 + path: ../src/Command/BootstrapFilesRunner.php diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index 7ec2169d4fb..1f437b604d0 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -37,6 +37,7 @@ final class AnalyseApplication { public function __construct( + private BootstrapFilesRunner $bootstrapFilesRunner, private AnalyserRunner $analyserRunner, private AnalyserResultFinalizer $analyserResultFinalizer, private StubValidator $stubValidator, @@ -50,6 +51,7 @@ public function __construct( /** * @param string[] $files * @param mixed[]|null $projectConfigArray + * @throws InceptionNotSuccessfulException */ public function analyse( array $files, @@ -214,6 +216,7 @@ private function mapCollectedData(array $collectedData): array /** * @param string[] $files * @param string[] $allAnalysedFiles + * @throws InceptionNotSuccessfulException */ private function runAnalyser( array $files, @@ -230,6 +233,11 @@ private function runAnalyser( $filesCount = count($files); $allAnalysedFilesCount = count($allAnalysedFiles); if ($filesCount === 0) { + // nothing to analyse, but the deferred bootstrapFiles still must + // run in the main thread: the phases that follow may reflect + // analysed code (stub validation, collector rules from the result + // cache) + $this->bootstrapFilesRunner->run($errorOutput, $debug); $errorOutput->getStyle()->progressStart($allAnalysedFilesCount); $errorOutput->getStyle()->progressAdvance($allAnalysedFilesCount); $errorOutput->getStyle()->progressFinish(); @@ -296,7 +304,7 @@ private function runAnalyser( } } - $analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input); + $analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input, $errorOutput); if (!$debug) { $errorOutput->getStyle()->progressFinish(); diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index bd073559b80..d822af5a370 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -201,6 +201,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tmpFile, $insteadOfFile, true, + deferBootstrapFiles: true, ); } catch (InceptionNotSuccessfulException $e) { return 1; diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index dac67670a14..be77e56245c 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -30,6 +30,7 @@ public function __construct( private Analyser $analyser, private ParallelAnalyser $parallelAnalyser, private CpuCoreCounter $cpuCoreCounter, + private BootstrapFilesRunner $bootstrapFilesRunner, ) { } @@ -39,6 +40,7 @@ public function __construct( * @param string[] $allAnalysedFiles * @param Closure(string $file): void|null $preFileCallback * @param Closure(int, list=): void|null $postFileCallback + * @throws InceptionNotSuccessfulException */ public function runAnalyser( array $files, @@ -51,6 +53,7 @@ public function runAnalyser( ?string $tmpFile, ?string $insteadOfFile, InputInterface $input, + Output $errorOutput, ): AnalyserResult { $filesCount = count($files); @@ -93,10 +96,22 @@ public function runAnalyser( if ($result === null) { throw new ShouldNotHappenException(); } + // the parallel analysis is over and no more workers fork - the + // main thread runs the deferred bootstrapFiles now, before the + // phases that may reflect analysed code (stub validation, + // collector rules) + $this->bootstrapFilesRunner->run($errorOutput, $debug); + return $result; } } + // every path below analyses in-process - including the fall-throughs + // from the parallel branch above (no main script, zero-process + // schedule) - so the main thread runs the deferred bootstrapFiles + // first + $this->bootstrapFilesRunner->run($errorOutput, $debug); + return $this->analyser->analyse( $this->switchTmpFile($files, $insteadOfFile, $tmpFile), $preFileCallback, diff --git a/src/Command/BootstrapFilesRunner.php b/src/Command/BootstrapFilesRunner.php new file mode 100644 index 00000000000..a0df68c46ce --- /dev/null +++ b/src/Command/BootstrapFilesRunner.php @@ -0,0 +1,88 @@ +hasRun) { + return; + } + $this->hasRun = true; + + /** @var list|false $autoloadFunctionsBefore */ + $autoloadFunctionsBefore = spl_autoload_functions(); + + foreach ($this->container->getParameter('bootstrapFiles') as $bootstrapFile) { + CommandHelper::executeBootstrapFile($bootstrapFile, $this->container, $errorOutput, $debugEnabled); + } + + self::mergeNewAutoloadFunctions($autoloadFunctionsBefore); + } + + /** + * Merges autoloaders registered since $autoloadFunctionsBefore into the + * globals the BetterReflection source locators consult lazily (see + * autoloadFunctions.php) - late merging in a deferred or forked run is + * picked up by the next reflection ask. + * + * @param list|false $autoloadFunctionsBefore + */ + public static function mergeNewAutoloadFunctions(array|false $autoloadFunctionsBefore): void + { + /** @var list|false $autoloadFunctionsAfter */ + $autoloadFunctionsAfter = spl_autoload_functions(); + if ($autoloadFunctionsBefore === false || $autoloadFunctionsAfter === false) { + return; + } + + $collectedAutoloadFunctions = collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); + $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctions'] ?? [], + $collectedAutoloadFunctions['appended'], + ); + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], + $collectedAutoloadFunctions['prepended'], + ); + } + +} diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 30cd4561dd2..4039d257a5e 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -41,7 +41,6 @@ use function array_filter; use function array_key_exists; use function array_map; -use function array_merge; use function array_values; use function class_exists; use function count; @@ -57,7 +56,6 @@ use function is_file; use function is_readable; use function is_string; -use function PHPStan\collectNewAutoloadFunctions; use function register_shutdown_function; use function spl_autoload_functions; use function sprintf; @@ -96,6 +94,7 @@ public static function begin( ?string $singleReflectionFile, ?string $singleReflectionInsteadOfFile, bool $cleanupContainerCache, + bool $deferBootstrapFiles = false, ): InceptionResult { $stdOutput = new SymfonyOutput($output, new SymfonyStyle(new ErrorsConsoleStyle($input, $output))); @@ -520,23 +519,25 @@ public static function begin( $defaultLevelUsed = false; } - foreach ($container->getParameter('bootstrapFiles') as $bootstrapFileFromArray) { - self::executeBootstrapFile($bootstrapFileFromArray, $container, $errorOutput, $debugEnabled); - } - - /** @var list|false $autoloadFunctionsAfter */ - $autoloadFunctionsAfter = spl_autoload_functions(); - - if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { - $collectedAutoloadFunctions = collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); - $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( - $GLOBALS['__phpstanAutoloadFunctions'] ?? [], - $collectedAutoloadFunctions['appended'], - ); - $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( - $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], - $collectedAutoloadFunctions['prepended'], - ); + // merges autoloaders registered since the top of begin() - the + // --autoload-file require, anything container creation pulled in. This + // cannot wait for the bootstrapFiles run: when that run is deferred, + // the parent analyses long before it happens, and the source locators + // must see these autoloaders throughout. It also cannot fold into + // BootstrapFilesRunner::run() - by the time run() snapshots its own + // before-list these are already registered, so its diff would exclude + // them. run() brackets only the bootstrap files themselves, in + // whichever process executes them. + BootstrapFilesRunner::mergeNewAutoloadFunctions($autoloadFunctionsBefore); + + // the analyse flow defers bootstrapFiles to the exact places that need + // them: the main thread right before an in-process analysis, every + // worker (spawned or forked - WorkerRunner), and the main thread after + // the workers of a parallel analysis (AnalyserRunner) - resources the + // files open (database connections!) must not be inherited by forked + // children. Every other command runs them here. + if (!$deferBootstrapFiles) { + $container->getByType(BootstrapFilesRunner::class)->run($errorOutput, $debugEnabled); } if (PHP_VERSION_ID >= 80000) { @@ -627,7 +628,7 @@ public static function begin( /** * @throws InceptionNotSuccessfulException */ - private static function executeBootstrapFile( + public static function executeBootstrapFile( string $file, Container $container, Output $errorOutput, diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index c8be288469e..5199808d7b0 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -100,6 +100,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tmpFile, $insteadOfFile, false, + deferBootstrapFiles: true, ); } catch (InceptionNotSuccessfulException $e) { return 1; @@ -143,14 +144,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int // child can reuse it without re-booting (see ParallelAnalyser). $workerRunner = $container->getByType(WorkerRunner::class); - return $workerRunner->run( - $output, - $analysedFiles, - (int) $port, - $identifier, - $tmpFile, - $insteadOfFile, - ); + try { + return $workerRunner->run( + $output, + $analysedFiles, + (int) $port, + $identifier, + $tmpFile, + $insteadOfFile, + ); + } catch (InceptionNotSuccessfulException) { + // a deferred bootstrap file failed - its error is already printed + return 1; + } } } diff --git a/src/Parallel/ForkedProcess.php b/src/Parallel/ForkedProcess.php index 0d26422d29f..caf6d958108 100644 --- a/src/Parallel/ForkedProcess.php +++ b/src/Parallel/ForkedProcess.php @@ -2,6 +2,7 @@ namespace PHPStan\Parallel; +use PHPStan\Command\InceptionNotSuccessfulException; use PHPStan\ShouldNotHappenException; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; @@ -88,14 +89,20 @@ public function start(callable $onData, callable $onError, callable $onExit): vo // the worker on its own fresh event loop and never return. $this->server->close(); $output = new StreamOutput($this->stdOut); - $exitCode = $this->workerRunner->run( - $output, - $this->analysedFiles, - $this->serverPort, - $this->identifier, - $this->tmpFile, - $this->insteadOfFile, - ); + try { + $exitCode = $this->workerRunner->run( + $output, + $this->analysedFiles, + $this->serverPort, + $this->identifier, + $this->tmpFile, + $this->insteadOfFile, + ); + } catch (InceptionNotSuccessfulException) { + // a deferred bootstrap file failed - its error is already + // printed to the child's collected stdout + exit(1); + } exit($exitCode); } diff --git a/src/Parallel/WorkerRunner.php b/src/Parallel/WorkerRunner.php index c570454e7f9..ae14e2d4c56 100644 --- a/src/Parallel/WorkerRunner.php +++ b/src/Parallel/WorkerRunner.php @@ -8,6 +8,11 @@ use PHPStan\Analyser\InternalError; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Collectors\Registry as CollectorRegistry; +use PHPStan\Command\BootstrapFilesRunner; +use PHPStan\Command\ErrorsConsoleStyle; +use PHPStan\Command\InceptionNotSuccessfulException; +use PHPStan\Command\Symfony\SymfonyOutput; +use PHPStan\Command\Symfony\SymfonyStyle; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Rules\Registry as RuleRegistry; @@ -16,6 +21,7 @@ use React\Socket\TcpConnector; use React\Stream\ReadableStreamInterface; use React\Stream\WritableStreamInterface; +use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\OutputInterface; use Throwable; use function array_fill_keys; @@ -43,6 +49,7 @@ final class WorkerRunner { public function __construct( + private BootstrapFilesRunner $bootstrapFilesRunner, private FileAnalyser $fileAnalyser, private RuleRegistry $ruleRegistry, private CollectorRegistry $collectorRegistry, @@ -56,6 +63,7 @@ public function __construct( /** * @param string[] $analysedFiles the full analysed-files list (raw, before tmp-file substitution) * @return int exit code (0 on success, 1 if a worker-communication error occurred) + * @throws InceptionNotSuccessfulException */ public function run( OutputInterface $output, @@ -66,6 +74,16 @@ public function run( ?string $insteadOfFile, ): int { + // every worker - spawned or forked - runs the bootstrapFiles itself, + // so resources they open (database connections!) are per-worker. The + // one exception is a worker forked from a parent that already ran + // them (the fixer flow runs them eagerly): it inherits that execution + // and the once-per-process latch skips + $this->bootstrapFilesRunner->run( + new SymfonyOutput($output, new SymfonyStyle(new ErrorsConsoleStyle(new StringInput(''), $output))), + false, + ); + $analysedFiles = $this->switchTmpFile($analysedFiles, $insteadOfFile, $tmpFile); $this->nodeScopeResolver->setAnalysedFiles($analysedFiles); $analysedFiles = array_fill_keys($analysedFiles, true); diff --git a/tests/PHPStan/Command/AnalyseApplicationIntegrationTest.php b/tests/PHPStan/Command/AnalyseApplicationIntegrationTest.php index f7d7b086f39..41303eafbfa 100644 --- a/tests/PHPStan/Command/AnalyseApplicationIntegrationTest.php +++ b/tests/PHPStan/Command/AnalyseApplicationIntegrationTest.php @@ -78,19 +78,23 @@ private function runPath(string $path, int $expectedStatusCode): string null, CommandHelper::DEFAULT_LEVEL, ); - $analysisResult = $analyserApplication->analyse( - [$path], - true, - $symfonyOutput, - $symfonyOutput, - false, - true, - null, - null, - null, - null, - $this->createStub(InputInterface::class), - ); + try { + $analysisResult = $analyserApplication->analyse( + [$path], + true, + $symfonyOutput, + $symfonyOutput, + false, + true, + null, + null, + null, + null, + $this->createStub(InputInterface::class), + ); + } catch (InceptionNotSuccessfulException) { + $this->fail('Inception was not successful.'); + } $statusCode = $errorFormatter->formatErrors($analysisResult, $symfonyOutput); rewind($output->getStream());