From 8a15b24b5a14143588fcc5c85ca4b515079d9eb4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 5 Sep 2026 08:47:30 +0200 Subject: [PATCH] Watch files through the OS instead of re-hashing them every second PHPStan Pro's FileMonitor re-hashed every analysed and scanned file on every poll - 2772 files and ~230ms on a mid-sized project, once a second, for as long as the browser tab is open. That is also the floor on how fast an edit can be noticed. FileMonitor becomes an interface. The old implementation is HashingFileMonitor and stays the answer for Windows and for anything the native backends cannot do. FsEventsFileMonitor (macOS) and InotifyFileMonitor (Linux) reach the kernel through FFI, which needs no extra extension and works in CLI whatever ffi.enable says. The native monitors are a gate in front of the hashing one rather than a replacement: the kernel is only asked "was anything touched", and the wrapped HashingFileMonitor still decides which files that means, so the reported FileMonitorResult is what it always was. An idle poll costs one non-blocking syscall instead of reading the project, which is what lets the poll interval drop from 1s to 50ms. kqueue would have been the smaller dependency on macOS but EVFILT_VNODE on a directory reports directory-entry changes only - a file rewritten in place, which is what most editor saves are, produces no event at all. Registering a watch can succeed on a filesystem that then never delivers anything, and a monitor that silently sees nothing would leave Pro looking frozen. So initialize() writes a probe file into a watched directory and refuses the backend unless the kernel reports it, which costs ~17ms once and turns that failure into a fallback. Measured on a 2760-file tree, hashing vs native: macOS idle poll 81.9ms -> 0.002ms, detection 1110ms -> 143ms Linux idle poll 34.2ms -> 0.002ms, detection 1065ms -> 104ms End to end in Pro on a real project, edit to analysis start: 0.749s -> 0.250s, and the full hash passes over a 40s session went from 26 to 4 - one per edit, none while idle. PHPSTAN_DISABLE_NATIVE_FILE_MONITOR forces the hashing monitor. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LD1Zy3qXBVk6ajYdn6QPKu Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6ecc-7668-9fe2-6e4d943f7b8e --- build/ffi-new-not-nullable.neon | 8 + build/ignore-by-php-version.neon.php | 4 + build/phpstan.neon | 8 + src/Command/FixerApplication.php | 24 +- src/File/DirectoryWalker.php | 2 +- src/File/FileMonitor.php | 159 +------- src/File/FileMonitorFactory.php | 94 +++++ src/File/FileMonitorNotSupportedException.php | 15 + src/File/FileMonitorResult.php | 16 + src/File/FsEventsFileMonitor.php | 195 ++++++++++ src/File/HashingFileMonitor.php | 174 +++++++++ src/File/InotifyFileMonitor.php | 107 ++++++ src/File/NativeFileMonitor.php | 357 ++++++++++++++++++ tests/PHPStan/File/FileMonitorTest.php | 227 +++++++++++ 14 files changed, 1240 insertions(+), 150 deletions(-) create mode 100644 build/ffi-new-not-nullable.neon create mode 100644 src/File/FileMonitorFactory.php create mode 100644 src/File/FileMonitorNotSupportedException.php create mode 100644 src/File/FsEventsFileMonitor.php create mode 100644 src/File/HashingFileMonitor.php create mode 100644 src/File/InotifyFileMonitor.php create mode 100644 src/File/NativeFileMonitor.php create mode 100644 tests/PHPStan/File/FileMonitorTest.php 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 e4c1650ff6a..5619f6226db 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/Command/FixerApplication.php b/src/Command/FixerApplication.php index 2ebe31762bd..dce60f46c3f 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -16,6 +16,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; @@ -75,7 +76,7 @@ final class FixerApplication * @param string[] $bootstrapFiles */ public function __construct( - private FileMonitor $fileMonitor, + private FileMonitorFactory $fileMonitorFactory, private IgnoredErrorHelper $ignoredErrorHelper, private StubFilesProvider $stubFilesProvider, #[AutowiredParameter] @@ -103,6 +104,8 @@ public function __construct( { } + private ?FileMonitor $fileMonitor = null; + public function run( InceptionResult $inceptionResult, InputInterface $input, @@ -154,7 +157,7 @@ public function run( } }); - $this->fileMonitor->initialize(array_merge( + $this->fileMonitor = $this->fileMonitorFactory->create(array_merge( $this->getComposerLocks(), $this->getComposerInstalled(), $this->getExecutedFiles(), @@ -402,24 +405,29 @@ private function writeInfoFile(string $infoPath, string $version, string $branch */ private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback): void { - $callback = function () use (&$callback, $loop, $hasChangesCallback): void { + if ($this->fileMonitor === null) { + throw new ShouldNotHappenException(); + } + $fileMonitor = $this->fileMonitor; + $interval = $fileMonitor->getPollInterval(); + $callback = function () use (&$callback, $loop, $hasChangesCallback, $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); + $loop->addTimer($interval, $callback); return; } - $changes = $this->fileMonitor->getChanges(); + $changes = $fileMonitor->getChanges(); if ($changes->hasAnyChanges()) { $hasChangesCallback($changes); } - $loop->addTimer(1.0, $callback); + $loop->addTimer($interval, $callback); }; - $loop->addTimer(1.0, $callback); + $loop->addTimer($interval, $callback); } private function analyse( 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 4e21aa2c11e..2daa5729411 100644 --- a/src/File/FileMonitor.php +++ b/src/File/FileMonitor.php @@ -2,151 +2,28 @@ 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; - -#[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, - ) - { - } + public function getChanges(): FileMonitorResult; /** - * @param array $filePaths + * 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. */ - 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); - } - - $this->fileHashes = $fileHashes; - $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) { - if (!array_key_exists($filePath, $oldFileHashes)) { - $newFiles[] = $filePath; - $fileHashes[$filePath] = $this->getFileHash($filePath); - continue; - } - - $oldHash = $oldFileHashes[$filePath]; - unset($oldFileHashes[$filePath]); - $newHash = $this->getFileHash($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 - { - $hash = hash_file('sha256', $filePath); - - if ($hash === false) { - throw new CouldNotReadFileException($filePath); - } - - 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); - } + 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..79b9a33aa34 --- /dev/null +++ b/src/File/FsEventsFileMonitor.php @@ -0,0 +1,195 @@ + + */ + 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): void + { + // 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); + } + + 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[] = [$stream, $array, $paths, $cfString]; + } + +} diff --git a/src/File/HashingFileMonitor.php b/src/File/HashingFileMonitor.php new file mode 100644 index 00000000000..fa395dffb5c --- /dev/null +++ b/src/File/HashingFileMonitor.php @@ -0,0 +1,174 @@ +|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) { + $fileHashes[$filePath] = $this->getFileHash($filePath); + } + + $this->fileHashes = $fileHashes; + $this->filePaths = $filePaths; + } + + public function getPollInterval(): float + { + return self::POLL_INTERVAL_SECONDS; + } + + /** + * The paths initialize() decided to watch, for a monitor wrapping this one. + * + * @return array + */ + public function getMonitoredFiles(): array + { + if ($this->fileHashes === null) { + throw new ShouldNotHappenException(); + } + + return array_keys($this->fileHashes); + } + + 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) { + if (!array_key_exists($filePath, $oldFileHashes)) { + $newFiles[] = $filePath; + $fileHashes[$filePath] = $this->getFileHash($filePath); + continue; + } + + $oldHash = $oldFileHashes[$filePath]; + unset($oldFileHashes[$filePath]); + $newHash = $this->getFileHash($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 + { + $hash = hash_file('sha256', $filePath); + + if ($hash === false) { + throw new CouldNotReadFileException($filePath); + } + + 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..68d3e5740ac --- /dev/null +++ b/src/File/InotifyFileMonitor.php @@ -0,0 +1,107 @@ +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): void + { + 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. + if ($this->ffi->inotify_add_watch($this->fd, $directory, self::WATCH_MASK) < 0) { + throw new FileMonitorNotSupportedException(); + } + } + + 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..6dfe11c78e0 --- /dev/null +++ b/src/File/NativeFileMonitor.php @@ -0,0 +1,357 @@ + */ + 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] + */ + private ?array $unwatchedStats = null; + + /** + * @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 + { + $this->hashingFileMonitor->initialize($filePaths); + $this->open(); + $this->arm(); + $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()) { + return new FileMonitorResult([], [], []); + } + + $changes = $this->hashingFileMonitor->getChanges(); + + try { + // the change may have created directories that need watching too + $this->arm(); + } catch (FileMonitorNotSupportedException) { + // the project outgrew the watch budget mid-session - the wrapped + // monitor keeps answering correctly, only without the gate + } + + return $changes; + } + + public function getPollInterval(): float + { + return self::POLL_INTERVAL_SECONDS; + } + + /** + * @throws FileMonitorNotSupportedException + */ + private function arm(): 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; + } + + $unwatchedStats[$file] = $this->statOf($file); + } + + $directories = $this->watchesRecursively() ? array_keys($roots) : $this->expand($roots); + if (count($directories) > static::WATCH_LIMIT) { + throw new FileMonitorNotSupportedException(); + } + + foreach ($directories as $directory) { + if (isset($this->watchedDirectories[$directory])) { + continue; + } + + $this->addWatch($directory); + $this->watchedDirectories[$directory] = true; + } + + $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(); + } + + foreach ($this->unwatchedStats as $file => $stat) { + if ($this->statOf($file) !== $stat) { + 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; + + /** + * @throws FileMonitorNotSupportedException + */ + abstract protected function addWatch(string $directory): void; + + /** Consumes every queued event without blocking. */ + abstract protected function drainEvents(): bool; + +} diff --git a/tests/PHPStan/File/FileMonitorTest.php b/tests/PHPStan/File/FileMonitorTest.php new file mode 100644 index 00000000000..0cb9f5d2f0d --- /dev/null +++ b/tests/PHPStan/File/FileMonitorTest.php @@ -0,0 +1,227 @@ +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', "removeDirectory($this->directory); + } + + 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()); + } + + /** 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); + } + + private function createMonitor(string $kind): 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([]); + } 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); + } + +}