Watch files through the OS instead of re-hashing them every second - #6377
Watch files through the OS instead of re-hashing them every second#6377ondrejmirtes wants to merge 1 commit into
Conversation
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
|
You've opened the pull request against the latest branch 2.3.x. PHPStan 2.3 is not going to be released for months. If your code is relevant on 2.2.x and you want it to be released sooner, please rebase your pull request and change its target to 2.2.x. |
I really like this PR and the improvements we get out of it. I fear that most default-php installations will not ship with the necessary php-extensions and therefore its usefullness is limited. we might need a CLI PRO-startup-warning or message in the PRO-UI, which informs people how to get these extensions ... or maybe we can ship the extensions similar to turbo .. or a |
PHPStan Pro's
FileMonitorre-hashes every analysed and scanned file on every poll — 2772 files and ~230 ms 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.What changed
FileMonitorbecomes an interface. The old implementation isHashingFileMonitorand stays the answer for Windows and for anything the native backends cannot do.FsEventsFileMonitor(macOS) andInotifyFileMonitor(Linux) reach the kernel through FFI.The native monitors are a gate in front of the hashing one, not a replacement. The kernel is only asked "was anything touched"; the wrapped
HashingFileMonitorstill decides which files that means, so the reportedFileMonitorResultis exactly what it always was — exclude rules and the touch-without-content-change case stay in one place. An idle poll costs one non-blocking syscall instead of reading the project, which is what lets the poll interval drop from 1 s to 50 ms.Measurements
2760-file tree, hashing vs native:
End to end in Pro on a real project (edit → analysis starts): 0.749 s → 0.250 s, and full hash passes over a 40 s session went from 26 to 4 — one per edit, none while idle.
Design notes
EVFILT_VNODEon a directory reports directory-entry changes only; a file rewritten in place — which is what most editor saves are — produces no event at all. FSEvents reports it, watches subtrees recursively, and needs no extra extension.initialize()writes a probe file into a watched directory and refuses the backend unless the kernel reports it. Costs ~17 ms once, and turns that failure into a fallback.stat()rather than pulling their parent — otherwise the project root becomes a recursive watch and every result-cache write reopens the gate.Compatibility
PHP floor is unchanged (7.4); FFI has existed since 7.4. Verified in real images:
php-cliphpXX-ffiphp:*Docker imagesffi.enable=preloadis not a blocker — the CLI SAPI is exempt. Every failure path (no FFI, watch limits, unwritable tree, a filesystem that never delivers) falls back toHashingFileMonitor, so no configuration is worse off than before.PHPSTAN_DISABLE_NATIVE_FILE_MONITORforces the hashing monitor.Docker Desktop bind mounts were tested and do work; the 1–3 s there is VirtioFS propagation, which the hashing monitor pays identically.
Tests
tests/PHPStan/File/FileMonitorTest.phpruns the same 9 scenarios against every backend available on the host, asserting identical results — 18 tests / 198 assertions green on macOS (FSEvents) and in Docker on Linux (inotify).Open question for review
PHPStan cannot model FFI's methods (they are the C functions from the cdef string), so
build/phpstan.neoncarries a path-scopedmethod.notFoundignore for the two monitor classes. The proper fix would be an FFI methods-reflection extension — flagged rather than built, since it changes analysis for every FFI user.🤖 Generated with Claude Code