From 9c34e6f02e742b445f55c466e07e3f30ecf7359b Mon Sep 17 00:00:00 2001 From: huanceng Date: Mon, 21 Sep 2026 14:52:26 +0800 Subject: [PATCH 1/3] fix: unblock event loop for analyze_repo, detach git subprocess stdin on Windows - Run analyze_repo via asyncio.to_thread under an asyncio.Lock: keeps tree-sitter usage serialized without blocking the MCP stdio pump (aligns with the to_thread pattern used by all other sync handlers) - Add stdin=DEVNULL to GitIgnoreFilter's git calls so children never inherit the MCP JSON-RPC stdin pipe, which hangs on Windows ProactorEventLoop --- codewiki/mcp/server.py | 11 +++++++---- .../be/dependency_analyzer/analysis/repo_analyzer.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/codewiki/mcp/server.py b/codewiki/mcp/server.py index 2c0f404d..31b4e592 100644 --- a/codewiki/mcp/server.py +++ b/codewiki/mcp/server.py @@ -372,6 +372,8 @@ 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 +384,11 @@ 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))] + # NOTE: Tree-sitter C extensions are not thread-safe, so we use + # a lock to ensure only one analyze_repo runs at a time, but + # offload to a thread to avoid blocking the stdio pump. + 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/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( From 01a4b9794b64fb018761f566d98f1d3fb59e6788 Mon Sep 17 00:00:00 2001 From: anhnh2002 Date: Wed, 23 Sep 2026 09:17:55 +0700 Subject: [PATCH 2/3] fix: make per-file parse timeout thread-safe so analyze_repo works off the main thread Running analyze_repo via asyncio.to_thread exposed a latent bug in the SIGALRM-based timeout() helper: signal.signal() can only be called from the main thread, so from a worker thread it raised (ValueError, then UnboundLocalError in the finally block), which the per-file `except Exception` swallowed at DEBUG level. Every file was skipped and analyze_repo returned 0 components on macOS/Linux. Windows was unaffected because it already fell back through the AttributeError path. - timeout(): skip SIGALRM when it is unavailable or when not on the main thread; run the body without a timeout instead of failing - server.py: reword the lock comment and satisfy ruff format (blank line) --- codewiki/mcp/server.py | 9 ++++-- .../analysis/call_graph_analyzer.py | 28 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/codewiki/mcp/server.py b/codewiki/mcp/server.py index 31b4e592..bfcb810a 100644 --- a/codewiki/mcp/server.py +++ b/codewiki/mcp/server.py @@ -374,6 +374,7 @@ async def list_tools() -> list[Tool]: _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.""" @@ -384,9 +385,11 @@ 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 we use - # a lock to ensure only one analyze_repo runs at a time, but - # offload to a thread to avoid blocking the stdio pump. + # 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))] 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: From 68cde6c1dfbc64ad768ffd5bc79a7266f95b6d52 Mon Sep 17 00:00:00 2001 From: anhnh2002 Date: Wed, 23 Sep 2026 09:26:59 +0700 Subject: [PATCH 3/3] fix: detach stdin on remaining git subprocess calls Apply the same stdin=DEVNULL treatment to the git clone/config/checkout calls in cloning.py and github_processor.py so no git child process inherits the parent's stdin (the MCP JSON-RPC pipe when run as a server, or an interactive terminal where git could otherwise block on a prompt). --- codewiki/src/be/dependency_analyzer/analysis/cloning.py | 4 ++++ codewiki/src/fe/github_processor.py | 3 +++ 2 files changed, 7 insertions(+) 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/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, )