From 7fabeca42eef902a45995f2e11fca117c9b5ab19 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 4 Nov 2026 06:27:58 +0000 Subject: [PATCH 1/5] 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. Files outside the watched trees - config files, composer.lock - are polled with stat(), whose mtime has a resolution of a second. A rewrite that keeps the size within the second the file was last hashed in looks unchanged, so such a file is hashed again once that second is over, the way git treats racily clean index entries. A new directory gets its inotify watch after the hashing pass that found it, so the next poll hashes once more to catch a file written into it in between. Every re-arm asks for a watch on every directory again, because a directory deleted and created again, like during a git checkout, needs a new one, and a directory deleted while being armed is skipped instead of disabling the gate. Config files added mid-session through watchFiles() keep the stat of the files watched already, so an edit made during an analysis is not lost. 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 Amp-Thread-ID: https://ampcode.com/threads/T-01a1a533-a2c5-7581-b58e-fb459c136c67 --- build/ffi-new-not-nullable.neon | 8 + build/ignore-by-php-version.neon.php | 4 + build/phpstan.neon | 8 + src/Command/FixerApplication.php | 27 +- src/File/DirectoryWalker.php | 2 +- src/File/FileMonitor.php | 197 +------- src/File/FileMonitorFactory.php | 94 ++++ src/File/FileMonitorNotSupportedException.php | 15 + src/File/FileMonitorResult.php | 16 + src/File/FsEventsFileMonitor.php | 202 ++++++++ src/File/HashingFileMonitor.php | 217 +++++++++ src/File/InotifyFileMonitor.php | 124 +++++ src/File/NativeFileMonitor.php | 450 ++++++++++++++++++ tests/PHPStan/File/FileMonitorTest.php | 313 ++++++++++++ 14 files changed, 1490 insertions(+), 187 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 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/Command/FixerApplication.php b/src/Command/FixerApplication.php index 8b8e408d1e8..4f4eaf2d826 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; @@ -73,7 +74,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 +99,8 @@ public function __construct( { } + private ?FileMonitor $fileMonitor = null; + public function run( InceptionResult $inceptionResult, InputInterface $input, @@ -155,7 +158,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, @@ -395,24 +398,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( @@ -438,6 +446,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; } 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/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); + } + +} From 0e9ec2e76d684e30d42b6cce54effac892f804e4 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 4 Nov 2026 07:48:13 +0000 Subject: [PATCH 2/5] PHPStan Pro: analyse a burst of changes once it is over The file monitor reports changes as soon as a poll sees them, and with the native monitors that is every 50 ms. A git checkout or a coding standard fixer writing files over a second was analysed as soon as its first files changed - a half-written tree, with errors that are gone a moment later - and once more for the rest. The code shown still follows every poll, but the analysis starts once no change was seen for 500 ms, or 2 s after the first change for changes that never stop. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a533-a2c5-7581-b58e-fb459c136c67 --- src/Command/FixerApplication.php | 55 +++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index 4f4eaf2d826..b199b2e64c8 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -47,6 +47,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; @@ -59,6 +61,15 @@ 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; @@ -168,18 +179,20 @@ public function run( $encoder, ); - $this->monitorFileChanges($loop, function (FileMonitorResult $changes) use ($loop, $mainScript, $projectConfigFile, $input, $encoder): void { + $this->monitorFileChanges($loop, static function (FileMonitorResult $changes) use ($encoder): void { + if (count($changes->getChangedFiles()) === 0) { + return; + } + + $encoder->write(['action' => 'changedFiles', 'data' => [ + 'paths' => $changes->getChangedFiles(), + ]]); + }, function () use ($loop, $mainScript, $projectConfigFile, $input, $encoder): void { if ($this->processInProgress !== null) { $this->processInProgress->cancel(); $this->processInProgress = null; } - if (count($changes->getChangedFiles()) > 0) { - $encoder->write(['action' => 'changedFiles', 'data' => [ - 'paths' => $changes->getChangedFiles(), - ]]); - } - $this->analyse( $loop, $mainScript, @@ -394,16 +407,19 @@ 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 { if ($this->fileMonitor === null) { throw new ShouldNotHappenException(); } $fileMonitor = $this->fileMonitor; $interval = $fileMonitor->getPollInterval(); - $callback = function () use (&$callback, $loop, $hasChangesCallback, $fileMonitor, $interval): void { + $firstChangeAt = null; + $lastChangeAt = null; + $callback = function () use (&$callback, &$firstChangeAt, &$lastChangeAt, $loop, $hasChangesCallback, $changesSettledCallback, $fileMonitor, $interval): void { if (!$this->fileMonitorActive) { $loop->addTimer($interval, $callback); return; @@ -413,12 +429,29 @@ private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCal return; } $changes = $fileMonitor->getChanges(); + $now = microtime(true); if ($changes->hasAnyChanges()) { + $firstChangeAt ??= $now; + $lastChangeAt = $now; $hasChangesCallback($changes); } - $loop->addTimer($interval, $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($interval, $callback); } From cd234c777636c74445d2e85a1bcd1e9ad369dd6b Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 4 Nov 2026 08:48:59 +0000 Subject: [PATCH 3/5] Analyse again the files changed while they were analysed The result cache records the hash a file had when the run began, before it was read. A file written to during the analysis was saved under that hash with what the analysis made of whichever version it read: - An edit undone before the analysis ended left the errors of the undone code under the hash of the current one, for good. - The exported nodes of a changed file were saved from its new contents, while the files using it may have been analysed against the old ones. The next run found its exported nodes unchanged and kept their errors. PHPStan Pro hits both all the time: the files are being edited while it analyses them. When it saves, the result cache now compares the stat of the analysed files with the one taken when they were hashed, and for a file written to in the second it was hashed, its hash. A changed file and the files using it are recorded to be analysed again, which costs the next run what an API change of that file would. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a533-a2c5-7581-b58e-fb459c136c67 --- .../ResultCache/ResultCacheManager.php | 115 +++++++++++++++++- .../ResultCacheManagerCollectedDataTest.php | 69 ++++++++++- 2 files changed, 177 insertions(+), 7 deletions(-) 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/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, From 0f02c24ac644492589d8451acd984255b3b23de8 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 4 Nov 2026 08:48:59 +0000 Subject: [PATCH 4/5] PHPStan Pro: show parse errors with the other errors A syntax error in an analysed file, a common sight while typing, replaced all the errors with an "Internal error" panel asking for a bug report. It is shown on its line now, can't be ignored, and the other errors stay. While there is one, ignores that matched nothing are not reported, the file's errors are not known. Errors with an exception other than a parse error stay internal errors. A syntax error in code that runs, like a bootstrap file, still stops the worker and is shown as a crashed analysis. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a533-a2c5-7581-b58e-fb459c136c67 --- src/Command/FixerWorkerRunner.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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(); From a915bdc2e05ef1aff8a652136cb72299d09b2c8b Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 4 Nov 2026 08:48:59 +0000 Subject: [PATCH 5/5] PHPStan Pro: analyse the files changed while an analysis runs once it ends Files were not watched during an analysis, so an edit made while it ran was noticed only after it ended, by the next poll. They are watched during an analysis now, and the code shown follows them right away. Once they settle, the analysis in progress is left to finish - what it saves to the result cache makes the next one cheaper - and one more starts when it ends, for everything that changed meanwhile. One worker at a time writes the result cache. Stopping the analysis in progress instead was tried, on the Laravel framework with edits overlapping analyses: about the same time until the final result, but 35-40 % more CPU, because each stopped analysis is thrown away. Only while the API of a widely used class kept changing was stopping faster, by 3 s of 14 s. Cancelling the worker never stopped it anyway - terminating the process terminated the shell running its command - and is removed. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a533-a2c5-7581-b58e-fb459c136c67 --- src/Command/FixerApplication.php | 32 +++++++++++++----------- src/Process/ProcessCanceledException.php | 10 -------- src/Process/SpawnedProcessPromise.php | 32 ++++-------------------- 3 files changed, 22 insertions(+), 52 deletions(-) delete mode 100644 src/Process/ProcessCanceledException.php diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index b199b2e64c8..001a3d95a53 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -24,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; @@ -73,6 +72,9 @@ final class FixerApplication /** @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 */ @@ -188,9 +190,11 @@ public function run( '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->processInProgress->cancel(); - $this->processInProgress = null; + $this->analysisRequested = true; + return; } $this->analyse( @@ -424,10 +428,6 @@ private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCal $loop->addTimer($interval, $callback); return; } - if ($this->processInProgress !== null) { - $loop->addTimer($interval, $callback); - return; - } $changes = $fileMonitor->getChanges(); $now = microtime(true); @@ -506,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(); @@ -535,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/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()); - } - }