diff --git a/build/phpstan.neon b/build/phpstan.neon index 5d82318da9c..5a5f6fd5869 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -80,6 +80,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 f9b6f60d628..cc5da1f09f2 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(), @@ -404,24 +407,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/FileMonitor.php b/src/File/FileMonitor.php index 0778fd0c647..7c685abeed6 100644 --- a/src/File/FileMonitor.php +++ b/src/File/FileMonitor.php @@ -2,146 +2,32 @@ 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 KqueueFileMonitor}, {@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. + */ +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, - ) - { - } - /** - * @param array $filePaths + * @param array $filePaths extra files to monitor besides the analysed and scanned ones */ - 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(); - } - $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; - } + public function initialize(array $filePaths): void; - $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; - } + public function getChanges(): FileMonitorResult; /** - * @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..31d06c7382f --- /dev/null +++ b/src/File/FsEventsFileMonitor.php @@ -0,0 +1,192 @@ + + */ + 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]'); + $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..c30188abc3b --- /dev/null +++ b/src/File/HashingFileMonitor.php @@ -0,0 +1,169 @@ +|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, + ) + { + } + + /** + * @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(); + } + $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..3ae15a07112 --- /dev/null +++ b/src/File/NativeFileMonitor.php @@ -0,0 +1,356 @@ + */ + 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, + ) + { + } + + /** + * @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..32ed04f14f8 --- /dev/null +++ b/tests/PHPStan/File/FileMonitorTest.php @@ -0,0 +1,220 @@ +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->directory . '/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->directory . '/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->directory . '/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->directory . '/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->directory . '/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->directory . '/sub/b.php'], $changes->getChangedFiles()); + } + + private function createMonitor(string $kind): FileMonitor + { + $fileHelper = new FileHelper($this->directory); + $finder = new FileFinder(new FileExcluder($fileHelper, []), $fileHelper, ['php']); + $hashing = new HashingFileMonitor($finder, $finder, [$this->directory], [$this->directory], [], []); + + 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); + } + +}