Skip to content

Watch files through the OS instead of re-hashing them every second - #6377

Merged
ondrejmirtes merged 5 commits into
2.3.xfrom
native-file-monitors
Nov 4, 2026
Merged

ondrejmirtes merged 5 commits into
2.3.xfrom
native-file-monitors

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

PHPStan Pro's FileMonitor re-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

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.

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 HashingFileMonitor still decides which files that means, so the reported FileMonitorResult is 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:

idle poll detection (median)
macOS hashing 81.9 ms 1110 ms
macOS FSEvents 0.002 ms 143 ms
Linux hashing 34.2 ms 1065 ms
Linux inotify 0.002 ms 104 ms

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

  • kqueue was tried on macOS and rejected. 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. FSEvents reports it, watches subtrees recursively, and needs no extra extension.
  • A watch can register successfully and then never deliver. 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. Costs ~17 ms once, and turns that failure into a fallback.
  • Scattered monitored files (composer.lock, config files) are polled with 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:

environment native path
Homebrew macOS, Debian/Ubuntu php-cli yes
Alpine needs phpXX-ffi
official php:* Docker images no (no ext-ffi)
Windows no

ffi.enable=preload is not a blocker — the CLI SAPI is exempt. Every failure path (no FFI, watch limits, unwritable tree, a filesystem that never delivers) falls back to HashingFileMonitor, so no configuration is worse off than before. PHPSTAN_DISABLE_NATIVE_FILE_MONITOR forces 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.php runs 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.neon carries a path-scoped method.notFound ignore 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

@phpstan-bot

Copy link
Copy Markdown
Collaborator

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.

@staabm

staabm commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

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.

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 pie installer integration .. or something similar to make sure the extensions will be easily available/installable

@ondrejmirtes
ondrejmirtes force-pushed the native-file-monitors branch 3 times, most recently from 2887eb2 to 8a15b24 Compare October 31, 2026 14:27
ondrejmirtes and others added 2 commits November 4, 2026 06:52
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
@ondrejmirtes
ondrejmirtes marked this pull request as ready for review November 4, 2026 09:40
@phpstan-bot

Copy link
Copy Markdown
Collaborator

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
… 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
@ondrejmirtes
ondrejmirtes merged commit d7768b2 into 2.3.x Nov 4, 2026
1039 of 1043 checks passed
@ondrejmirtes
ondrejmirtes deleted the native-file-monitors branch November 4, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants