Skip to content

Commit bda6f2a

Browse files
committed
merge: cheap staleness pre-check for the LSP server (perf, fail-closed preserved)
2 parents fced8f4 + 88ae734 commit bda6f2a

2 files changed

Lines changed: 74 additions & 2 deletions

File tree

src/index_graph/lsp/server.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ def _fingerprint(root: Path) -> str:
5050
return hashlib.sha256("\n".join(entries).encode("utf-8")).hexdigest()
5151

5252

53+
def _cheap_signature(root: Path) -> str:
54+
"""A cheap staleness pre-check: SHA-256 over the sorted
55+
(relative-path, mtime_ns, size) triples of the Python tree. This is
56+
stat-only (no file reads), so it is O(files) rather than the
57+
O(files * bytes) of [`_fingerprint`]. Any added or removed .py file moves
58+
the set of paths, and any write moves that file's mtime, so the signature
59+
moves; a stable tree keeps it identical. It is used only as a FAST PATH
60+
before the authoritative full-content fingerprint: a match means "nothing
61+
stat-visible changed, skip the full re-read"; a mismatch always falls back
62+
to [`_fingerprint`], which stays the source of truth. (The one thing a
63+
stat-only check cannot see is a content edit that preserves both size and
64+
mtime to the nanosecond, which a normal filesystem write never does.)"""
65+
root = Path(root).resolve()
66+
entries: list[str] = []
67+
for py in walk_files(root, suffixes=(".py",)):
68+
try:
69+
rel = py.relative_to(root).as_posix()
70+
except ValueError:
71+
rel = py.as_posix()
72+
try:
73+
st = py.stat()
74+
entries.append(f"{rel}:{st.st_mtime_ns}:{st.st_size}")
75+
except OSError:
76+
entries.append(f"{rel}:unstattable")
77+
entries.sort()
78+
return hashlib.sha256("\n".join(entries).encode("utf-8")).hexdigest()
79+
80+
5381
class LSPServer:
5482
"""A single-workspace language server over the wave-1 symbol graph."""
5583

@@ -58,6 +86,7 @@ def __init__(self, root: Path, trace: str = "off") -> None:
5886
self.trace = trace
5987
self.symbol_graph: SymbolGraph | None = None
6088
self.fingerprint: str | None = None
89+
self.cheap_sig: str | None = None
6190
self.should_exit = False
6291
self._shutdown = False
6392

@@ -67,12 +96,32 @@ def _build(self) -> None:
6796
"""Build (or rebuild) the symbol graph and pin the current fingerprint."""
6897
self.symbol_graph = build_symbol_graph(self.root)
6998
self.fingerprint = _fingerprint(self.root)
99+
self.cheap_sig = _cheap_signature(self.root)
70100

71101
def is_stale(self) -> bool:
72-
"""True when the Python tree changed on disk since the last build."""
102+
"""True when the Python tree changed on disk since the last build.
103+
104+
Fail-closed and fast in the common case: a cheap stat-only signature is
105+
checked first, and only when it moves does the authoritative full-content
106+
fingerprint run. IDEs issue definition/references on every hover and
107+
click, so the unchanged-tree path (the overwhelming majority) avoids
108+
re-reading and re-hashing every file.
109+
"""
73110
if self.fingerprint is None:
74111
return False
75-
return _fingerprint(self.root) != self.fingerprint
112+
cheap_now = _cheap_signature(self.root)
113+
if cheap_now == self.cheap_sig:
114+
return False # fast path: nothing stat-visible changed
115+
# The cheap signature moved; the full-content fingerprint is the
116+
# authority and decides staleness (fail-closed on a real change).
117+
if _fingerprint(self.root) != self.fingerprint:
118+
return True
119+
# mtime/size moved but content is byte-identical (e.g. a touch or a
120+
# rewrite with the same bytes): not stale. Refresh the cheap signature
121+
# so the next request takes the fast path instead of re-running the
122+
# full check every time.
123+
self.cheap_sig = cheap_now
124+
return False
76125

77126
# --- dispatch ------------------------------------------------------------
78127

tests/test_lsp_staleness.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,29 @@ def test_fingerprint_stable_when_nothing_changes(tmp_path):
2121
assert server.is_stale() is False # idempotent
2222

2323

24+
def test_unchanged_tree_does_not_full_reread_per_request(tmp_path, monkeypatch):
25+
# Perf guard: on an unchanged tree, the cheap stat-only pre-check must take
26+
# the fast path so the expensive full-content fingerprint is NOT recomputed
27+
# on every definition/references request (IDEs issue these constantly).
28+
write(tmp_path, "mod.py", "def foo():\n pass\ndef bar():\n foo()\n")
29+
server = LSPServer(root=tmp_path)
30+
_init(server) # the single build computes the full fingerprint exactly once
31+
import index_graph.lsp.server as srv
32+
real = srv._fingerprint
33+
calls = {"n": 0}
34+
35+
def counting(root):
36+
calls["n"] += 1
37+
return real(root)
38+
39+
monkeypatch.setattr(srv, "_fingerprint", counting)
40+
for _ in range(25):
41+
assert server.is_stale() is False
42+
assert calls["n"] == 0, (
43+
f"the full-content fingerprint was recomputed {calls['n']} times on an "
44+
"unchanged tree; the cheap pre-check should take the fast path")
45+
46+
2447
def test_definition_on_stale_workspace_errors(tmp_path):
2548
write(tmp_path, "mod.py", "def foo():\n pass\ndef bar():\n foo()\n")
2649
server = LSPServer(root=tmp_path)

0 commit comments

Comments
 (0)