fix(watch): ignore read-only inotify events so the watcher stops re-triggering on its own reads

On Linux, inotify emits opened / closed_no_write events for every file open and close,
including the watcher's own AST rebuild reading the tree, so the watcher fed itself a
rebuild loop. Drop those two read-only event types; close-after-write and
create/modify/move/delete still trigger, and the filter is a no-op on the macOS/Windows
backends that never emit them.
This commit is contained in:
Azeem1985
2026-08-25 15:44:45 +01:00
committed by safishamsi
parent 282976b2f4
commit b022c3469c
2 changed files with 48 additions and 1 deletions
+19 -1
View File
@@ -1930,6 +1930,24 @@ def _batch_triggers_rebuild(batch: list[Path]) -> bool:
return has_code or has_deletion
_READ_ONLY_EVENT_TYPES = frozenset({"opened", "closed_no_write"})
def _is_read_only_event(event) -> bool:
"""True for watchdog events that mean a file was merely READ, not changed.
On Linux, inotify (watchdog >= 2.3) reports ``opened`` and, since watchdog 4,
``closed_no_write`` for every file open/close — including the watcher's own
AST rebuild reading the tree, hook guards stat-ing sources, and editors or
agents reading files. Counting those as changes makes the watcher re-trigger
itself forever ("N file(s) changed" while nothing was modified), burn CPU and
keep re-writing the ``needs_update`` flag. Only creation, modification, move,
deletion and close-after-write are changes; macOS (PollingObserver) never
emits these, so the filter is a no-op there.
"""
return getattr(event, "event_type", None) in _READ_ONLY_EVENT_TYPES
def _batch_needs_llm_flag(batch: list[Path]) -> bool:
"""True when the batch contains a non-code file that still exists on disk.
@@ -1977,7 +1995,7 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
nonlocal last_trigger, pending
if event.is_directory:
if event.is_directory or _is_read_only_event(event):
return
path = Path(os.fsdecode(event.src_path))
# Check .graphifyignore BEFORE the extension/dotfile/out filters so
+29
View File
@@ -3769,3 +3769,32 @@ def test_subfolder_marker_incremental_matches_cold_build(tmp_path, monkeypatch):
f"incremental vs cold id drift: only-incremental={sorted(incremental_ids - cold_ids)[:5]}, "
f"only-cold={sorted(cold_ids - incremental_ids)[:5]}"
)
# --- read-only inotify events must not count as changes (#watch-self-trigger) ---
def test_read_only_events_are_ignored():
"""``opened`` / ``closed_no_write`` mean a file was read, not changed."""
from graphify.watch import _is_read_only_event
class E:
def __init__(self, t):
self.event_type = t
assert _is_read_only_event(E("opened"))
assert _is_read_only_event(E("closed_no_write"))
for t in ("created", "modified", "deleted", "moved", "closed"):
assert not _is_read_only_event(E(t)), t
def test_read_only_events_with_real_watchdog_classes():
pytest.importorskip("watchdog.events")
from watchdog import events as we
from graphify.watch import _is_read_only_event
assert _is_read_only_event(we.FileOpenedEvent("/tmp/x.py"))
if hasattr(we, "FileClosedNoWriteEvent"):
assert _is_read_only_event(we.FileClosedNoWriteEvent("/tmp/x.py"))
assert not _is_read_only_event(we.FileModifiedEvent("/tmp/x.py"))
assert not _is_read_only_event(we.FileCreatedEvent("/tmp/x.py"))
assert not _is_read_only_event(we.FileClosedEvent("/tmp/x.py"))