Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions codewiki/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Expand Down
28 changes: 16 additions & 12 deletions codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import re
import signal
import threading
import time
import traceback
from collections import defaultdict
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions codewiki/src/be/dependency_analyzer/analysis/cloning.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def clone_repository(github_url: str) -> str:
"true",
],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
)
except Exception:
Expand All @@ -105,6 +106,7 @@ def clone_repository(github_url: str) -> str:
],
check=True,
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
timeout=300,
)
Expand All @@ -121,6 +123,7 @@ def clone_repository(github_url: str) -> str:
"true",
],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
)

Expand All @@ -144,6 +147,7 @@ def clone_repository(github_url: str) -> str:
"HEAD",
],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
)
except Exception:
Expand Down
2 changes: 2 additions & 0 deletions codewiki/src/be/dependency_analyzer/analysis/repo_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions codewiki/src/fe/github_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand Down
Loading