From 7eee92239be551c042b3177b01ebab2643cd2f64 Mon Sep 17 00:00:00 2001 From: Louise Lu <150593366+louiseboo@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:12:33 +0800 Subject: [PATCH] fix: keep file search off the event loop --- raven/agent/tools/file_search.py | 10 +++++-- tests/test_search_tools.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/raven/agent/tools/file_search.py b/raven/agent/tools/file_search.py index ae2e441..14ed3b2 100644 --- a/raven/agent/tools/file_search.py +++ b/raven/agent/tools/file_search.py @@ -351,6 +351,7 @@ class FindTool(_FsTool): """Find files by glob pattern, sorted by recency. Pure-Python (pathlib).""" _DEFAULT_LIMIT = 1000 + timeout_seconds = 30.0 @property def name(self) -> str: @@ -413,11 +414,14 @@ async def execute( # recursively (fd-style), so 'foo.py' finds it at any depth. glob_expr = pattern if "/" in pattern else f"**/{pattern}" try: - matches = [ - p for p in base.glob(glob_expr) if not any(part in _IGNORE_DIRS for part in p.relative_to(base).parts) - ] + return await asyncio.to_thread(self._run_find, base, glob_expr, cap) except (ValueError, OSError) as e: return f"Error running find: {e}" + + def _run_find(self, base: Path, glob_expr: str, cap: int) -> str: + matches = [ + p for p in base.glob(glob_expr) if not any(part in _IGNORE_DIRS for part in p.relative_to(base).parts) + ] if not matches: return "No files found matching pattern." diff --git a/tests/test_search_tools.py b/tests/test_search_tools.py index beb784f..8a90469 100644 --- a/tests/test_search_tools.py +++ b/tests/test_search_tools.py @@ -6,12 +6,15 @@ from __future__ import annotations +import asyncio import shutil +import time from pathlib import Path import pytest from raven.agent.tools.file_search import FindTool, GrepTool +from raven.agent.tools.registry import ToolRegistry @pytest.fixture @@ -145,3 +148,48 @@ async def test_find_sorted_by_recency(tree: Path): out = await tool.execute(pattern="*.py") lines = out.splitlines() assert lines[0].endswith("util.py") + + +async def test_find_does_not_block_the_event_loop(tree: Path, monkeypatch): + original_glob = Path.glob + + def slow_glob(path: Path, pattern: str): + time.sleep(0.5) + return original_glob(path, pattern) + + monkeypatch.setattr(Path, "glob", slow_glob) + tool = FindTool(workspace=tree, allowed_dir=tree) + + started = time.monotonic() + search = asyncio.create_task(tool.execute(pattern="*.py")) + await asyncio.sleep(0.02) + event_loop_delay = time.monotonic() - started + result = await search + + assert event_loop_delay < 0.25 + assert "src/app.py" in result + + +async def test_find_registry_timeout_can_fire_during_slow_glob(tree: Path, monkeypatch): + original_glob = Path.glob + + def slow_glob(path: Path, pattern: str): + time.sleep(0.5) + return original_glob(path, pattern) + + monkeypatch.setattr(Path, "glob", slow_glob) + tool = FindTool(workspace=tree, allowed_dir=tree) + tool.timeout_seconds = 0.05 + registry = ToolRegistry() + registry.register(tool) + + started = time.monotonic() + result = await registry.execute("find", {"pattern": "*.py"}) + elapsed = time.monotonic() - started + + assert elapsed < 0.25 + assert "timed out after" in result + + +def test_find_declares_a_bounded_timeout(): + assert FindTool.timeout_seconds == 30.0