mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user