diff --git a/build/ffi-new-not-nullable.neon b/build/ffi-new-not-nullable.neon new file mode 100644 index 00000000000..69f6b991832 --- /dev/null +++ b/build/ffi-new-not-nullable.neon @@ -0,0 +1,8 @@ +parameters: + ignoreErrors: + - + # FFI::new() is typed nullable only in the PHP 8.0-8.3 stubs + message: '#^Strict comparison using \=\=\= between FFI\\CData and null will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: ../src/File/FsEventsFileMonitor.php diff --git a/build/ignore-by-php-version.neon.php b/build/ignore-by-php-version.neon.php index eb8ca1654f7..debc6ba56c9 100644 --- a/build/ignore-by-php-version.neon.php +++ b/build/ignore-by-php-version.neon.php @@ -37,6 +37,10 @@ $includes[] = __DIR__ . '/deprecated-8.4.neon'; } +if (PHP_VERSION_ID < 80000 || PHP_VERSION_ID >= 80400) { + $includes[] = __DIR__ . '/ffi-new-not-nullable.neon'; +} + if (PHP_VERSION_ID < 80200) { $includes[] = __DIR__ . '/old-phpunit.neon'; } else { diff --git a/build/phpstan.neon b/build/phpstan.neon index 8e14cc3d5c0..29eae01570c 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -82,6 +82,14 @@ parameters: - '#^Dynamic call to static method PHPUnit\\Framework\\\S+\(\)\.$#' - '#should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule::processNode\(\)$#' - '#Variable property access on PhpParser\\Node#' + - + # FFI's methods are the C functions declared in the cdef string at + # runtime, so no reflection can know them + identifier: method.notFound + message: '#^Call to an undefined method FFI::#' + paths: + - ../src/File/FsEventsFileMonitor.php + - ../src/File/InotifyFileMonitor.php - identifier: shipmonk.deadMethod message: '#^Unused .*?::__construct#' # likely used in DIC diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index a99f2d15db2..1e8215dd56a 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -48,6 +48,7 @@ use function array_unique; use function array_values; use function class_exists; +use function clearstatcache; use function count; use function error_get_last; use function explode; @@ -120,8 +121,9 @@ final class ResultCacheManager private const MISSING_FILE_HASH = ''; /** - * Prefixes the recorded hash of an analysed file with a parse error, whose result is not cached. - * The prefixed hash matches no real hash, so the file is re-analysed on every run. + * Prefixes the recorded hash of an analysed file with a parse error, whose result is not cached, + * or of one that changed during the analysis, or uses one that did. The prefixed hash matches no + * real hash, so the file is re-analysed by the next run. */ private const REANALYSE_FILE_HASH_PREFIX = 'reanalyse:'; @@ -173,6 +175,22 @@ final class ResultCacheManager */ private array $recordedFileStats = []; + /** + * The stat of the analysed files when they were hashed, to tell at save time which of them + * changed while they were analysed - see getFilesChangedSinceHashed(). Kept as a string rather + * than what stat() returned: it lives through the whole analysis. + * + * @var array + */ + private array $hashedFileStats = []; + + /** + * The analysed files modified in the second they were hashed in or later. + * + * @var array + */ + private array $racilyHashedFiles = []; + private ?ResultCachePathTransformer $pathTransformer = null; /** @var array */ @@ -1378,6 +1396,37 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache unset($errorsByFile[$file]); } + // The files changed during the analysis may have been read in another version than the one + // their hash and exported nodes describe, and so may the files using them, which are analysed + // again too. A run that analysed nothing read nothing. + $filesChangedDuringAnalysis = []; + if ($resultCache->isFullAnalysis() || $resultCache->getFilesToAnalyse() !== []) { + $filesChangedDuringAnalysis = $this->getFilesChangedSinceHashed($currentFileHashes); + } + foreach (array_keys($filesChangedDuringAnalysis) as $file) { + if ($output->isVeryVerbose()) { + $output->writeLineFormatted(sprintf('Result cache will re-analyse %s and the files using it because it changed during the analysis.', $file)); + } + + $filesToReanalyse[$file] = true; + } + if ($filesChangedDuringAnalysis !== []) { + foreach ([$dependencies, $usedTraitDependencies] as $fileDependencies) { + foreach ($fileDependencies as $file => $filesDependedOn) { + if (!array_key_exists($file, $currentFileHashes)) { + continue; + } + + foreach ($filesDependedOn as $fileDependedOn) { + if (array_key_exists($fileDependedOn, $filesChangedDuringAnalysis)) { + $filesToReanalyse[$file] = true; + break; + } + } + } + } + } + // in the order save() writes them, which is the order they are restored in $collectedDataRuleResults = $finalizerResult->getCollectedDataRuleResults(); ksort($collectedDataRuleResults); @@ -1392,6 +1441,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache $stubFiles = $this->getStubFiles(); if ( !$resultCache->isFullAnalysis() + && $filesChangedDuringAnalysis === [] && array_diff($resultCache->getFilesToAnalyse(), array_keys($filesToReanalyse)) === [] && $this->restoredCacheUnchanged && $errorsByFile === $resultCache->getErrors() @@ -3143,6 +3193,17 @@ private function statAnalysedFiles(array $allAnalysedFiles): array */ private function hashAnalysedFiles(array $analysedFileStats, ?array $cachedDependencies): array { + $hashedAt = time(); + $this->hashedFileStats = []; + $this->racilyHashedFiles = []; + foreach ($analysedFileStats as $file => $stat) { + $this->hashedFileStats[$file] = self::getStatKey($stat); + if ($stat['mtime'] < $hashedAt && $stat['ctime'] < $hashedAt) { + continue; + } + + $this->racilyHashedFiles[$file] = true; + } $signatures = $this->fileStatSignatures->begin(); $hashes = []; foreach ($analysedFileStats as $file => $stat) { @@ -3197,6 +3258,56 @@ private function fileStatSignaturesDiffer(array $cachedDependencies): bool return false; } + /** + * The analysed files written to after they were hashed. The analysis may have read them before + * or after the write, or the files depending on them may have read the other version, so neither + * the hash recorded for them nor their exported nodes describe what was analysed. That includes + * a file changed and changed back, whose hash is the recorded one again. + * + * A file still written to in the second it was hashed in can keep its stat, its hash tells + * whether it is the same - unless it got its hashed contents back within that second as well. + * + * @param array $currentFileHashes + * @return array + */ + private function getFilesChangedSinceHashed(array $currentFileHashes): array + { + clearstatcache(); + $changedFiles = []; + foreach ($this->hashedFileStats as $file => $hashedStat) { + $stat = @stat($file); + if ($stat === false) { + // the next run finds it gone and analyses the files that depended on it + continue; + } + + if (self::getStatKey($stat) !== $hashedStat) { + $changedFiles[$file] = true; + continue; + } + + if (!array_key_exists($file, $this->racilyHashedFiles)) { + continue; + } + + if (!array_key_exists($file, $currentFileHashes) || hash_file('sha256', $file) === $currentFileHashes[$file]) { + continue; + } + + $changedFiles[$file] = true; + } + + return $changedFiles; + } + + /** + * @param array $stat what stat() returned for the file + */ + private static function getStatKey(array $stat): string + { + return sprintf('%d:%d:%d:%d:%d', $stat['size'], $stat['mtime'], $stat['ctime'], $stat['ino'], $stat['dev']); + } + private function getDependencyFileHash(string $path): string { if (!is_file($path)) { diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index 8b8e408d1e8..001a3d95a53 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -15,6 +15,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\File\FileMonitor; +use PHPStan\File\FileMonitorFactory; use PHPStan\File\FileMonitorResult; use PHPStan\File\FileReader; use PHPStan\File\FileWriter; @@ -23,7 +24,6 @@ use PHPStan\Internal\DirectoryCreatorException; use PHPStan\Internal\HttpClientFactory; use PHPStan\PhpDoc\StubFilesProvider; -use PHPStan\Process\ProcessCanceledException; use PHPStan\Process\ProcessCrashedException; use PHPStan\Process\ProcessHelper; use PHPStan\Process\SpawnedProcessPromise; @@ -46,6 +46,8 @@ use function http_build_query; use function ini_get; use function is_file; +use function microtime; +use function min; use function parse_url; use function sprintf; use function unlink; @@ -58,9 +60,21 @@ final class FixerApplication { + /** + * A burst of changes, like a git checkout or a coding standard fixer at work, + * is analysed once it is over, not as soon as its first file changes. + */ + private const CHANGES_QUIET_SECONDS = 0.5; + + /** Changes that never stop for that long are analysed this long after the first one anyway. */ + private const CHANGES_WAIT_SECONDS_LIMIT = 2.0; + /** @var PromiseInterface|null */ private PromiseInterface|null $processInProgress = null; + /** Files changed during the analysis in progress, another one starts once it ends. */ + private bool $analysisRequested = false; + private bool $fileMonitorActive = true; /** @var string[] watched files besides the analysed ones and the config files */ @@ -73,7 +87,7 @@ final class FixerApplication * @param string[] $bootstrapFiles */ public function __construct( - private FileMonitor $fileMonitor, + private FileMonitorFactory $fileMonitorFactory, private StubFilesProvider $stubFilesProvider, #[AutowiredParameter] private array $analysedPaths, @@ -98,6 +112,8 @@ public function __construct( { } + private ?FileMonitor $fileMonitor = null; + public function run( InceptionResult $inceptionResult, InputInterface $input, @@ -155,7 +171,7 @@ public function run( $this->getExecutedFiles(), $this->getStubFiles(), ); - $this->fileMonitor->initialize(array_merge($this->watchedFilesWithoutConfig, $this->allConfigFiles)); + $this->fileMonitor = $this->fileMonitorFactory->create(array_merge($this->watchedFilesWithoutConfig, $this->allConfigFiles)); $this->analyse( $loop, @@ -165,16 +181,20 @@ public function run( $encoder, ); - $this->monitorFileChanges($loop, function (FileMonitorResult $changes) use ($loop, $mainScript, $projectConfigFile, $input, $encoder): void { - if ($this->processInProgress !== null) { - $this->processInProgress->cancel(); - $this->processInProgress = null; + $this->monitorFileChanges($loop, static function (FileMonitorResult $changes) use ($encoder): void { + if (count($changes->getChangedFiles()) === 0) { + return; } - if (count($changes->getChangedFiles()) > 0) { - $encoder->write(['action' => 'changedFiles', 'data' => [ - 'paths' => $changes->getChangedFiles(), - ]]); + $encoder->write(['action' => 'changedFiles', 'data' => [ + 'paths' => $changes->getChangedFiles(), + ]]); + }, function () use ($loop, $mainScript, $projectConfigFile, $input, $encoder): void { + // The analysis in progress is not stopped: what it saves to the result cache makes the next + // one cheaper, and that one starts once it ends, for everything that changed meanwhile. + if ($this->processInProgress !== null) { + $this->analysisRequested = true; + return; } $this->analyse( @@ -391,28 +411,49 @@ private function writeInfoFile(string $infoPath, string $version, string $branch } /** - * @param callable(FileMonitorResult): void $hasChangesCallback + * @param callable(FileMonitorResult): void $hasChangesCallback called for every poll with changes, the code shown can follow them right away + * @param callable(): void $changesSettledCallback called once the changes stop, to analyse them */ - private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback): void + private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback, callable $changesSettledCallback): void { - $callback = function () use (&$callback, $loop, $hasChangesCallback): void { + if ($this->fileMonitor === null) { + throw new ShouldNotHappenException(); + } + $fileMonitor = $this->fileMonitor; + $interval = $fileMonitor->getPollInterval(); + $firstChangeAt = null; + $lastChangeAt = null; + $callback = function () use (&$callback, &$firstChangeAt, &$lastChangeAt, $loop, $hasChangesCallback, $changesSettledCallback, $fileMonitor, $interval): void { if (!$this->fileMonitorActive) { - $loop->addTimer(1.0, $callback); + $loop->addTimer($interval, $callback); return; } - if ($this->processInProgress !== null) { - $loop->addTimer(1.0, $callback); - return; - } - $changes = $this->fileMonitor->getChanges(); + $changes = $fileMonitor->getChanges(); + $now = microtime(true); if ($changes->hasAnyChanges()) { + $firstChangeAt ??= $now; + $lastChangeAt = $now; $hasChangesCallback($changes); } - $loop->addTimer(1.0, $callback); + if ($firstChangeAt === null || $lastChangeAt === null) { + $loop->addTimer($interval, $callback); + return; + } + + if ($now - $lastChangeAt >= self::CHANGES_QUIET_SECONDS || $now - $firstChangeAt >= self::CHANGES_WAIT_SECONDS_LIMIT) { + $firstChangeAt = null; + $lastChangeAt = null; + $changesSettledCallback(); + $loop->addTimer($interval, $callback); + return; + } + + // the monitor may poll less often than this, the end of the changes is noticed by the next poll + $loop->addTimer(min($interval, self::CHANGES_QUIET_SECONDS), $callback); }; - $loop->addTimer(1.0, $callback); + $loop->addTimer($interval, $callback); } private function analyse( @@ -438,6 +479,9 @@ private function analyse( $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') { + if ($this->fileMonitor === null) { + throw new ShouldNotHappenException(); + } $this->fileMonitor->watchFiles(array_merge($this->watchedFilesWithoutConfig, $data['result']['files'])); return; } @@ -462,17 +506,11 @@ private function analyse( )); $this->processInProgress = $process->run(); - $this->processInProgress->then(function () use ($server): void { - $this->processInProgress = null; + $this->processInProgress->then(static function () use ($server): void { $server->close(); - }, function (Throwable $e) use ($server, $phpstanFixerEncoder): void { - $this->processInProgress = null; + }, static function (Throwable $e) use ($server, $phpstanFixerEncoder): void { $server->close(); - if ($e instanceof ProcessCanceledException) { - return; - } - if ($e instanceof ProcessCrashedException) { $message = 'Analysis crashed'; $traceAsString = $e->getMessage(); @@ -491,6 +529,14 @@ private function analyse( shouldReportBug: false, )], ]]); + })->finally(function () use ($loop, $mainScript, $projectConfigFile, $input, $phpstanFixerEncoder): void { + $this->processInProgress = null; + if (!$this->analysisRequested) { + return; + } + + $this->analysisRequested = false; + $this->analyse($loop, $mainScript, $projectConfigFile, $input, $phpstanFixerEncoder); }); } diff --git a/src/Command/FixerWorkerRunner.php b/src/Command/FixerWorkerRunner.php index db57edb5156..7ae27b061f1 100644 --- a/src/Command/FixerWorkerRunner.php +++ b/src/Command/FixerWorkerRunner.php @@ -194,7 +194,7 @@ public function run( function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ($out, $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles): void { $internalErrors = []; foreach ($errors as $fileSpecificError) { - if (!$fileSpecificError->hasNonIgnorableException()) { + if (!$this->isInternalError($fileSpecificError)) { continue; } @@ -243,15 +243,23 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ); } + $hasParseErrors = false; foreach ($finalizerResult->getAnalyserResult()->getUnorderedErrors() as $fileSpecificError) { if (!$fileSpecificError->hasNonIgnorableException()) { continue; } + if (!$this->isInternalError($fileSpecificError)) { + $hasParseErrors = true; + continue; + } $internalErrors[] = $this->transformErrorIntoInternalError($fileSpecificError); } $hasInternalErrors = count($internalErrors) > 0 || $finalizerResult->getAnalyserResult()->hasReachedInternalErrorsCountLimit(); + // what is in a file that cannot be parsed is not known, so the ignores that did not match + // anything there are not reported, like when the analysis is incomplete for other reasons + $isAnalysisIncomplete = $hasInternalErrors || $hasParseErrors; if ($hasInternalErrors) { $out->write(['action' => 'analysisCrash', 'data' => [ @@ -267,7 +275,7 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use ]]); } - [$collectorErrors, $ignoredCollectorErrors] = $this->filterErrors($finalizerResult->getCollectorErrors(), $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles, $hasInternalErrors); + [$collectorErrors, $ignoredCollectorErrors] = $this->filterErrors($finalizerResult->getCollectorErrors(), $ignoredErrorHelperResult, $isOnlyFiles, $inceptionFiles, $isAnalysisIncomplete); foreach ($finalizerResult->getLocallyIgnoredCollectorErrors() as $locallyIgnoredCollectorError) { $ignoredCollectorErrors[] = [$locallyIgnoredCollectorError, null]; } @@ -284,7 +292,7 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use $finalizerResult->getErrors(), $isOnlyFiles, $inceptionFiles, - $hasInternalErrors, + $isAnalysisIncomplete, ); $ignoreFileErrors = []; foreach ($ignoredErrorHelperProcessedResult->getNotIgnoredErrors() as $error) { @@ -335,6 +343,15 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use return 0; } + /** + * An error with an exception that cannot be ignored, other than a parse error. A parse error + * describes the analysed code, so it is shown with the other errors, on its line. + */ + private function isInternalError(Error $error): bool + { + return $error->hasNonIgnorableException() && $error->getIdentifier() !== 'phpstan.parse'; + } + private function transformErrorIntoInternalError(Error $error): InternalError { $message = $error->getMessage(); diff --git a/src/File/DirectoryWalker.php b/src/File/DirectoryWalker.php index 6808646295b..908f6bd0891 100644 --- a/src/File/DirectoryWalker.php +++ b/src/File/DirectoryWalker.php @@ -42,7 +42,7 @@ * walk between the two - both happen before the analysis starts, on a file set that is snapshotted * for the whole run anyway. * - * FileMonitor detects changes by re-running the finder and must see the filesystem as it is now, + * HashingFileMonitor detects changes by re-running the finder and must see the filesystem as it is now, * so it walks uncached and clears the shared walks before each check. * * Reading the directories is what a walk costs - on a tree the size of Drupal core (28k directories) diff --git a/src/File/FileMonitor.php b/src/File/FileMonitor.php index ae9711ce8e2..bbcf2f9dcd4 100644 --- a/src/File/FileMonitor.php +++ b/src/File/FileMonitor.php @@ -2,194 +2,35 @@ namespace PHPStan\File; -use PHPStan\DependencyInjection\AutowiredParameter; -use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\ShouldNotHappenException; -use function array_diff; -use function array_key_exists; -use function array_keys; -use function array_merge; -use function array_unique; -use function hash_file; -use function is_dir; -use function is_file; -use function is_readable; - -#[AutowiredService] -final class FileMonitor +/** + * Watches the analysed and scanned files for changes between PHPStan Pro analyses. + * + * {@see HashingFileMonitor} is the portable implementation: it re-hashes every + * monitored file on every poll. The native implementations + * ({@see FsEventsFileMonitor}, {@see InotifyFileMonitor}) wrap it and let the + * kernel answer "did anything change at all", so an idle poll touches no files; + * once the kernel says yes, they delegate to the hashing monitor so the reported + * result is identical either way. + * + * {@see FileMonitorFactory} picks the implementation for the current platform + * and hands it back initialized. + */ +interface FileMonitor { - /** @var array|null */ - private ?array $fileHashes = null; - - /** @var array|null */ - private ?array $filePaths = null; - - /** - * @param string[] $analysedPaths - * @param string[] $analysedPathsFromConfig - * @param string[] $scanFiles - * @param string[] $scanDirectories - */ - public function __construct( - #[AutowiredParameter(ref: '@fileFinderAnalyse')] - private FileFinder $analyseFileFinder, - #[AutowiredParameter(ref: '@fileFinderScan')] - private FileFinder $scanFileFinder, - #[AutowiredParameter] - private array $analysedPaths, - #[AutowiredParameter] - private array $analysedPathsFromConfig, - #[AutowiredParameter] - private array $scanFiles, - #[AutowiredParameter] - private array $scanDirectories, - private DirectoryWalker $directoryWalker, - ) - { - } - - /** - * @param array $filePaths - */ - 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) { - $hash = $this->getFileHash($filePath); - if ($hash === null) { - continue; - } - - $fileHashes[$filePath] = $hash; - } - - $this->fileHashes = $fileHashes; - $this->filePaths = $filePaths; - } + public function getChanges(): FileMonitorResult; /** * 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) { - throw new ShouldNotHappenException(); - } - - // Detecting a change means looking at the filesystem as it is now, so the walks shared - // with the analysis file discovery must not be reused here. - $this->directoryWalker->clearCachedWalks(); - $finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths); - $oldFileHashes = $this->fileHashes; - $fileHashes = []; - $newFiles = []; - $changedFiles = []; - $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] = $newHash; - continue; - } - - $oldHash = $oldFileHashes[$filePath]; - unset($oldFileHashes[$filePath]); - $fileHashes[$filePath] = $newHash; - if ($oldHash === $newHash) { - continue; - } - - $changedFiles[] = $filePath; - } - - $this->fileHashes = $fileHashes; - - foreach (array_keys($oldFileHashes) as $file) { - $deletedFiles[] = $file; - } - - return new FileMonitorResult( - $newFiles, - $changedFiles, - $deletedFiles, - ); - } - - private function getFileHash(string $filePath): ?string - { - if (!is_file($filePath) || !is_readable($filePath)) { - return null; - } - - $hash = @hash_file('sha256', $filePath); - if ($hash === false) { - return null; - } - - return $hash; - } + public function watchFiles(array $filePaths): void; /** - * @param string[] $allAnalysedFiles - * @return array + * How often the caller should poll. A monitor whose idle poll is free can + * afford a much shorter interval, which is what makes an edit noticed sooner. */ - private function getScannedFiles(array $allAnalysedFiles): array - { - $scannedFiles = $this->scanFiles; - $analysedDirectories = []; - foreach (array_merge($this->analysedPaths, $this->analysedPathsFromConfig) as $analysedPath) { - if (is_file($analysedPath)) { - continue; - } - - if (!is_dir($analysedPath)) { - continue; - } - - $analysedDirectories[] = $analysedPath; - } - - $directories = array_unique(array_merge($analysedDirectories, $this->scanDirectories)); - foreach ($this->scanFileFinder->findFiles($directories)->getFiles() as $file) { - $scannedFiles[] = $file; - } - - return array_diff($scannedFiles, $allAnalysedFiles); - } + public function getPollInterval(): float; } diff --git a/src/File/FileMonitorFactory.php b/src/File/FileMonitorFactory.php new file mode 100644 index 00000000000..c4daca2950c --- /dev/null +++ b/src/File/FileMonitorFactory.php @@ -0,0 +1,94 @@ + $filePaths extra files to monitor besides the analysed and scanned ones + */ + public function create(array $filePaths): FileMonitor + { + $native = $this->createNative(); + if ($native !== null) { + try { + $native->initialize($filePaths); + + return $native; + } catch (FileMonitorNotSupportedException) { + // fall through to hashing + } + } + + $this->hashingFileMonitor->initialize($filePaths); + + return $this->hashingFileMonitor; + } + + private function createNative(): ?NativeFileMonitor + { + // escape hatch: the native monitors depend on FFI and on kernel + // facilities a container or a hardened host can take away in ways they + // cannot detect + if (getenv(self::DISABLE_ENV_VARIABLE) !== false) { + return null; + } + + if (PHP_OS_FAMILY === 'Darwin') { + return new FsEventsFileMonitor( + $this->hashingFileMonitor, + $this->analysedPaths, + $this->analysedPathsFromConfig, + $this->scanDirectories, + ); + } + + if (PHP_OS_FAMILY === 'Linux') { + return new InotifyFileMonitor( + $this->hashingFileMonitor, + $this->analysedPaths, + $this->analysedPathsFromConfig, + $this->scanDirectories, + ); + } + + return null; + } + +} diff --git a/src/File/FileMonitorNotSupportedException.php b/src/File/FileMonitorNotSupportedException.php new file mode 100644 index 00000000000..737bdf3067e --- /dev/null +++ b/src/File/FileMonitorNotSupportedException.php @@ -0,0 +1,15 @@ +newFiles; + } + /** * @return string[] */ @@ -28,6 +36,14 @@ public function getChangedFiles(): array return $this->changedFiles; } + /** + * @return string[] + */ + public function getDeletedFiles(): array + { + return $this->deletedFiles; + } + public function hasAnyChanges(): bool { return count($this->newFiles) > 0 diff --git a/src/File/FsEventsFileMonitor.php b/src/File/FsEventsFileMonitor.php new file mode 100644 index 00000000000..0799c0c353b --- /dev/null +++ b/src/File/FsEventsFileMonitor.php @@ -0,0 +1,202 @@ + root => stream + */ + private array $streams = []; + + private bool $changed = false; + + /** + * Kept referenced: an FFI callback must outlive every call into it. + * + * @var (Closure(mixed, mixed, mixed, mixed, mixed, mixed): void)|null + */ + private ?Closure $callback = null; + + protected function watchesRecursively(): bool + { + return true; + } + + protected function open(): void + { + if (!extension_loaded('ffi') || !class_exists(FFI::class)) { + throw new FileMonitorNotSupportedException(); + } + + try { + $ffi = FFI::cdef(self::CDEF, self::FRAMEWORK); + } catch (Throwable) { + throw new FileMonitorNotSupportedException(); + } + + $this->ffi = $ffi; + $this->runLoopMode = $ffi->CFStringCreateWithCString(null, 'kCFRunLoopDefaultMode', self::KCF_STRING_ENCODING_UTF8); + $this->callback = function ($stream, $info, $numEvents, $eventPaths, $eventFlags, $eventIds): void { + $this->changed = true; + }; + } + + protected function addWatch(string $directory): bool + { + // arm() asks again on every change, a root keeps the stream it got first + if (isset($this->streams[$directory])) { + return false; + } + + // A stream's path list is fixed once created, and watchesRecursively() + // keeps the roots down to a handful, so one stream per root is cheaper + // than rebuilding a combined stream whenever a root appears. + $this->startStream($directory); + + return true; + } + + protected function drainEvents(): bool + { + if ($this->ffi === null) { + throw new ShouldNotHappenException(); + } + + // Runs whatever the run loop already has ready and returns immediately; + // the callback flips $changed while it does. + $this->ffi->CFRunLoopRunInMode($this->runLoopMode, self::LATENCY_SECONDS, 0); + + $changed = $this->changed; + $this->changed = false; + + return $changed; + } + + public function __destruct() + { + if ($this->ffi === null) { + return; + } + + foreach ($this->streams as [$stream]) { + $this->ffi->FSEventStreamStop($stream); + $this->ffi->FSEventStreamInvalidate($stream); + $this->ffi->FSEventStreamRelease($stream); + } + + $this->streams = []; + } + + /** + * @throws FileMonitorNotSupportedException + */ + private function startStream(string $directory): void + { + if ($this->ffi === null) { + throw new ShouldNotHappenException(); + } + + $ffi = $this->ffi; + $paths = $ffi->new('void*[1]'); + if ($paths === null) { + throw new FileMonitorNotSupportedException(); + } + $cfString = $ffi->CFStringCreateWithCString(null, $directory, self::KCF_STRING_ENCODING_UTF8); + if ($cfString === null) { + throw new FileMonitorNotSupportedException(); + } + + $paths[0] = $cfString; + $array = $ffi->CFArrayCreate(null, $ffi->cast('const void**', FFI::addr($paths)), 1, null); + if ($array === null) { + throw new FileMonitorNotSupportedException(); + } + + $stream = $ffi->FSEventStreamCreate( + null, + $this->callback, + null, + $array, + self::SINCE_NOW, + self::LATENCY_SECONDS, + self::CREATE_FLAGS, + ); + if ($stream === null) { + throw new FileMonitorNotSupportedException(); + } + + $ffi->FSEventStreamScheduleWithRunLoop($stream, $ffi->CFRunLoopGetCurrent(), $this->runLoopMode); + if ($ffi->FSEventStreamStart($stream) === 0) { + throw new FileMonitorNotSupportedException(); + } + + // CFArrayCreate was given no retain callbacks, so the strings (and the + // array) are only alive as long as this holds on to them. + $this->streams[$directory] = [$stream, $array, $paths, $cfString]; + } + +} diff --git a/src/File/HashingFileMonitor.php b/src/File/HashingFileMonitor.php new file mode 100644 index 00000000000..c0429d3cbb6 --- /dev/null +++ b/src/File/HashingFileMonitor.php @@ -0,0 +1,217 @@ +|null */ + private ?array $fileHashes = null; + + /** @var array|null */ + private ?array $filePaths = null; + + /** + * @param string[] $analysedPaths + * @param string[] $analysedPathsFromConfig + * @param string[] $scanFiles + * @param string[] $scanDirectories + */ + public function __construct( + #[AutowiredParameter(ref: '@fileFinderAnalyse')] + private FileFinder $analyseFileFinder, + #[AutowiredParameter(ref: '@fileFinderScan')] + private FileFinder $scanFileFinder, + #[AutowiredParameter] + private array $analysedPaths, + #[AutowiredParameter] + private array $analysedPathsFromConfig, + #[AutowiredParameter] + private array $scanFiles, + #[AutowiredParameter] + private array $scanDirectories, + private DirectoryWalker $directoryWalker, + ) + { + } + + /** + * @param array $filePaths + */ + 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) { + $hash = $this->getFileHash($filePath); + if ($hash === null) { + continue; + } + + $fileHashes[$filePath] = $hash; + } + + $this->fileHashes = $fileHashes; + $this->filePaths = $filePaths; + } + + /** + * @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 getPollInterval(): float + { + return self::POLL_INTERVAL_SECONDS; + } + + /** + * The paths this monitor watches, for a monitor wrapping this one. Includes + * the extra files that are missing right now, their appearing is a change too. + * + * @return array + */ + public function getMonitoredFiles(): array + { + if ($this->fileHashes === null || $this->filePaths === null) { + throw new ShouldNotHappenException(); + } + + return array_values(array_unique(array_merge(array_keys($this->fileHashes), $this->filePaths))); + } + + public function getChanges(): FileMonitorResult + { + if ($this->fileHashes === null || $this->filePaths === null) { + throw new ShouldNotHappenException(); + } + + // Detecting a change means looking at the filesystem as it is now, so the walks shared + // with the analysis file discovery must not be reused here. + $this->directoryWalker->clearCachedWalks(); + $finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths); + $oldFileHashes = $this->fileHashes; + $fileHashes = []; + $newFiles = []; + $changedFiles = []; + $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] = $newHash; + continue; + } + + $oldHash = $oldFileHashes[$filePath]; + unset($oldFileHashes[$filePath]); + $fileHashes[$filePath] = $newHash; + if ($oldHash === $newHash) { + continue; + } + + $changedFiles[] = $filePath; + } + + $this->fileHashes = $fileHashes; + + foreach (array_keys($oldFileHashes) as $file) { + $deletedFiles[] = $file; + } + + return new FileMonitorResult( + $newFiles, + $changedFiles, + $deletedFiles, + ); + } + + private function getFileHash(string $filePath): ?string + { + if (!is_file($filePath) || !is_readable($filePath)) { + return null; + } + + $hash = @hash_file('sha256', $filePath); + if ($hash === false) { + return null; + } + + return $hash; + } + + /** + * @param string[] $allAnalysedFiles + * @return array + */ + private function getScannedFiles(array $allAnalysedFiles): array + { + $scannedFiles = $this->scanFiles; + $analysedDirectories = []; + foreach (array_merge($this->analysedPaths, $this->analysedPathsFromConfig) as $analysedPath) { + if (is_file($analysedPath)) { + continue; + } + + if (!is_dir($analysedPath)) { + continue; + } + + $analysedDirectories[] = $analysedPath; + } + + $directories = array_unique(array_merge($analysedDirectories, $this->scanDirectories)); + foreach ($this->scanFileFinder->findFiles($directories)->getFiles() as $file) { + $scannedFiles[] = $file; + } + + return array_diff($scannedFiles, $allAnalysedFiles); + } + +} diff --git a/src/File/InotifyFileMonitor.php b/src/File/InotifyFileMonitor.php new file mode 100644 index 00000000000..c82bc4b81b0 --- /dev/null +++ b/src/File/InotifyFileMonitor.php @@ -0,0 +1,124 @@ + + */ + private array $watchDescriptors = []; + + protected function watchesRecursively(): bool + { + return false; + } + + protected function open(): void + { + if (!extension_loaded('ffi') || !class_exists(FFI::class)) { + throw new FileMonitorNotSupportedException(); + } + + try { + $ffi = FFI::cdef(self::CDEF); + $fd = $ffi->inotify_init1(self::IN_NONBLOCK); + } catch (Throwable) { + throw new FileMonitorNotSupportedException(); + } + + if ($fd < 0) { + throw new FileMonitorNotSupportedException(); + } + + $this->ffi = $ffi; + $this->fd = $fd; + $this->buffer = $ffi->new('char[' . self::READ_BUFFER_BYTES . ']'); + } + + protected function addWatch(string $directory): bool + { + if ($this->ffi === null) { + throw new ShouldNotHappenException(); + } + + // -1 means the per-user watch limit is exhausted (or the directory went + // away between listing and arming) - either way this monitor cannot + // promise to see every change, so it must not be used at all. + $watchDescriptor = $this->ffi->inotify_add_watch($this->fd, $directory, self::WATCH_MASK); + if ($watchDescriptor < 0) { + throw new FileMonitorNotSupportedException(); + } + + if (isset($this->watchDescriptors[$watchDescriptor])) { + return false; + } + + $this->watchDescriptors[$watchDescriptor] = true; + + return true; + } + + protected function drainEvents(): bool + { + if ($this->ffi === null || $this->buffer === null) { + throw new ShouldNotHappenException(); + } + + $changed = false; + while ($this->ffi->read($this->fd, $this->buffer, self::READ_BUFFER_BYTES) > 0) { + $changed = true; + } + + return $changed; + } + +} diff --git a/src/File/NativeFileMonitor.php b/src/File/NativeFileMonitor.php new file mode 100644 index 00000000000..dc29ff4e727 --- /dev/null +++ b/src/File/NativeFileMonitor.php @@ -0,0 +1,450 @@ + */ + private array $watchedDirectories = []; + + /** + * Monitored files that lie outside the watched roots - composer.lock, the + * config files, the phar PHPStan runs from. There are a few dozen of them + * and they are scattered, so they are polled by stat() rather than pulling + * their whole parent directory into a recursive watch: the project root is + * a common parent, and watching it would make every result cache write look + * like a source change. + * + * stat() is enough because this only has to open the gate - the wrapped + * hashing monitor still decides whether the content actually differs. + * + * @var array|null path => [mtime, size, second the hashing pass that saw it started in] + */ + private ?array $unwatchedStats = null; + + /** + * False once the watches no longer cover everything - the project outgrew + * the watch budget mid-session. The hashing monitor then runs at its own + * pace whatever the kernel says. + */ + private bool $watchesCoverEverything = true; + + private float $lastHashedAt = 0.0; + + /** + * A directory gets its watch after the hashing pass that found it, so a + * file written into it in between has no event. The next poll hashes again. + */ + private bool $rescanPending = false; + + /** + * @param string[] $analysedPaths + * @param string[] $analysedPathsFromConfig + * @param string[] $scanDirectories + */ + public function __construct( + private HashingFileMonitor $hashingFileMonitor, + private array $analysedPaths, + private array $analysedPathsFromConfig, + private array $scanDirectories, + ) + { + } + + /** + * @param array $filePaths extra files to monitor besides the analysed and scanned ones + * @throws FileMonitorNotSupportedException + */ + public function initialize(array $filePaths): void + { + $hashedSince = time(); + $this->hashingFileMonitor->initialize($filePaths); + $this->lastHashedAt = microtime(true); + $this->open(); + $this->arm($hashedSince); + $this->verifyWatchesDeliver(); + } + + /** + * Proves the watches actually fire before anyone relies on them. + * + * A watch can be registered successfully and then never deliver anything: + * inotify reports nothing at all for a Docker Desktop bind mount (the + * host's files reach the container through a userspace filesystem), and + * the same is true of NFS and other network mounts. Silently watching such + * a tree would leave PHPStan Pro looking frozen, which is far worse than + * being slow, so a monitor that cannot see a file it created itself is + * refused and the caller falls back to hashing. + * + * @throws FileMonitorNotSupportedException + */ + private function verifyWatchesDeliver(): void + { + $directory = null; + $probe = null; + foreach (array_keys($this->watchedDirectories) as $candidate) { + $path = $candidate . DIRECTORY_SEPARATOR . '.phpstan-file-monitor-probe-' . uniqid(); + // no .php extension, so the finder never sees it even mid-probe + if (@file_put_contents($path, '') === false) { + continue; + } + + $directory = $candidate; + $probe = $path; + break; + } + + if ($probe === null || $directory === null) { + // nothing writable to prove anything with + throw new FileMonitorNotSupportedException(); + } + + $delivered = false; + $deadline = microtime(true) + self::PROBE_TIMEOUT_SECONDS; + while (microtime(true) < $deadline) { + if ($this->drainEvents()) { + $delivered = true; + break; + } + + usleep(self::PROBE_POLL_MICROSECONDS); + } + + @unlink($probe); + $this->drainEvents(); + + if (!$delivered) { + throw new FileMonitorNotSupportedException(); + } + } + + public function getChanges(): FileMonitorResult + { + if ($this->unwatchedStats === null) { + throw new ShouldNotHappenException(); + } + + if (!$this->drainEvents() && !$this->hasUnwatchedChange() && !$this->rescanPending && !$this->isHashingDue()) { + return new FileMonitorResult([], [], []); + } + + $this->rescanPending = false; + $hashedSince = time(); + $changes = $this->hashingFileMonitor->getChanges(); + $this->lastHashedAt = microtime(true); + + // the change may have created directories that need watching too + $this->rearm($hashedSince); + + return $changes; + } + + private function isHashingDue(): bool + { + if ($this->watchesCoverEverything) { + return false; + } + + return microtime(true) - $this->lastHashedAt >= $this->hashingFileMonitor->getPollInterval(); + } + + /** + * @param array $filePaths + */ + public function watchFiles(array $filePaths): void + { + if ($this->unwatchedStats === null) { + throw new ShouldNotHappenException(); + } + + // A file watched already keeps the stat from when it was last hashed: + // a change nobody polled yet, made during an analysis, must still open the gate. + $oldStats = $this->unwatchedStats; + + $hashedSince = time(); + $this->hashingFileMonitor->watchFiles($filePaths); + $this->rearm($hashedSince); + + foreach ($oldStats as $file => $stat) { + if (!isset($this->unwatchedStats[$file])) { + continue; + } + + $this->unwatchedStats[$file] = $stat; + } + } + + private function rearm(int $hashedSince): void + { + try { + $this->arm($hashedSince); + } catch (FileMonitorNotSupportedException) { + // the project outgrew the watch budget mid-session - the wrapped + // monitor keeps answering correctly, only without the gate + $this->watchesCoverEverything = false; + } + } + + public function getPollInterval(): float + { + return self::POLL_INTERVAL_SECONDS; + } + + /** + * @param int $hashedSince the second the hashing pass this follows started in + * @throws FileMonitorNotSupportedException + */ + private function arm(int $hashedSince): void + { + $roots = []; + foreach ([...$this->analysedPaths, ...$this->analysedPathsFromConfig, ...$this->scanDirectories] as $path) { + if (!is_dir($path)) { + continue; + } + + $roots[$path] = true; + } + + $unwatchedStats = []; + foreach ($this->hashingFileMonitor->getMonitoredFiles() as $file) { + $directory = $this->watchTargetDirectory($file); + if ($directory !== null && $this->isUnder($directory, $roots)) { + continue; + } + + [$mtime, $size] = $this->statOf($file); + $unwatchedStats[$file] = [$mtime, $size, $hashedSince]; + } + + $directories = $this->watchesRecursively() ? array_keys($roots) : $this->expand($roots); + if (count($directories) > static::WATCH_LIMIT) { + throw new FileMonitorNotSupportedException(); + } + + // Every directory, not only the new paths: a watch belongs to the directory, + // and one deleted and created again, like during a git checkout, needs a new one. + $watchedDirectories = []; + foreach ($directories as $directory) { + try { + $newWatch = $this->addWatch($directory); + } catch (FileMonitorNotSupportedException $e) { + // deleted since it was listed, like during a git checkout - the + // watch on its parent reports it if it comes back + if (!is_dir($directory)) { + continue; + } + + throw $e; + } + + $watchedDirectories[$directory] = true; + if (!$newWatch) { + continue; + } + + $this->rescanPending = true; + } + + $this->watchedDirectories = $watchedDirectories; + $this->unwatchedStats = $unwatchedStats; + } + + /** + * @return array{int, int} + */ + private function statOf(string $file): array + { + $stat = @stat($file); + + return $stat === false ? [0, 0] : [$stat['mtime'], $stat['size']]; + } + + /** + * Every directory below the roots, empty ones included: a file created in + * one of them is a change nobody else would report. + * + * @param array $roots + * @return array + * @throws FileMonitorNotSupportedException + */ + private function expand(array $roots): array + { + $directories = $roots; + foreach (array_keys($roots) as $root) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($iterator as $entry) { + if (!$entry->isDir()) { + continue; + } + + $directories[$entry->getPathname()] = true; + if (count($directories) > static::WATCH_LIMIT) { + throw new FileMonitorNotSupportedException(); + } + } + } + + return array_keys($directories); + } + + /** + * The real directory whose modification would reveal a change to $file, or + * null when there is none. + * + * A file inside a phar cannot change unless the archive does, so the + * archive's own directory is what needs watching. + */ + private function watchTargetDirectory(string $file): ?string + { + if (str_starts_with($file, 'phar://')) { + $file = substr($file, strlen('phar://')); + $end = strpos($file, '.phar'); + if ($end === false) { + return null; + } + + $file = substr($file, 0, $end + strlen('.phar')); + if (!is_file($file)) { + return null; + } + } + + $directory = dirname($file); + + return is_dir($directory) ? $directory : null; + } + + /** + * Whether a watch already covers this directory - directly for a recursive + * backend, through {@see self::expand()} having watched every directory + * below the root for a non-recursive one. + * + * @param array $roots + */ + private function isUnder(string $directory, array $roots): bool + { + foreach (array_keys($roots) as $root) { + if ($directory === $root) { + return true; + } + + if (str_starts_with($directory, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; + } + + private function hasUnwatchedChange(): bool + { + if ($this->unwatchedStats === null) { + throw new ShouldNotHappenException(); + } + + clearstatcache(); + $now = time(); + foreach ($this->unwatchedStats as $file => [$mtime, $size, $hashedSince]) { + if ($this->statOf($file) !== [$mtime, $size]) { + return true; + } + + // mtime has a resolution of a second: a rewrite that keeps the size + // within the second the file was hashed in leaves stat() unchanged, + // so such a file is hashed again once that second is over + if ($mtime >= $hashedSince && $now > $hashedSince) { + return true; + } + } + + return false; + } + + /** Whether one watch covers a whole subtree, or every directory needs its own. */ + abstract protected function watchesRecursively(): bool; + + /** + * @throws FileMonitorNotSupportedException + */ + abstract protected function open(): void; + + /** + * Watches the directory unless it is watched already. + * + * @return bool whether the watch is new + * @throws FileMonitorNotSupportedException + */ + abstract protected function addWatch(string $directory): bool; + + /** Consumes every queued event without blocking. */ + abstract protected function drainEvents(): bool; + +} diff --git a/src/Process/ProcessCanceledException.php b/src/Process/ProcessCanceledException.php deleted file mode 100644 index ae42c75d3b5..00000000000 --- a/src/Process/ProcessCanceledException.php +++ /dev/null @@ -1,10 +0,0 @@ - */ private Deferred $deferred; - private ?Process $process = null; - - private bool $canceled = false; - public function __construct(private LoopInterface $loop, private string $command) { - $this->deferred = new Deferred(function (): void { - $this->cancel(); - }); + $this->deferred = new Deferred(); } /** @@ -48,18 +41,13 @@ public function run(): PromiseInterface throw new ShouldNotHappenException('Failed creating temp file for stderr.'); } - $this->process = new Process($this->command, fds: [ + $process = new Process($this->command, fds: [ 1 => $tmpStdOutResource, 2 => $tmpStdErrResource, ]); - $this->process->start($this->loop); + $process->start($this->loop); - $this->process->on('exit', function ($exitCode) use ($tmpStdOutResource, $tmpStdErrResource): void { - if ($this->canceled) { - fclose($tmpStdOutResource); - fclose($tmpStdErrResource); - return; - } + $process->on('exit', function ($exitCode) use ($tmpStdOutResource, $tmpStdErrResource): void { rewind($tmpStdOutResource); $stdOut = stream_get_contents($tmpStdOutResource); fclose($tmpStdOutResource); @@ -84,14 +72,4 @@ public function run(): PromiseInterface return $this->deferred->promise(); } - private function cancel(): void - { - if ($this->process === null) { - throw new ShouldNotHappenException('Cancelling process before running'); - } - $this->canceled = true; - $this->process->terminate(); - $this->deferred->reject(new ProcessCanceledException()); - } - } diff --git a/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php index fd37082cae0..30bf9746beb 100644 --- a/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php +++ b/tests/PHPStan/Analyser/ResultCache/ResultCacheManagerCollectedDataTest.php @@ -5,6 +5,7 @@ use Nette\Utils\Strings; use Override; use PHPStan\Analyser\AnalyserResult; +use PHPStan\Analyser\Error; use PHPStan\Analyser\FinalizerResult; use PHPStan\Analyser\ValueDependencyCollector; use PHPStan\Command\Output; @@ -193,6 +194,51 @@ public function testPreviousEntryFramingIsDiscarded(): void $this->assertTrue($resultCache->isFullAnalysis()); } + public function testFileChangedDuringTheAnalysisIsAnalysedAgainWithTheFilesUsingIt(): void + { + [$a, $b, $c] = $this->files; + $dependencies = [$b => [$a]]; + $this->waitUntilTheFilesAreInThePast(); + $manager = $this->createManager(); + $resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput()); + // a file with an error unrelated to a, analysed again only when a new symbol appears + $processed = $manager->process($this->analyserResult([], $dependencies, $this->files, [$c]), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + + file_put_contents($a, "createManager(); + $resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertSame([$a, $b], $resultCache->getFilesToAnalyse()); + // written after b read it, before a itself was read + file_put_contents($a, "process($this->analyserResult([], $dependencies, [$a, $b]), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertSame([$a, $b], $resultCache->getFilesToAnalyse()); + } + + public function testFileChangedAndChangedBackDuringTheAnalysisIsAnalysedAgain(): void + { + $a = $this->files[0]; + $this->saveFullAnalysis([]); + + $hashed = "createManager(); + $resultCache = $manager->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertSame([$a], $resultCache->getFilesToAnalyse()); + // analysed while it was different, then undone in the editor in a later second + file_put_contents($a, "waitUntilTheFilesAreInThePast(); + file_put_contents($a, $hashed); + $processed = $manager->process($this->analyserResult([], [], [$a]), $resultCache, $this->nullOutput(), false, true); + $this->assertTrue($processed->save(new FinalizerResult($processed->getAnalyserResult(), [], []))); + + $resultCache = $this->createManager()->restore($this->files, false, false, null, $this->nullOutput()); + $this->assertSame([$a], $resultCache->getFilesToAnalyse()); + } + /** * A save keeps the stat signature only of a file not modified in the second the save began, * so without waiting, saves on both sides of a second boundary differ in their signatures. @@ -243,16 +289,29 @@ private function saveFullAnalysis(array $collectedData): void /** * @param array, list>> $collectedData + * @param array> $dependencies of the files that depend on others + * @param list $filesWithExportedNodes analysed files whose exported nodes are read now + * @param list $filesWithErrors */ - private function analyserResult(array $collectedData): AnalyserResult + private function analyserResult(array $collectedData, array $dependencies = [], array $filesWithExportedNodes = [], array $filesWithErrors = []): AnalyserResult { - $dependencies = []; foreach ($this->files as $file) { - $dependencies[$file] = []; + $dependencies[$file] ??= []; + } + + $exportedNodeFetcher = self::getContainer()->getByType(ExportedNodeFetcher::class); + $exportedNodes = []; + foreach ($filesWithExportedNodes as $file) { + $exportedNodes[$file] = $exportedNodeFetcher->fetchNodes($file); + } + + $errors = []; + foreach ($filesWithErrors as $file) { + $errors[] = new Error('An error.', $file, 1); } return new AnalyserResult( - unorderedErrors: [], + unorderedErrors: $errors, filteredPhpErrors: [], allPhpErrors: [], locallyIgnoredErrors: [], @@ -264,7 +323,7 @@ private function analyserResult(array $collectedData): AnalyserResult usedTraitDependencies: [], valueDependencies: ['values' => [], 'dependents' => []], packageDependencies: [], - exportedNodes: [], + exportedNodes: $exportedNodes, reachedInternalErrorsCountLimit: false, peakMemoryUsageBytes: 0, processedFiles: $this->files, diff --git a/tests/PHPStan/File/FileMonitorTest.php b/tests/PHPStan/File/FileMonitorTest.php new file mode 100644 index 00000000000..15042721aad --- /dev/null +++ b/tests/PHPStan/File/FileMonitorTest.php @@ -0,0 +1,313 @@ +directory = sys_get_temp_dir() . '/phpstan-file-monitor-' . uniqid(); + mkdir($this->directory . '/sub', 0777, true); + file_put_contents($this->directory . '/a.php', "directory . '/sub/b.php', "directory . '-config'); + file_put_contents($this->directory . '-config/phpstan.neon', "parameters:\n\tlevel: 5\n"); + } + + #[Override] + protected function tearDown(): void + { + $this->removeDirectory($this->directory); + $this->removeDirectory($this->directory . '-config'); + } + + public static function dataMonitors(): iterable + { + yield 'hashing' => ['hashing']; + + if (PHP_OS_FAMILY === 'Darwin') { + yield 'fsevents' => ['fsevents']; + } elseif (PHP_OS_FAMILY === 'Linux') { + yield 'inotify' => ['inotify']; + } + } + + #[DataProvider('dataMonitors')] + public function testNothingChanged(string $kind): void + { + $monitor = $this->createMonitor($kind); + $this->assertNoChanges($monitor); + } + + #[DataProvider('dataMonitors')] + public function testRewritingAFileWithTheSameContentIsNotAChange(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/a.php', "assertNoChanges($monitor); + } + + #[DataProvider('dataMonitors')] + public function testChangedFile(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/a.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('a.php')], $changes->getChangedFiles()); + $this->assertSame([], $changes->getNewFiles()); + $this->assertSame([], $changes->getDeletedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testFileChangedInSubdirectory(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/sub/b.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('sub/b.php')], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testNewFile(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/c.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('c.php')], $changes->getNewFiles()); + $this->assertSame([], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testNewFileInNewDirectory(string $kind): void + { + $monitor = $this->createMonitor($kind); + mkdir($this->directory . '/fresh'); + file_put_contents($this->directory . '/fresh/d.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('fresh/d.php')], $changes->getNewFiles()); + } + + #[DataProvider('dataMonitors')] + public function testDeletedFile(string $kind): void + { + $monitor = $this->createMonitor($kind); + unlink($this->directory . '/sub/b.php'); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$this->expectedPath('sub/b.php')], $changes->getDeletedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testChangesAreReportedOnlyOnce(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/a.php', "waitForChanges($monitor); + $this->assertNoChanges($monitor); + } + + #[DataProvider('dataMonitors')] + public function testSecondChangeAfterTheFirstOne(string $kind): void + { + $monitor = $this->createMonitor($kind); + file_put_contents($this->directory . '/a.php', "waitForChanges($monitor); + file_put_contents($this->directory . '/sub/b.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('sub/b.php')], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testFileChangedInDirectoryDeletedAndCreatedAgain(string $kind): void + { + $monitor = $this->createMonitor($kind); + // like switching git branches + unlink($this->directory . '/sub/b.php'); + rmdir($this->directory . '/sub'); + $this->waitForChanges($monitor); + mkdir($this->directory . '/sub'); + file_put_contents($this->directory . '/sub/c.php', "waitForChanges($monitor); + + file_put_contents($this->directory . '/sub/c.php', "waitForChanges($monitor); + $this->assertSame([$this->expectedPath('sub/c.php')], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testChangedConfigFile(string $kind): void + { + $config = $this->directory . '-config/phpstan.neon'; + $monitor = $this->createMonitor($kind, [$config]); + file_put_contents($config, "parameters:\n\tlevel: 6\n"); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$config], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testConfigFileChangedTwiceWithinASecondKeepingItsSize(string $kind): void + { + $config = $this->directory . '-config/phpstan.neon'; + $monitor = $this->createMonitor($kind, [$config]); + file_put_contents($config, "parameters:\n\tlevel: 6\n"); + $this->waitForChanges($monitor); + file_put_contents($config, "parameters:\n\tlevel: 7\n"); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$config], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testConfigFileMissingAtStartAppears(string $kind): void + { + $baseline = $this->directory . '-config/baseline.neon'; + $monitor = $this->createMonitor($kind, [$baseline]); + $this->assertNoChanges($monitor); + file_put_contents($baseline, "parameters:\n\tignoreErrors: []\n"); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$baseline], $changes->getNewFiles()); + } + + #[DataProvider('dataMonitors')] + public function testWatchFilesStartsWatchingAFile(string $kind): void + { + $config = $this->directory . '-config/phpstan.neon'; + $baseline = $this->directory . '-config/baseline.neon'; + $monitor = $this->createMonitor($kind, [$config]); + file_put_contents($baseline, "parameters:\n\tignoreErrors: []\n"); + $monitor->watchFiles([$config, $baseline]); + $this->assertNoChanges($monitor); + + file_put_contents($baseline, "parameters:\n\tignoreErrors: [x]\n"); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$baseline], $changes->getChangedFiles()); + } + + #[DataProvider('dataMonitors')] + public function testChangeBeforeWatchFilesIsNotLost(string $kind): void + { + $config = $this->directory . '-config/phpstan.neon'; + $baseline = $this->directory . '-config/baseline.neon'; + $monitor = $this->createMonitor($kind, [$config]); + // changed during an analysis, when nobody polls, before the analysis reports its config files + file_put_contents($config, "parameters:\n\tlevel: 6\n\nincludes:\n\t- baseline.neon\n"); + file_put_contents($baseline, "parameters:\n\tignoreErrors: []\n"); + $monitor->watchFiles([$config, $baseline]); + $changes = $this->waitForChanges($monitor); + $this->assertSame([$config], $changes->getChangedFiles()); + } + + /** The monitors report paths normalized by FileHelper, with the platform's directory separator. */ + private function expectedPath(string $relativePath): string + { + return (new FileHelper($this->directory))->normalizePath($this->directory . '/' . $relativePath); + } + + /** + * @param string[] $extraFiles + */ + private function createMonitor(string $kind, array $extraFiles = []): FileMonitor + { + $fileHelper = new FileHelper($this->directory); + $directoryWalker = new DirectoryWalker(new FileStatSignatures()); + $finder = new FileFinder(new FileExcluder($fileHelper, []), $fileHelper, ['php'], $directoryWalker); + $hashing = new HashingFileMonitor($finder, $finder, [$this->directory], [$this->directory], [], [], $directoryWalker); + + if ($kind === 'hashing') { + $monitor = $hashing; + } elseif ($kind === 'fsevents') { + $monitor = new FsEventsFileMonitor($hashing, [$this->directory], [$this->directory], []); + } elseif ($kind === 'inotify') { + $monitor = new InotifyFileMonitor($hashing, [$this->directory], [$this->directory], []); + } else { + self::fail('Unknown monitor ' . $kind); + } + + try { + $monitor->initialize($extraFiles); + } catch (FileMonitorNotSupportedException $e) { + // FFI disabled, or the kernel refused a watch - the factory would + // fall back to hashing here, and so does the test + self::markTestSkipped(sprintf('%s monitor is not supported here: %s', $kind, $e->getMessage())); + } + + return $monitor; + } + + private function waitForChanges(FileMonitor $monitor): FileMonitorResult + { + for ($i = 0; $i < self::WAIT_ITERATIONS_LIMIT; $i++) { + $changes = $monitor->getChanges(); + if ($changes->hasAnyChanges()) { + return $changes; + } + + usleep(self::WAIT_STEP_MICROSECONDS); + } + + $this->fail(sprintf('No changes reported within %d ms', self::WAIT_ITERATIONS_LIMIT * self::WAIT_STEP_MICROSECONDS / 1000)); + } + + private function assertNoChanges(FileMonitor $monitor): void + { + // a native monitor may still have a queued event; it must not turn into + // a reported change, so poll a few times rather than only once + for ($i = 0; $i < 10; $i++) { + $changes = $monitor->getChanges(); + $this->assertSame([], $changes->getNewFiles()); + $this->assertSame([], $changes->getChangedFiles()); + $this->assertSame([], $changes->getDeletedFiles()); + usleep(self::WAIT_STEP_MICROSECONDS); + } + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + foreach (new DirectoryIterator($directory) as $entry) { + if ($entry->isDot()) { + continue; + } + + if ($entry->isDir()) { + $this->removeDirectory($entry->getPathname()); + continue; + } + + unlink($entry->getPathname()); + } + + rmdir($directory); + } + +}