Skip to content
This repository was archived by the owner on Jun 14, 2026. It is now read-only.

Commit 964d654

Browse files
committed
perf: parallelize LSP deadcode analysis and add reference cache
- Parallelize accurate deadcode symbol checks with asyncio.gather + semaphore(15) - Add 5-min TTL reference cache to AuroraLSPClient to avoid re-querying same symbols - Cache empty results too (avoids repeated lookups for dead symbols) - clear_ref_cache() and auto-clear on close()
1 parent d5b1695 commit 964d654

3 files changed

Lines changed: 151 additions & 17 deletions

File tree

packages/lsp/src/aurora_lsp/analysis.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import asyncio
89
import json
910
import logging
1011
import subprocess
@@ -607,30 +608,31 @@ async def find_dead_code(
607608
dead = []
608609

609610
if accurate:
610-
# Phase 2 (accurate): LSP references for each symbol (95%+ accuracy, slower)
611+
# Phase 2 (accurate): LSP references for each symbol (95%+ accuracy)
612+
# Parallelized with semaphore to avoid overwhelming the language server
611613
logger.info(f"Accurate mode: checking {len(all_symbols)} symbols via LSP references")
612-
for sym in all_symbols:
613-
try:
614-
# Get usages via LSP (excludes imports automatically)
615-
result = await self.find_usages(
616-
sym["file"], sym["line"], sym.get("col", 10), include_imports=False
617-
)
618-
usage_count = result.get("total_usages", 0)
614+
semaphore = asyncio.Semaphore(15)
619615

620-
# Dead if 0 usages (definition itself is not counted as usage)
621-
if usage_count == 0:
622-
dead.append(
623-
{
616+
async def _check_symbol(sym: dict) -> dict | None:
617+
async with semaphore:
618+
try:
619+
result = await self.find_usages(
620+
sym["file"], sym["line"], sym.get("col", 10), include_imports=False
621+
)
622+
if result.get("total_usages", 0) == 0:
623+
return {
624624
"file": sym["file"],
625625
"line": sym["line"],
626626
"name": sym["name"],
627627
"kind": SymbolKind(sym["kind"]).name.lower(),
628628
"imports": result.get("total_imports", 0),
629629
}
630-
)
631-
except Exception as e:
632-
logger.debug(f"LSP reference check failed for {sym['name']}: {e}")
633-
continue
630+
except Exception as e:
631+
logger.debug(f"LSP reference check failed for {sym['name']}: {e}")
632+
return None
633+
634+
results = await asyncio.gather(*[_check_symbol(sym) for sym in all_symbols])
635+
dead = [r for r in results if r is not None]
634636
else:
635637
# Phase 2 (fast): ONE batched ripgrep call for ALL symbols (80-85% accuracy)
636638
symbol_names = list(set(s["name"] for s in all_symbols))

packages/lsp/src/aurora_lsp/client.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import asyncio
99
import logging
10+
import time
1011
from pathlib import Path
1112
from typing import Any
1213

@@ -60,6 +61,9 @@ def __init__(self, workspace: Path | str):
6061
self._lock = asyncio.Lock()
6162
self._logger: Any = None
6263
self._started = False
64+
# Reference cache: (rel_path, line, col) -> (timestamp, results)
65+
self._ref_cache: dict[tuple[str, int, int], tuple[float, list[dict]]] = {}
66+
self._ref_cache_ttl: float = 300.0 # 5 minutes
6367

6468
# Initialize language map if multilspy is available
6569
if MULTILSPY_AVAILABLE and Language is not None:
@@ -160,6 +164,9 @@ async def request_references(
160164
) -> list[dict]:
161165
"""Find all references to a symbol.
162166
167+
Results are cached by (file_path, line, col) with a 5-minute TTL so
168+
repeated deadcode runs or overlapping symbol checks reuse prior results.
169+
163170
Args:
164171
file_path: Path to file containing the symbol.
165172
line: Line number (0-indexed).
@@ -168,8 +175,20 @@ async def request_references(
168175
Returns:
169176
List of reference locations, each with 'file', 'line', 'col' keys.
170177
"""
171-
server = await self._ensure_server(file_path)
172178
rel_path = self._to_relative(file_path)
179+
cache_key = (rel_path, line, col)
180+
181+
# Check cache
182+
cached = self._ref_cache.get(cache_key)
183+
if cached is not None:
184+
ts, results = cached
185+
if (time.monotonic() - ts) < self._ref_cache_ttl:
186+
logger.debug(f"Reference cache hit for {rel_path}:{line}:{col}")
187+
return results
188+
# Expired — remove stale entry
189+
del self._ref_cache[cache_key]
190+
191+
server = await self._ensure_server(file_path)
173192
self._ensure_file_open(server, file_path)
174193

175194
try:
@@ -180,6 +199,8 @@ async def request_references(
180199
f"LSP returned 0 references for {rel_path}:{line}:{col} "
181200
f"(language server may not fully support this file type)"
182201
)
202+
# Cache the result (including empty results to avoid re-querying)
203+
self._ref_cache[cache_key] = (time.monotonic(), normalized)
183204
return normalized
184205
except Exception as e:
185206
logger.warning(f"request_references failed for {rel_path}:{line}:{col}: {e}")
@@ -273,6 +294,10 @@ async def request_diagnostics(
273294
logger.warning(f"request_diagnostics failed: {e}")
274295
return []
275296

297+
def clear_ref_cache(self) -> None:
298+
"""Clear the reference cache (e.g. after file modifications)."""
299+
self._ref_cache.clear()
300+
276301
async def close(self) -> None:
277302
"""Close all language server connections."""
278303
async with self._lock:
@@ -295,6 +320,7 @@ async def close(self) -> None:
295320
self._servers.clear()
296321
self._contexts.clear()
297322
self._open_files.clear()
323+
self._ref_cache.clear()
298324

299325
def _to_relative(self, file_path: str | Path) -> str:
300326
"""Convert absolute path to workspace-relative path."""

packages/lsp/tests/test_analysis_client.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,109 @@ def test_to_relative_outside_workspace(self, tmp_path):
323323
client = self._make_client(tmp_path)
324324
result = client._to_relative("/completely/different/path.py")
325325
assert result == "/completely/different/path.py"
326+
327+
328+
# ---------------------------------------------------------------------------
329+
# Reference cache
330+
# ---------------------------------------------------------------------------
331+
332+
333+
class TestReferenceCache:
334+
"""Tests for in-memory reference cache on AuroraLSPClient."""
335+
336+
def _make_client(self, tmp_path):
337+
client = AuroraLSPClient(workspace=tmp_path)
338+
return client
339+
340+
@pytest.mark.asyncio
341+
async def test_cache_hit_avoids_server_call(self, tmp_path):
342+
"""Second call with same args returns cached result without querying LSP."""
343+
import time
344+
345+
client = self._make_client(tmp_path)
346+
347+
fake_server = AsyncMock()
348+
fake_server.request_references = AsyncMock(
349+
return_value=[
350+
{
351+
"absolutePath": "/proj/foo.py",
352+
"range": {"start": {"line": 10, "character": 0}},
353+
}
354+
]
355+
)
356+
fake_server.open_file = MagicMock(return_value=MagicMock())
357+
358+
with patch.object(client, "_ensure_server", return_value=fake_server):
359+
result1 = await client.request_references("src/foo.py", 5, 0)
360+
result2 = await client.request_references("src/foo.py", 5, 0)
361+
362+
# Server queried only once — second call served from cache
363+
assert fake_server.request_references.call_count == 1
364+
assert result1 == result2
365+
assert len(result1) == 1
366+
367+
@pytest.mark.asyncio
368+
async def test_cache_miss_different_location(self, tmp_path):
369+
"""Different (file, line, col) triggers a fresh LSP query."""
370+
client = self._make_client(tmp_path)
371+
372+
fake_server = AsyncMock()
373+
fake_server.request_references = AsyncMock(return_value=[])
374+
fake_server.open_file = MagicMock(return_value=MagicMock())
375+
376+
with patch.object(client, "_ensure_server", return_value=fake_server):
377+
await client.request_references("src/a.py", 1, 0)
378+
await client.request_references("src/a.py", 2, 0)
379+
380+
assert fake_server.request_references.call_count == 2
381+
382+
@pytest.mark.asyncio
383+
async def test_cache_expiry(self, tmp_path, monkeypatch):
384+
"""Expired entries are evicted and LSP is re-queried."""
385+
import time as time_mod
386+
387+
client = self._make_client(tmp_path)
388+
client._ref_cache_ttl = 0.0 # expire immediately
389+
390+
fake_server = AsyncMock()
391+
fake_server.request_references = AsyncMock(return_value=[])
392+
fake_server.open_file = MagicMock(return_value=MagicMock())
393+
394+
with patch.object(client, "_ensure_server", return_value=fake_server):
395+
await client.request_references("src/a.py", 1, 0)
396+
# TTL=0 means next call should miss cache
397+
await client.request_references("src/a.py", 1, 0)
398+
399+
assert fake_server.request_references.call_count == 2
400+
401+
@pytest.mark.asyncio
402+
async def test_cache_empty_results(self, tmp_path):
403+
"""Empty results are cached too (avoids re-querying dead symbols)."""
404+
client = self._make_client(tmp_path)
405+
406+
fake_server = AsyncMock()
407+
fake_server.request_references = AsyncMock(return_value=[])
408+
fake_server.open_file = MagicMock(return_value=MagicMock())
409+
410+
with patch.object(client, "_ensure_server", return_value=fake_server):
411+
r1 = await client.request_references("src/a.py", 1, 0)
412+
r2 = await client.request_references("src/a.py", 1, 0)
413+
414+
assert r1 == r2 == []
415+
assert fake_server.request_references.call_count == 1
416+
417+
def test_clear_ref_cache(self, tmp_path):
418+
"""clear_ref_cache() empties the cache dict."""
419+
client = self._make_client(tmp_path)
420+
client._ref_cache[("src/a.py", 1, 0)] = (0.0, [])
421+
assert len(client._ref_cache) == 1
422+
client.clear_ref_cache()
423+
assert len(client._ref_cache) == 0
424+
425+
@pytest.mark.asyncio
426+
async def test_close_clears_cache(self, tmp_path):
427+
"""close() clears reference cache along with other state."""
428+
client = self._make_client(tmp_path)
429+
client._ref_cache[("src/a.py", 1, 0)] = (0.0, [{"file": "x", "line": 0, "col": 0}])
430+
await client.close()
431+
assert len(client._ref_cache) == 0

0 commit comments

Comments
 (0)