fix(sdk): grep crashing when files vanish mid-walk (#3592)

`FilesystemBackend.grep`'s Python fallback walked the tree via
`Path.rglob("*")` but only guarded per-file operations inside the loop.
If a directory entry was unlinked or renamed *during* enumeration
(parallel build, editor save-via-rename, concurrent tool call),
`FileNotFoundError` escaped from `os.scandir` and failed the entire grep
invocation. This mirrors the partial-result pattern already used by the
sibling `glob()` method, so callers see whatever was found before the
abort plus a clear warning.
This commit is contained in:
Mason Daugherty
2026-05-26 13:38:42 -04:00
committed by GitHub
parent 73402e8173
commit 0b8301b206
2 changed files with 117 additions and 35 deletions
@@ -545,15 +545,16 @@ class FilesystemBackend(BackendProtocol):
# Try ripgrep first (with -F flag for literal search)
results = self._ripgrep_search(pattern, base_full, glob)
partial_error: str | None = None
if results is None:
# Python fallback needs escaped pattern for literal search
results = self._python_search(re.escape(pattern), base_full, glob)
results, partial_error = self._python_search(re.escape(pattern), base_full, glob)
matches: list[GrepMatch] = []
for fpath, items in results.items():
for line_num, line_text in items:
matches.append({"path": fpath, "line": int(line_num), "text": line_text})
return GrepResult(matches=matches)
return GrepResult(error=partial_error, matches=matches)
def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]] | None: # noqa: C901, PLR0912 # C901: split except clauses for per-clause logging; PLR0912: dir/file cwd branch + containment check
"""Search using ripgrep with fixed-string (literal) mode.
@@ -649,7 +650,7 @@ class FilesystemBackend(BackendProtocol):
return results
def _python_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]]: # noqa: C901, PLR0912
def _python_search(self, pattern: str, base_full: Path, include_glob: str | None) -> tuple[dict[str, list[tuple[int, str]]], str | None]: # noqa: C901, PLR0912
"""Fallback search using Python when ripgrep is unavailable.
Recursively searches files, respecting `max_file_size_bytes` limit.
@@ -660,7 +661,12 @@ class FilesystemBackend(BackendProtocol):
include_glob: Optional glob pattern to filter files by name.
Returns:
Dict mapping file paths to list of `(line_number, line_text)` tuples.
`results` contains every match found before iteration completed.
`partial_error` is `None` on a clean walk, otherwise a
human-readable message indicating the walk aborted early
(e.g., a directory entry was removed mid-walk); callers
should treat such results as incomplete.
"""
# Compile escaped pattern once for efficiency (used in loop)
regex = re.compile(pattern)
@@ -668,41 +674,52 @@ class FilesystemBackend(BackendProtocol):
results: dict[str, list[tuple[int, str]]] = {}
root = base_full if base_full.is_dir() else base_full.parent
for fp in root.rglob("*"):
try:
if not fp.is_file():
try:
for fp in root.rglob("*"):
try:
if not fp.is_file():
continue
except (PermissionError, OSError, RuntimeError):
continue
except (PermissionError, OSError, RuntimeError):
continue
if include_glob:
rel_path = str(fp.relative_to(root))
if not wcglob.globmatch(rel_path, include_glob, flags=wcglob.BRACE | wcglob.GLOBSTAR):
if include_glob:
rel_path = str(fp.relative_to(root))
if not wcglob.globmatch(rel_path, include_glob, flags=wcglob.BRACE | wcglob.GLOBSTAR):
continue
try:
if fp.stat().st_size > self.max_file_size_bytes:
continue
except (OSError, RuntimeError):
continue
try:
if fp.stat().st_size > self.max_file_size_bytes:
try:
content = fp.read_text()
except (UnicodeDecodeError, PermissionError, OSError, RuntimeError):
continue
except (OSError, RuntimeError):
continue
try:
content = fp.read_text()
except (UnicodeDecodeError, PermissionError, OSError, RuntimeError):
continue
for line_num, line in enumerate(content.splitlines(), 1):
if regex.search(line):
if self.virtual_mode:
try:
virt_path = self._to_virtual_path(fp)
except ValueError:
logger.debug("Skipping grep result outside root: %s", fp)
continue
except (OSError, RuntimeError):
logger.warning("Could not resolve grep result path: %s", fp, exc_info=True)
continue
else:
virt_path = str(fp)
results.setdefault(virt_path, []).append((line_num, line))
for line_num, line in enumerate(content.splitlines(), 1):
if regex.search(line):
if self.virtual_mode:
try:
virt_path = self._to_virtual_path(fp)
except ValueError:
logger.debug("Skipping grep result outside root: %s", fp)
continue
except (OSError, RuntimeError):
logger.warning("Could not resolve grep result path: %s", fp, exc_info=True)
continue
else:
virt_path = str(fp)
results.setdefault(virt_path, []).append((line_num, line))
except (OSError, RuntimeError) as e:
# `rglob` raised mid-iteration. `OSError` covers the common case
# where a directory entry is unlinked or renamed during the walk
# (the original `FileNotFoundError` report). `RuntimeError` covers
# symlink-loop detection on older Python versions. Return the
# matches already accumulated and surface the abort so callers
# don't treat the result as complete.
msg = f"Grep of '{base_full}' aborted after {len(results)} matching file(s): {e}"
logger.warning("%s", msg, exc_info=True)
return results, msg
return results
return results, None
def glob(self, pattern: str, path: str = "/") -> GlobResult: # noqa: C901, PLR0912 # Complex virtual_mode logic
"""Find files matching a glob pattern.
@@ -699,6 +699,71 @@ def test_grep_containment_check_blocks_escaping_symlink(tmp_path: Path, monkeypa
assert not any("secret" in p for p in matched), f"containment check failed — escaping symlink leaked result: {matched}"
def _install_flaky_rglob(monkeypatch: pytest.MonkeyPatch, exc: Exception, after_yields: int = 1) -> None:
"""Replace `Path.rglob` with a generator that yields N entries then raises."""
real_rglob = Path.rglob
def flaky_rglob(self: Path, pattern: str):
for idx, entry in enumerate(sorted(real_rglob(self, pattern)), start=1):
yield entry
if idx >= after_yields:
raise exc
monkeypatch.setattr(Path, "rglob", flaky_rglob)
@pytest.mark.parametrize("virtual_mode", [False, True])
def test_grep_python_fallback_survives_mid_iteration_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, virtual_mode: bool) -> None:
"""Python grep fallback returns accumulated matches when `rglob` aborts mid-walk.
`Path.rglob` can raise `FileNotFoundError` (or other `OSError` subclasses)
when a directory entry is unlinked or renamed while the walk is in
progress. The fallback must surface a partial result rather than letting
the exception escape and fail the whole tool invocation.
"""
root = tmp_path / "project"
root.mkdir()
(root / "first.txt").write_text("hello world\n")
(root / "second.txt").write_text("hello world\n")
be = FilesystemBackend(root_dir=str(root), virtual_mode=virtual_mode)
monkeypatch.setattr(be, "_ripgrep_search", lambda *_a, **_k: None)
_install_flaky_rglob(monkeypatch, FileNotFoundError("simulated mid-walk unlink"))
grep_path = "/" if virtual_mode else str(root)
result = be.grep("hello", path=grep_path)
assert result.matches is not None
assert result.error is not None
assert "aborted" in result.error
assert str(root) in result.error if not virtual_mode else True
matched_paths = {m["path"] for m in result.matches}
if virtual_mode:
assert "/first.txt" in matched_paths
assert "/second.txt" not in matched_paths
else:
assert any(p.endswith("first.txt") for p in matched_paths)
assert not any(p.endswith("second.txt") for p in matched_paths)
def test_grep_python_fallback_survives_runtime_error_mid_walk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`RuntimeError` from `rglob` (e.g. symlink-loop detection) is also recoverable."""
root = tmp_path
(root / "a.txt").write_text("hello\n")
(root / "b.txt").write_text("hello\n")
be = FilesystemBackend(root_dir=str(root), virtual_mode=False)
monkeypatch.setattr(be, "_ripgrep_search", lambda *_a, **_k: None)
_install_flaky_rglob(monkeypatch, RuntimeError("symlink loop"))
result = be.grep("hello", path=str(root))
assert result.error is not None
assert "symlink loop" in result.error
assert result.matches
class TestToVirtualPath:
"""Tests for FilesystemBackend._to_virtual_path."""