Skip to content

Commit 993b8b6

Browse files
ondrejmirtesclaude
andcommitted
Watch files through the OS instead of re-hashing them every second
PHPStan Pro's FileMonitor re-hashed every analysed and scanned file on every poll - 2772 files and ~230ms on a mid-sized project, once a second, for as long as the browser tab is open. That is also the floor on how fast an edit can be noticed. FileMonitor becomes an interface. The old implementation is HashingFileMonitor and stays the answer for Windows and for anything the native backends cannot do. FsEventsFileMonitor (macOS) and InotifyFileMonitor (Linux) reach the kernel through FFI, which needs no extra extension and works in CLI whatever ffi.enable says. The native monitors are a gate in front of the hashing one rather than a replacement: the kernel is only asked "was anything touched", and the wrapped HashingFileMonitor still decides which files that means, so the reported FileMonitorResult is what it always was. An idle poll costs one non-blocking syscall instead of reading the project, which is what lets the poll interval drop from 1s to 50ms. kqueue would have been the smaller dependency on macOS but EVFILT_VNODE on a directory reports directory-entry changes only - a file rewritten in place, which is what most editor saves are, produces no event at all. Registering a watch can succeed on a filesystem that then never delivers anything, and a monitor that silently sees nothing would leave Pro looking frozen. So initialize() writes a probe file into a watched directory and refuses the backend unless the kernel reports it, which costs ~17ms once and turns that failure into a fallback. Measured on a 2760-file tree, hashing vs native: macOS idle poll 81.9ms -> 0.002ms, detection 1110ms -> 143ms Linux idle poll 34.2ms -> 0.002ms, detection 1065ms -> 104ms End to end in Pro on a real project, edit to analysis start: 0.749s -> 0.250s, and the full hash passes over a 40s session went from 26 to 4 - one per edit, none while idle. PHPSTAN_DISABLE_NATIVE_FILE_MONITOR forces the hashing monitor. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LD1Zy3qXBVk6ajYdn6QPKu Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6ecc-7668-9fe2-6e4d943f7b8e
1 parent 091c5a7 commit 993b8b6

12 files changed

Lines changed: 1225 additions & 150 deletions

‎build/phpstan.neon‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ parameters:
8282
- '#^Dynamic call to static method PHPUnit\\Framework\\\S+\(\)\.$#'
8383
- '#should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule<PhpParser\\Node>::processNode\(\)$#'
8484
- '#Variable property access on PhpParser\\Node#'
85+
-
86+
# FFI's methods are the C functions declared in the cdef string at
87+
# runtime, so no reflection can know them
88+
identifier: method.notFound
89+
message: '#^Call to an undefined method FFI::#'
90+
paths:
91+
- ../src/File/FsEventsFileMonitor.php
92+
- ../src/File/InotifyFileMonitor.php
8593
-
8694
identifier: shipmonk.deadMethod
8795
message: '#^Unused .*?::__construct#' # likely used in DIC

‎src/Command/FixerApplication.php‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use PHPStan\DependencyInjection\AutowiredParameter;
1717
use PHPStan\DependencyInjection\AutowiredService;
1818
use PHPStan\File\FileMonitor;
19+
use PHPStan\File\FileMonitorFactory;
1920
use PHPStan\File\FileMonitorResult;
2021
use PHPStan\File\FileReader;
2122
use PHPStan\File\FileWriter;
@@ -75,7 +76,7 @@ final class FixerApplication
7576
* @param string[] $bootstrapFiles
7677
*/
7778
public function __construct(
78-
private FileMonitor $fileMonitor,
79+
private FileMonitorFactory $fileMonitorFactory,
7980
private IgnoredErrorHelper $ignoredErrorHelper,
8081
private StubFilesProvider $stubFilesProvider,
8182
#[AutowiredParameter]
@@ -103,6 +104,8 @@ public function __construct(
103104
{
104105
}
105106

107+
private ?FileMonitor $fileMonitor = null;
108+
106109
public function run(
107110
InceptionResult $inceptionResult,
108111
InputInterface $input,
@@ -154,7 +157,7 @@ public function run(
154157
}
155158
});
156159

157-
$this->fileMonitor->initialize(array_merge(
160+
$this->fileMonitor = $this->fileMonitorFactory->create(array_merge(
158161
$this->getComposerLocks(),
159162
$this->getComposerInstalled(),
160163
$this->getExecutedFiles(),
@@ -402,24 +405,29 @@ private function writeInfoFile(string $infoPath, string $version, string $branch
402405
*/
403406
private function monitorFileChanges(LoopInterface $loop, callable $hasChangesCallback): void
404407
{
405-
$callback = function () use (&$callback, $loop, $hasChangesCallback): void {
408+
if ($this->fileMonitor === null) {
409+
throw new ShouldNotHappenException();
410+
}
411+
$fileMonitor = $this->fileMonitor;
412+
$interval = $fileMonitor->getPollInterval();
413+
$callback = function () use (&$callback, $loop, $hasChangesCallback, $fileMonitor, $interval): void {
406414
if (!$this->fileMonitorActive) {
407-
$loop->addTimer(1.0, $callback);
415+
$loop->addTimer($interval, $callback);
408416
return;
409417
}
410418
if ($this->processInProgress !== null) {
411-
$loop->addTimer(1.0, $callback);
419+
$loop->addTimer($interval, $callback);
412420
return;
413421
}
414-
$changes = $this->fileMonitor->getChanges();
422+
$changes = $fileMonitor->getChanges();
415423

416424
if ($changes->hasAnyChanges()) {
417425
$hasChangesCallback($changes);
418426
}
419427

420-
$loop->addTimer(1.0, $callback);
428+
$loop->addTimer($interval, $callback);
421429
};
422-
$loop->addTimer(1.0, $callback);
430+
$loop->addTimer($interval, $callback);
423431
}
424432

425433
private function analyse(

‎src/File/DirectoryWalker.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
* walk between the two - both happen before the analysis starts, on a file set that is snapshotted
4343
* for the whole run anyway.
4444
*
45-
* FileMonitor detects changes by re-running the finder and must see the filesystem as it is now,
45+
* HashingFileMonitor detects changes by re-running the finder and must see the filesystem as it is now,
4646
* so it walks uncached and clears the shared walks before each check.
4747
*
4848
* Reading the directories is what a walk costs - on a tree the size of Drupal core (28k directories)

‎src/File/FileMonitor.php‎

Lines changed: 18 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -2,151 +2,28 @@
22

33
namespace PHPStan\File;
44

5-
use PHPStan\DependencyInjection\AutowiredParameter;
6-
use PHPStan\DependencyInjection\AutowiredService;
7-
use PHPStan\ShouldNotHappenException;
8-
use function array_diff;
9-
use function array_key_exists;
10-
use function array_keys;
11-
use function array_merge;
12-
use function array_unique;
13-
use function hash_file;
14-
use function is_dir;
15-
use function is_file;
16-
17-
#[AutowiredService]
18-
final class FileMonitor
5+
/**
6+
* Watches the analysed and scanned files for changes between PHPStan Pro analyses.
7+
*
8+
* {@see HashingFileMonitor} is the portable implementation: it re-hashes every
9+
* monitored file on every poll. The native implementations
10+
* ({@see FsEventsFileMonitor}, {@see InotifyFileMonitor}) wrap it and let the
11+
* kernel answer "did anything change at all", so an idle poll touches no files;
12+
* once the kernel says yes, they delegate to the hashing monitor so the reported
13+
* result is identical either way.
14+
*
15+
* {@see FileMonitorFactory} picks the implementation for the current platform
16+
* and hands it back initialized.
17+
*/
18+
interface FileMonitor
1919
{
2020

21-
/** @var array<string, string>|null */
22-
private ?array $fileHashes = null;
23-
24-
/** @var array<string>|null */
25-
private ?array $filePaths = null;
26-
27-
/**
28-
* @param string[] $analysedPaths
29-
* @param string[] $analysedPathsFromConfig
30-
* @param string[] $scanFiles
31-
* @param string[] $scanDirectories
32-
*/
33-
public function __construct(
34-
#[AutowiredParameter(ref: '@fileFinderAnalyse')]
35-
private FileFinder $analyseFileFinder,
36-
#[AutowiredParameter(ref: '@fileFinderScan')]
37-
private FileFinder $scanFileFinder,
38-
#[AutowiredParameter]
39-
private array $analysedPaths,
40-
#[AutowiredParameter]
41-
private array $analysedPathsFromConfig,
42-
#[AutowiredParameter]
43-
private array $scanFiles,
44-
#[AutowiredParameter]
45-
private array $scanDirectories,
46-
private DirectoryWalker $directoryWalker,
47-
)
48-
{
49-
}
21+
public function getChanges(): FileMonitorResult;
5022

5123
/**
52-
* @param array<string> $filePaths
24+
* How often the caller should poll. A monitor whose idle poll is free can
25+
* afford a much shorter interval, which is what makes an edit noticed sooner.
5326
*/
54-
public function initialize(array $filePaths): void
55-
{
56-
$finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths);
57-
$fileHashes = [];
58-
foreach (array_unique(array_merge($finderResult->getFiles(), $filePaths, $this->getScannedFiles($finderResult->getFiles()))) as $filePath) {
59-
$fileHashes[$filePath] = $this->getFileHash($filePath);
60-
}
61-
62-
$this->fileHashes = $fileHashes;
63-
$this->filePaths = $filePaths;
64-
}
65-
66-
public function getChanges(): FileMonitorResult
67-
{
68-
if ($this->fileHashes === null || $this->filePaths === null) {
69-
throw new ShouldNotHappenException();
70-
}
71-
72-
// Detecting a change means looking at the filesystem as it is now, so the walks shared
73-
// with the analysis file discovery must not be reused here.
74-
$this->directoryWalker->clearCachedWalks();
75-
$finderResult = $this->analyseFileFinder->findFiles($this->analysedPaths);
76-
$oldFileHashes = $this->fileHashes;
77-
$fileHashes = [];
78-
$newFiles = [];
79-
$changedFiles = [];
80-
$deletedFiles = [];
81-
$filePaths = array_unique(array_merge($finderResult->getFiles(), $this->filePaths, $this->getScannedFiles($finderResult->getFiles())));
82-
foreach ($filePaths as $filePath) {
83-
if (!array_key_exists($filePath, $oldFileHashes)) {
84-
$newFiles[] = $filePath;
85-
$fileHashes[$filePath] = $this->getFileHash($filePath);
86-
continue;
87-
}
88-
89-
$oldHash = $oldFileHashes[$filePath];
90-
unset($oldFileHashes[$filePath]);
91-
$newHash = $this->getFileHash($filePath);
92-
$fileHashes[$filePath] = $newHash;
93-
if ($oldHash === $newHash) {
94-
continue;
95-
}
96-
97-
$changedFiles[] = $filePath;
98-
}
99-
100-
$this->fileHashes = $fileHashes;
101-
102-
foreach (array_keys($oldFileHashes) as $file) {
103-
$deletedFiles[] = $file;
104-
}
105-
106-
return new FileMonitorResult(
107-
$newFiles,
108-
$changedFiles,
109-
$deletedFiles,
110-
);
111-
}
112-
113-
private function getFileHash(string $filePath): string
114-
{
115-
$hash = hash_file('sha256', $filePath);
116-
117-
if ($hash === false) {
118-
throw new CouldNotReadFileException($filePath);
119-
}
120-
121-
return $hash;
122-
}
123-
124-
/**
125-
* @param string[] $allAnalysedFiles
126-
* @return array<string>
127-
*/
128-
private function getScannedFiles(array $allAnalysedFiles): array
129-
{
130-
$scannedFiles = $this->scanFiles;
131-
$analysedDirectories = [];
132-
foreach (array_merge($this->analysedPaths, $this->analysedPathsFromConfig) as $analysedPath) {
133-
if (is_file($analysedPath)) {
134-
continue;
135-
}
136-
137-
if (!is_dir($analysedPath)) {
138-
continue;
139-
}
140-
141-
$analysedDirectories[] = $analysedPath;
142-
}
143-
144-
$directories = array_unique(array_merge($analysedDirectories, $this->scanDirectories));
145-
foreach ($this->scanFileFinder->findFiles($directories)->getFiles() as $file) {
146-
$scannedFiles[] = $file;
147-
}
148-
149-
return array_diff($scannedFiles, $allAnalysedFiles);
150-
}
27+
public function getPollInterval(): float;
15128

15229
}

‎src/File/FileMonitorFactory.php‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\File;
4+
5+
use PHPStan\DependencyInjection\AutowiredParameter;
6+
use PHPStan\DependencyInjection\AutowiredService;
7+
use function getenv;
8+
use const PHP_OS_FAMILY;
9+
10+
/**
11+
* Picks the {@see FileMonitor} for this platform and hands it back initialized.
12+
*
13+
* Whether a native monitor can actually run is only known once it has opened
14+
* its kernel handle and armed every watch, so the fallback decision belongs
15+
* here rather than at the call site: a native monitor that cannot promise to
16+
* see every change is discarded whole, and {@see HashingFileMonitor} - correct
17+
* everywhere, just slower - answers instead.
18+
*/
19+
#[AutowiredService]
20+
final class FileMonitorFactory
21+
{
22+
23+
/** Set to anything to force {@see HashingFileMonitor}. */
24+
private const DISABLE_ENV_VARIABLE = 'PHPSTAN_DISABLE_NATIVE_FILE_MONITOR';
25+
26+
/**
27+
* @param string[] $analysedPaths
28+
* @param string[] $analysedPathsFromConfig
29+
* @param string[] $scanDirectories
30+
*/
31+
public function __construct(
32+
private HashingFileMonitor $hashingFileMonitor,
33+
#[AutowiredParameter]
34+
private array $analysedPaths,
35+
#[AutowiredParameter]
36+
private array $analysedPathsFromConfig,
37+
#[AutowiredParameter]
38+
private array $scanDirectories,
39+
)
40+
{
41+
}
42+
43+
/**
44+
* @param array<string> $filePaths extra files to monitor besides the analysed and scanned ones
45+
*/
46+
public function create(array $filePaths): FileMonitor
47+
{
48+
$native = $this->createNative();
49+
if ($native !== null) {
50+
try {
51+
$native->initialize($filePaths);
52+
53+
return $native;
54+
} catch (FileMonitorNotSupportedException) {
55+
// fall through to hashing
56+
}
57+
}
58+
59+
$this->hashingFileMonitor->initialize($filePaths);
60+
61+
return $this->hashingFileMonitor;
62+
}
63+
64+
private function createNative(): ?NativeFileMonitor
65+
{
66+
// escape hatch: the native monitors depend on FFI and on kernel
67+
// facilities a container or a hardened host can take away in ways they
68+
// cannot detect
69+
if (getenv(self::DISABLE_ENV_VARIABLE) !== false) {
70+
return null;
71+
}
72+
73+
if (PHP_OS_FAMILY === 'Darwin') {
74+
return new FsEventsFileMonitor(
75+
$this->hashingFileMonitor,
76+
$this->analysedPaths,
77+
$this->analysedPathsFromConfig,
78+
$this->scanDirectories,
79+
);
80+
}
81+
82+
if (PHP_OS_FAMILY === 'Linux') {
83+
return new InotifyFileMonitor(
84+
$this->hashingFileMonitor,
85+
$this->analysedPaths,
86+
$this->analysedPathsFromConfig,
87+
$this->scanDirectories,
88+
);
89+
}
90+
91+
return null;
92+
}
93+
94+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\File;
4+
5+
use Exception;
6+
7+
/**
8+
* A native file monitor cannot run here - no FFI, the kernel refused a watch,
9+
* or the project has more directories than we are willing to watch.
10+
* {@see FileMonitorFactory} answers with {@see HashingFileMonitor} instead.
11+
*/
12+
final class FileMonitorNotSupportedException extends Exception
13+
{
14+
15+
}

0 commit comments

Comments
 (0)