From 92d6c80cab0977a7011059f600b1dbd409fd4cd5 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Tue, 3 Nov 2026 22:50:02 +0000 Subject: [PATCH 1/4] PHPStan Pro: a watched file that cannot be read is missing, not fatal The file monitor hashed every watched file and threw when one could not be read. A config file, baseline or bootstrap file missing for a moment, like during a git checkout, ended PHPStan Pro. Such a file is now missing: deleted while it is gone and new once it is back. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a31a-13cc-766a-8581-5c4eb5a12a01 --- src/File/FileMonitor.php | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/File/FileMonitor.php b/src/File/FileMonitor.php index 4e21aa2c11e..af2200f0463 100644 --- a/src/File/FileMonitor.php +++ b/src/File/FileMonitor.php @@ -13,6 +13,7 @@ use function hash_file; use function is_dir; use function is_file; +use function is_readable; #[AutowiredService] final class FileMonitor @@ -56,7 +57,12 @@ public function initialize(array $filePaths): void $finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths); $fileHashes = []; foreach (array_unique(array_merge($finderResult->getFiles(), $filePaths, $this->getScannedFiles($finderResult->getFiles()))) as $filePath) { - $fileHashes[$filePath] = $this->getFileHash($filePath); + $hash = $this->getFileHash($filePath); + if ($hash === null) { + continue; + } + + $fileHashes[$filePath] = $hash; } $this->fileHashes = $fileHashes; @@ -80,15 +86,21 @@ public function getChanges(): FileMonitorResult $deletedFiles = []; $filePaths = array_unique(array_merge($finderResult->getFiles(), $this->filePaths, $this->getScannedFiles($finderResult->getFiles()))); foreach ($filePaths as $filePath) { + // a file that cannot be read is missing, like a config file during a git checkout, + // it is deleted now and new when it is back + $newHash = $this->getFileHash($filePath); + if ($newHash === null) { + continue; + } + if (!array_key_exists($filePath, $oldFileHashes)) { $newFiles[] = $filePath; - $fileHashes[$filePath] = $this->getFileHash($filePath); + $fileHashes[$filePath] = $newHash; continue; } $oldHash = $oldFileHashes[$filePath]; unset($oldFileHashes[$filePath]); - $newHash = $this->getFileHash($filePath); $fileHashes[$filePath] = $newHash; if ($oldHash === $newHash) { continue; @@ -110,12 +122,15 @@ public function getChanges(): FileMonitorResult ); } - private function getFileHash(string $filePath): string + private function getFileHash(string $filePath): ?string { - $hash = hash_file('sha256', $filePath); + if (!is_file($filePath) || !is_readable($filePath)) { + return null; + } + $hash = @hash_file('sha256', $filePath); if ($hash === false) { - throw new CouldNotReadFileException($filePath); + return null; } return $hash; From af4f5dd477ec43b09010a2bbb36ff86ebe05f09a Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Tue, 3 Nov 2026 22:58:11 +0000 Subject: [PATCH 2/4] PHPStan Pro: watch the config files of the current config The file monitor watched the config files PHPStan Pro started with. A file included since then, like a newly generated baseline, was read by every analysis, but its changes were not noticed and nothing was analysed again. Each fixer:worker reads the config again and now tells FixerApplication its config files, which the file monitor watches from then on. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a31a-13cc-766a-8581-5c4eb5a12a01 --- src/Command/FixerApplication.php | 19 ++++++++++++++----- src/Command/FixerWorkerRunner.php | 14 ++++++++++++++ src/File/FileMonitor.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index a28f2e18837..8b8e408d1e8 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -63,6 +63,9 @@ final class FixerApplication private bool $fileMonitorActive = true; + /** @var string[] watched files besides the analysed ones and the config files */ + private array $watchedFilesWithoutConfig = []; + /** * @param string[] $analysedPaths * @param string[] $composerAutoloaderProjectPaths @@ -146,13 +149,13 @@ public function run( } }); - $this->fileMonitor->initialize(array_merge( + $this->watchedFilesWithoutConfig = array_merge( $this->getComposerLocks(), $this->getComposerInstalled(), $this->getExecutedFiles(), $this->getStubFiles(), - $this->allConfigFiles, - )); + ); + $this->fileMonitor->initialize(array_merge($this->watchedFilesWithoutConfig, $this->allConfigFiles)); $this->analyse( $loop, @@ -427,12 +430,18 @@ private function analyse( /** @var int<0, 65535> $serverPort */ $serverPort = parse_url($serverAddress, PHP_URL_PORT); - $server->on('connection', static function (ConnectionInterface $connection) use ($phpstanFixerEncoder): void { + $server->on('connection', function (ConnectionInterface $connection) use ($phpstanFixerEncoder): void { // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; // phpcs:enable $decoder = new Decoder($connection, true, options: $jsonInvalidUtf8Ignore, maxlength: 128 * 1024 * 1024); - $decoder->on('data', static function (array $data) use ($phpstanFixerEncoder): void { + $decoder->on('data', function (array $data) use ($phpstanFixerEncoder): void { + // the config files of this analysis, the config may include other files than at the start + if ($data['action'] === 'watchedConfigFiles') { + $this->fileMonitor->watchFiles(array_merge($this->watchedFilesWithoutConfig, $data['result']['files'])); + return; + } + $phpstanFixerEncoder->write($data); }); }); diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index f0205dda5da..7970683f94d 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\ResultCache\LazyCollectedData; use PHPStan\Analyser\ResultCache\ResultCacheManager; use PHPStan\Analyser\ResultCache\ResultCacheManagerFactory; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Parallel\ParallelAnalyser; use PHPStan\Parallel\Scheduler; @@ -50,7 +51,12 @@ final class FixerWorkerRunner { + /** + * @param string[] $allConfigFiles + */ public function __construct( + #[AutowiredParameter] + private array $allConfigFiles, private IgnoredErrorHelper $ignoredErrorHelper, private ResultCacheManagerFactory $resultCacheManagerFactory, private AnalyserResultFinalizer $analyserResultFinalizer, @@ -92,6 +98,14 @@ public function run( /** @var ResultCacheManager $resultCacheManager */ $resultCacheManager = $this->resultCacheManagerFactory->create([]); + // the config read for this analysis, files included since PHPStan Pro started are watched from now on + $out->write([ + 'action' => 'watchedConfigFiles', + 'result' => [ + 'files' => $this->allConfigFiles, + ], + ]); + $out->write([ 'action' => 'analysisStart', 'result' => [ diff --git a/src/File/FileMonitor.php b/src/File/FileMonitor.php index af2200f0463..ae9711ce8e2 100644 --- a/src/File/FileMonitor.php +++ b/src/File/FileMonitor.php @@ -69,6 +69,34 @@ public function initialize(array $filePaths): void $this->filePaths = $filePaths; } + /** + * Files to watch besides the analysed and scanned ones, from now on. + * + * @param array $filePaths + */ + public function watchFiles(array $filePaths): void + { + if ($this->fileHashes === null) { + throw new ShouldNotHappenException(); + } + + foreach ($filePaths as $filePath) { + if (array_key_exists($filePath, $this->fileHashes)) { + continue; + } + + // as it is now, not a change to report + $hash = $this->getFileHash($filePath); + if ($hash === null) { + continue; + } + + $this->fileHashes[$filePath] = $hash; + } + + $this->filePaths = $filePaths; + } + public function getChanges(): FileMonitorResult { if ($this->fileHashes === null || $this->filePaths === null) { From 26b5e274642edab62b46b2d3384632a7717457ff Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Tue, 3 Nov 2026 23:05:29 +0000 Subject: [PATCH 3/4] PHPStan Pro: report a failed analysis instead of waiting forever An exception in the fixer:worker, like a scanned file that cannot be read, rejected a promise nobody handled. The worker kept its connection open and waited forever, the UI showed an analysis in progress, and FixerApplication did not analyse any later change until PHPStan Pro was restarted. The worker now reports the exception as an internal error and ends the analysis. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a31a-13cc-766a-8581-5c4eb5a12a01 --- src/Command/FixerWorkerRunner.php | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index 7970683f94d..dcda84042c4 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -24,6 +24,7 @@ use React\Socket\ConnectionInterface; use React\Socket\TcpConnector; use Symfony\Component\Console\Input\InputInterface; +use Throwable; use function array_diff; use function array_key_exists; use function count; @@ -88,7 +89,8 @@ public function run( $loop = new StreamSelectLoop(); $tcpConnector = new TcpConnector($loop); - $tcpConnector->connect(sprintf('127.0.0.1:%d', $serverPort))->then(function (ConnectionInterface $connection) use ($errorOutput, $inceptionFiles, $isOnlyFiles, $projectConfigArray, $configuration, $input, $ignoredErrorHelperResult, $loop): void { + $out = null; + $tcpConnector->connect(sprintf('127.0.0.1:%d', $serverPort))->then(function (ConnectionInterface $connection) use ($errorOutput, $inceptionFiles, $isOnlyFiles, $projectConfigArray, $configuration, $input, $ignoredErrorHelperResult, $loop, &$out): PromiseInterface { // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; // phpcs:enable @@ -164,7 +166,7 @@ public function run( return $aTime <=> $bTime; }); - $this->runAnalyser( + return $this->runAnalyser( $loop, $filesToAnalyse, $inceptionFiles, @@ -284,6 +286,30 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ], ]); }); + })->catch(static function (Throwable $e) use (&$out, $loop): void { + // without an end the worker would wait forever and FixerApplication would not analyse again + if (!$out instanceof Encoder) { + // FixerApplication is gone + $loop->stop(); + return; + } + + $out->write(['action' => 'analysisCrash', 'data' => [ + 'internalErrors' => [new InternalError( + $e->getMessage(), + 'running PHPStan Pro worker', + InternalError::prepareTrace($e), + $e->getTraceAsString(), + shouldReportBug: false, + )], + ]]); + $out->end([ + 'action' => 'analysisEnd', + 'result' => [ + 'ignoreFileErrors' => [], + 'ignoreNotFileErrors' => [], + ], + ]); }); $loop->run(); From 45acb3c3c084b0b1027f59ae6d9c224de926f2ec Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Tue, 3 Nov 2026 23:07:28 +0000 Subject: [PATCH 4/4] PHPStan Pro: show what is wrong with ignoreErrors edited since the start FixerApplication validated the ignoreErrors of the config PHPStan Pro started with. For an invalid entry added later, the fixer:worker threw a bare ShouldNotHappenException and the UI showed "Analysis crashed" with "Internal error." The worker now reports the same messages the analyse command shows, like "Ignored error ... is missing a message or an identifier." Amp-Thread-ID: https://ampcode.com/threads/T-01a1a31a-13cc-766a-8581-5c4eb5a12a01 --- src/Command/FixerWorkerRunner.php | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index dcda84042c4..db57edb5156 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -17,7 +17,6 @@ use PHPStan\Parallel\ParallelAnalyser; use PHPStan\Parallel\Scheduler; use PHPStan\Process\CpuCoreCounter; -use PHPStan\ShouldNotHappenException; use React\EventLoop\LoopInterface; use React\EventLoop\StreamSelectLoop; use React\Promise\PromiseInterface; @@ -30,6 +29,7 @@ use function count; use function filemtime; use function filesize; +use function implode; use function in_array; use function is_file; use function memory_get_peak_usage; @@ -83,9 +83,6 @@ public function run( ): int { $ignoredErrorHelperResult = $this->ignoredErrorHelper->initialize(); - if (count($ignoredErrorHelperResult->getErrors()) > 0) { - throw new ShouldNotHappenException(); - } $loop = new StreamSelectLoop(); $tcpConnector = new TcpConnector($loop); @@ -95,6 +92,28 @@ public function run( $jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0; // phpcs:enable $out = new Encoder($connection, $jsonInvalidUtf8Ignore); + + // the config was edited since PHPStan Pro started, it reports what is wrong like the analyse command + if (count($ignoredErrorHelperResult->getErrors()) > 0) { + $out->write(['action' => 'analysisCrash', 'data' => [ + 'internalErrors' => [new InternalError( + implode("\n", $ignoredErrorHelperResult->getErrors()), + 'reading ignoreErrors in the configuration', + trace: [], + traceAsString: null, + shouldReportBug: false, + )], + ]]); + $out->end([ + 'action' => 'analysisEnd', + 'result' => [ + 'ignoreFileErrors' => [], + 'ignoreNotFileErrors' => [], + ], + ]); + + return resolve(null); + } //$in = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, 128 * 1024 * 1024); /** @var ResultCacheManager $resultCacheManager */