diff --git a/haiku_rag_slim/haiku/rag/ingester/sources/fs.py b/haiku_rag_slim/haiku/rag/ingester/sources/fs.py index 8b944e04..8de87469 100644 --- a/haiku_rag_slim/haiku/rag/ingester/sources/fs.py +++ b/haiku_rag_slim/haiku/rag/ingester/sources/fs.py @@ -139,7 +139,12 @@ class FSSource: if not self.filter.include_file(str(path)): continue uri = path.as_uri() - revision = str(path.stat().st_mtime_ns) + try: + revision = str(path.stat().st_mtime_ns) + except FileNotFoundError: + # File was deleted between the os.walk() and stat() call. + # Skip it — the next sweep (or watchfiles) will emit DELETE. + continue seen.add(uri) previous = snapshot.get(uri) kind = ( diff --git a/tests/ingester/test_fs_source.py b/tests/ingester/test_fs_source.py index df32304f..c827bf0e 100644 --- a/tests/ingester/test_fs_source.py +++ b/tests/ingester/test_fs_source.py @@ -131,6 +131,25 @@ async def test_fs_source_discover_respects_extension_filter(fs_root: Path): assert (fs_root / "b.txt").as_uri() not in uris +@pytest.mark.asyncio +async def test_fs_source_discover_skips_file_deleted_during_stat(fs_root: Path): + """A file deleted between os.walk() and stat() should be silently + skipped instead of crashing the entire discover() sweep.""" + src = FSSource(root=fs_root, supported_extensions=[".md", ".txt"]) + events = [] + async for event in src.discover(since=None): + events.append(event) + # Delete a file mid-iteration so the next stat() hits a missing file. + victim = fs_root / "b.txt" + if victim.exists(): + victim.unlink() + uris = {e.uri for e in events} + # a.md and sub/c.md should still appear; b.txt may or may not depending + # on iteration order, but the key assertion is no exception was raised. + assert (fs_root / "a.md").as_uri() in uris + assert (fs_root / "sub" / "c.md").as_uri() in uris + + @pytest.mark.asyncio async def test_fs_source_discover_respects_ignore_patterns(fs_root: Path): src = FSSource(