From 42922cd2bd8c070bcd50107955764b20c8f71e75 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 1 Jun 2026 07:46:35 -0400 Subject: [PATCH] Fix watch loop crash when file is deleted before stat() in _handle_watch_change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expression `str(path.stat().st_mtime_ns) if path.exists() else None` has a TOCTOU race: the file can be deleted between exists() and stat(). The resulting FileNotFoundError propagates up to _watch_loop's except handler, which records a breaker failure and terminates the loop — no more push events are processed until restart. Replace with a try/except around stat() and return early on FileNotFoundError. The deletion event from watchfiles will handle cleanup. --- haiku_rag_slim/haiku/rag/ingester/pollers/fs.py | 7 ++++++- tests/ingester/test_pollers.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/haiku_rag_slim/haiku/rag/ingester/pollers/fs.py b/haiku_rag_slim/haiku/rag/ingester/pollers/fs.py index d619e8e1..7e68f38a 100644 --- a/haiku_rag_slim/haiku/rag/ingester/pollers/fs.py +++ b/haiku_rag_slim/haiku/rag/ingester/pollers/fs.py @@ -131,7 +131,12 @@ class FSPoller(BasePoller): return if change in (Change.added, Change.modified): - revision = str(path.stat().st_mtime_ns) if path.exists() else None + try: + revision = str(path.stat().st_mtime_ns) + except FileNotFoundError: + # File was deleted between the watchfiles event and our + # stat() call. Skip — the deletion event will handle it. + return await self._jobs.enqueue( self.source_id, uri, diff --git a/tests/ingester/test_pollers.py b/tests/ingester/test_pollers.py index 3aa4b2b9..0ee158e7 100644 --- a/tests/ingester/test_pollers.py +++ b/tests/ingester/test_pollers.py @@ -484,6 +484,22 @@ async def test_watch_deleted_then_added_enqueues_upsert(tmp_path, jobs, sync): assert queued[0].op is JobOp.UPSERT +@pytest.mark.asyncio +async def test_watch_added_file_deleted_before_stat_does_not_crash(tmp_path, jobs, sync): + """If a file is deleted between the watchfiles event and the stat() + call, the handler should return silently instead of raising + FileNotFoundError and killing the watch loop.""" + from watchfiles import Change + + poller = _fs_poller(tmp_path, jobs, sync) + missing = tmp_path / "vanished.md" + # File doesn't exist — simulate Change.added arriving after deletion. + await poller._handle_watch_change(Change.added, missing) + + # No job should be enqueued, and no exception should have propagated. + assert await jobs.list_jobs(source_id="local") == [] + + # --- FSPoller end-to-end smoke ---