Fix watch loop crash when file is deleted before stat() in _handle_watch_change

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.
This commit is contained in:
Chris McDonough 2026-06-01 07:46:35 -04:00
parent d5e5733f67
commit 42922cd2bd
2 changed files with 22 additions and 1 deletions

View file

@ -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,

View file

@ -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 ---