Skip to content

Commit 01a4b97

Browse files
committed
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)
1 parent 9c34e6f commit 01a4b97

2 files changed

Lines changed: 22 additions & 15 deletions

File tree

‎codewiki/mcp/server.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ async def list_tools() -> list[Tool]:
374374

375375
_analyze_lock = asyncio.Lock()
376376

377+
377378
@server.call_tool()
378379
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
379380
"""Route tool calls to the appropriate handler."""
@@ -384,9 +385,11 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
384385
if name == "analyze_repo":
385386
from codewiki.mcp.tools.analysis import handle_analyze_repo
386387

387-
# NOTE: Tree-sitter C extensions are not thread-safe, so we use
388-
# a lock to ensure only one analyze_repo runs at a time, but
389-
# offload to a thread to avoid blocking the stdio pump.
388+
# Offloaded to a thread so the long-running analysis does not
389+
# block the stdio pump. The lock serialises concurrent
390+
# analyze_repo calls: each analyzer owns its own Tree-sitter
391+
# Parser, but parsers are not safe to drive from several
392+
# threads at once and the job is heavy anyway.
390393
async with _analyze_lock:
391394
return [_text(await asyncio.to_thread(handle_analyze_repo, arguments, _store))]
392395

‎codewiki/src/be/dependency_analyzer/analysis/call_graph_analyzer.py‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import logging
1010
import re
1111
import signal
12+
import threading
1213
import time
1314
import traceback
1415
from collections import defaultdict
@@ -33,25 +34,28 @@ class TimeoutError(Exception):
3334

3435
@contextmanager
3536
def timeout(seconds):
36-
"""Context manager for timeout on file parsing."""
37+
"""Context manager for timeout on file parsing.
38+
39+
SIGALRM only exists on Unix and can only be installed from the main
40+
thread. Anywhere else (Windows, worker threads such as the MCP server's
41+
``asyncio.to_thread`` pool) the body runs without a timeout instead of
42+
failing -- previously ``signal.signal`` raised from a worker thread and
43+
the per-file ``except Exception`` silently skipped every file.
44+
"""
45+
if not hasattr(signal, "SIGALRM") or threading.current_thread() is not threading.main_thread():
46+
yield
47+
return
3748

3849
def signal_handler(signum, frame):
3950
raise TimeoutError(f"File parsing exceeded {seconds}s timeout")
4051

41-
# Only use signal on Unix systems (not Windows)
52+
old_handler = signal.signal(signal.SIGALRM, signal_handler)
53+
signal.alarm(seconds)
4254
try:
43-
old_handler = signal.signal(signal.SIGALRM, signal_handler)
44-
signal.alarm(seconds)
45-
yield
46-
except AttributeError:
47-
# Windows doesn't support SIGALRM, skip timeout
4855
yield
4956
finally:
50-
try:
51-
signal.alarm(0)
52-
signal.signal(signal.SIGALRM, old_handler)
53-
except (AttributeError, ValueError):
54-
pass
57+
signal.alarm(0)
58+
signal.signal(signal.SIGALRM, old_handler)
5559

5660

5761
class CallGraphAnalyzer:

0 commit comments

Comments
 (0)