Repository navigation
Watch files through the OS instead of re-hashing them every second - #6377
Conversation
|
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 |
2887eb2 to
8a15b24
Compare
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) <noreply@anthropic.com> 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
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
8a15b24 to
0e9ec2e
Compare
|
This pull request has been marked as ready for review. |
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
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
fec2f6c to
83233c3
Compare
… 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
83233c3 to
a915bdc
Compare
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