diff --git a/codewiki/mcp/server.py b/codewiki/mcp/server.py index 2c0f404d..bfcb810a 100644 --- a/codewiki/mcp/server.py +++ b/codewiki/mcp/server.py @@ -372,6 +372,9 @@ async def list_tools() -> list[Tool]: return _fine_grained_tools() + _legacy_tools() +_analyze_lock = asyncio.Lock() + + @server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: """Route tool calls to the appropriate handler.""" @@ -382,10 +385,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: if name == "analyze_repo": from codewiki.mcp.tools.analysis import handle_analyze_repo - # NOTE: Tree-sitter C extensions are not thread-safe, so this - # must run on the main thread (blocking the event loop is - # acceptable for this one-time heavy operation). - return [_text(handle_analyze_repo(arguments, _store))] + # Offloaded to a thread so the long-running analysis does not + # block the stdio pump. The lock serialises concurrent + # analyze_repo calls: each analyzer owns its own Tree-sitter + # Parser, but parsers are not safe to drive from several + # threads at once and the job is heavy anyway. + async with _analyze_lock: + return [_text(await asyncio.to_thread(handle_analyze_repo, arguments, _store))] elif name == "read_code_components": from codewiki.mcp.tools.code_reader import handle_read_code_components diff --git a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py index 064438fa..4cfb3df2 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py +++ b/codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py @@ -9,6 +9,7 @@ import logging import re import signal +import threading import time import traceback from collections import defaultdict @@ -33,25 +34,28 @@ class TimeoutError(Exception): @contextmanager def timeout(seconds): - """Context manager for timeout on file parsing.""" + """Context manager for timeout on file parsing. + + SIGALRM only exists on Unix and can only be installed from the main + thread. Anywhere else (Windows, worker threads such as the MCP server's + ``asyncio.to_thread`` pool) the body runs without a timeout instead of + failing -- previously ``signal.signal`` raised from a worker thread and + the per-file ``except Exception`` silently skipped every file. + """ + if not hasattr(signal, "SIGALRM") or threading.current_thread() is not threading.main_thread(): + yield + return def signal_handler(signum, frame): raise TimeoutError(f"File parsing exceeded {seconds}s timeout") - # Only use signal on Unix systems (not Windows) + old_handler = signal.signal(signal.SIGALRM, signal_handler) + signal.alarm(seconds) try: - old_handler = signal.signal(signal.SIGALRM, signal_handler) - signal.alarm(seconds) - yield - except AttributeError: - # Windows doesn't support SIGALRM, skip timeout yield finally: - try: - signal.alarm(0) - signal.signal(signal.SIGALRM, old_handler) - except (AttributeError, ValueError): - pass + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) class CallGraphAnalyzer: diff --git a/codewiki/src/be/dependency_analyzer/analysis/cloning.py b/codewiki/src/be/dependency_analyzer/analysis/cloning.py index 0ec699ba..af4d838d 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/cloning.py +++ b/codewiki/src/be/dependency_analyzer/analysis/cloning.py @@ -88,6 +88,7 @@ def clone_repository(github_url: str) -> str: "true", ], capture_output=True, + stdin=subprocess.DEVNULL, text=True, ) except Exception: @@ -105,6 +106,7 @@ def clone_repository(github_url: str) -> str: ], check=True, capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=300, ) @@ -121,6 +123,7 @@ def clone_repository(github_url: str) -> str: "true", ], capture_output=True, + stdin=subprocess.DEVNULL, text=True, ) @@ -144,6 +147,7 @@ def clone_repository(github_url: str) -> str: "HEAD", ], capture_output=True, + stdin=subprocess.DEVNULL, text=True, ) except Exception: diff --git a/codewiki/src/be/dependency_analyzer/analysis/repo_analyzer.py b/codewiki/src/be/dependency_analyzer/analysis/repo_analyzer.py index 6731e666..67b6cfe3 100644 --- a/codewiki/src/be/dependency_analyzer/analysis/repo_analyzer.py +++ b/codewiki/src/be/dependency_analyzer/analysis/repo_analyzer.py @@ -54,6 +54,7 @@ def _load_git_ignored_paths(self) -> bool: capture_output=True, text=True, timeout=10, + stdin=subprocess.DEVNULL, ) if root_result.returncode != 0: logger.debug( @@ -82,6 +83,7 @@ def _load_git_ignored_paths(self) -> bool: check=True, capture_output=True, timeout=30, + stdin=subprocess.DEVNULL, ) except (OSError, ValueError, subprocess.SubprocessError) as exc: logger.warning( diff --git a/codewiki/src/fe/github_processor.py b/codewiki/src/fe/github_processor.py index 71d108cf..602d0ee1 100644 --- a/codewiki/src/fe/github_processor.py +++ b/codewiki/src/fe/github_processor.py @@ -64,6 +64,7 @@ def clone_repository(clone_url: str, target_dir: str, commit_id: str = None) -> result = subprocess.run( ["git", "clone", clone_url, target_dir], capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=WebAppConfig.CLONE_TIMEOUT, ) @@ -77,6 +78,7 @@ def clone_repository(clone_url: str, target_dir: str, commit_id: str = None) -> ["git", "checkout", commit_id], cwd=target_dir, capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=30, ) @@ -96,6 +98,7 @@ def clone_repository(clone_url: str, target_dir: str, commit_id: str = None) -> target_dir, ], capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=WebAppConfig.CLONE_TIMEOUT, )