From b022c3469c5783fbf44f1ef1bd9a4cdf87eadc29 Mon Sep 17 00:00:00 2001 From: Azeem1985 <90791197+Azeem1985@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:44:45 +0100 Subject: [PATCH] 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. --- graphify/watch.py | 20 +++++++++++++++++++- tests/test_watch.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index 8ad02c4d..f111871f 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 25d0cd96..075cc3f4 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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"))