LSP tool returns silently incomplete results (cold-index race + stale file state)
Summary
The built-in LSP tool can return silently incomplete symbol results. There are two distinct causes, and they need different fixes:
- Cold-index race — the first query in a session is sent before the language server has finished its initial workspace index, so it returns a truncated result. Re-querying a moment later returns the full set.
- Stale file state after external changes — the tool forwards document state for files it edits through
WriteandEdit, but sends nothing when a file changes any other way (aBashcommand,git checkout,sed -i, a formatter, a code generator). Those files stay invisible tofindReferencesfor the rest of the session. This one never self-corrects.
Both produce the same user-visible symptom: a plausible-looking answer that's missing whole files, with nothing to indicate it's incomplete.
Environment
- Claude Code 2.1.219 / 2.1.220, macOS (darwin/arm64), Node 24.18
- Language servers configured via a plugin
lspServersentry:basedpyright-langserver --stdio,vtsls --stdio, andty0.0.63 (uvx ty@latest server)
Server-side measurements below come from a zero-dependency LSP stdio client driving the servers directly, so they're independent of the agent. Client-side measurements are run through Claude Code against a warm server, so indexing isn't a factor.
1. Cold-index race
Reproduction
- Configure basedpyright and/or vtsls through a plugin's
lspServers. - In a fresh session, target a workspace where a symbol (
hub) has 180 call sites across 60 files. - As the first LSP action, run
LSPfindReferences(orincomingCalls) onhub. - Cold result: 1 reference (the declaration only).
- Re-run the identical query: 241 references across 61 files.
Protocol-level measurement
| Settle after server start | Result |
|---|---|
| 0 ms | n=1 |
| ≥500 ms | 241, stable thereafter |
For basedpyright and vtsls the behavior is binary — either n=1 or complete, no intermediate counts — and basedpyright's diagnosticMode: "workspace" has no effect on it.
@blightbow reports a genuine partial on ty 0.0.63: a cold findReferences returned the declaration plus 2 real references in one file while omitting 2 in a second file and 8 in a third, where the warm result was 13 across 4 files. So the truncation shape is server-dependent, and a cold result can be large enough to read as a real answer rather than an obvious failure.
Notably, the file holding 8 of those 13 references was never edited during the session, which rules out cause 2 for that case and leaves the cold-index race as the explanation. Sweeping settle times against ty with the standalone client did not reproduce it, so the trigger conditions for this race are not yet fully characterized.
Cause: the tool issues the query without waiting for the server to signal that its initial workspace index is complete — there's no wait on $/progress / workDoneProgress end or an equivalent ready signal.
2. Stale file state after external changes
Language servers that delegate file watching to the client only learn about on-disk changes when the client sends workspace/didChangeWatchedFiles. ty is one of these: it registers for that notification at init and then relies entirely on receiving it.
Server-side: if the notification never arrives, the change is invisible indefinitely. Starting from a workspace with 4 references, editing a file on disk to add 4 more, and sending no notification:
baseline n=4 {a.py: 3, core.py: 1}
edit b.py on disk (+4 refs), no notification sent
after 0.05s / 0.5s / 2s / 5s n=4 {a.py: 3, core.py: 1}
send workspace/didChangeWatchedFiles
n=8 {a.py: 3, b.py: 4, core.py: 1}
The result is stable and wrong until the server is told. Settle time is irrelevant.
Client-side (measured by @blightbow), same symbol, warm server, baseline 13 references across 4 files:
| Step | How the file changed | Result |
|---|---|---|
| baseline | | 13 |
| new file, +3 refs, written with cat > f <<'EOF' in Bash | external | 13 — invisible |
| new file, +3 refs, written with the Write tool | tool | 16 — Write file visible, Bash file still invisible |
| touch the Bash file with Edit, docstring only, no reference changed | tool | 19 — Bash file now visible |
The Bash file was created first and still didn't appear when the Write file did, so this isn't a timing artifact; and the step that revealed it changed zero references, so the notification is the only variable.
This isolates the gap precisely: document state is forwarded for Write/Edit, and not for anything else. Agents shell out constantly — git checkout, sed -i, formatters, code generators, dependency syncs all leave the server stale, and the agent's own file tools will never repair a file they don't happen to touch.
Impact
An agent that trusts the result acts on incomplete reference, call-hierarchy, or impact data. The failure is silent: a truncated result is indistinguishable from a complete one. The realistic consequence is a wrong conclusion that a symbol has no remaining callers, and a refactor or deletion based on it.
The only mitigation today is behavioral — re-query when a result looks thin — which depends on the agent noticing, and doesn't work at all for cause 2, where re-querying returns the same wrong answer.
Proposed fix
For the cold-index race, either:
- await the server's initial workspace-index signal (
$/progressworkDoneProgress end, or a server-specific ready notification) before returning the first result; or - apply a bounded retry-until-stable: re-issue the query once if the first result arrives within N ms of server start and the count changed.
For stale file state, send workspace/didChangeWatchedFiles for files changed outside Write/Edit. The in-tool half already works; the gap is external mutation. Cheapest version is probably to emit the notification after any Bash invocation that touched the workspace, rather than running a real file watcher. Retry logic doesn't cover this case.
Optionally, expose a per-server settle / waitForIndex key in lspServers so users can opt in explicitly.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Confirming this with a different language server, plus one detail that refines the "binary" characterization.
Environment: Claude Code 2.1.220, macOS (darwin/arm64). Language server is Astral's
ty0.0.63, configured through theastral@astral-shplugin'slspServersentry (uvx ty@latest server).Repro matched. As the first LSP action in a session,
findReferenceson a Python function returned 3 references across 2 files. The identical query, same file and position, returned 13 across 4 files a few minutes later. My second query happened to originate from a different file, so I first suspected query origin was the variable; re-running the original query once the server was warm ruled that out.The refinement: this issue reports the behavior as binary, either n=1 or complete, for basedpyright and vtsls. With
tythe cold result was a genuine partial. It returned the declaration plus 2 real references in one file, and omitted 2 references in a second file and 8 in a third. So partial counts do occur, and the shape looks server dependent.That matters for the stated impact. A cold result of 1 reads as obviously broken and invites a re-query. A result of 3 reads as an answer. In my case the omitted file was the module the function had just been moved out of, so acting on the cold result would have supported the wrong conclusion that its original caller no longer used it.
The proposed bounded retry-until-stable would cover the partial case as well as the n=1 one.
Thanks for the
tydata point — partial vs. binary is a useful distinction, and the fix needs to cover both shapes.Chasing your "the function had just been moved out of that module" detail turned up a second mechanism I think is worth separating from the cold-index race. Sweeping settle times against
ty0.0.63 didn't move the numbers for me, which is partly why I went looking elsewhere.tydelegates file watching to the client: it registersworkspace/didChangeWatchedFilesat init and then depends entirely on being told what changed on disk. If those notifications don't arrive, it never finds out. Standalone stdio client, agent out of the picture:Whole files silently missing, and unlike the cold-index case it never resolves on its own. That's the part that concerns me most, because bounded retry-until-stable doesn't help here — the retry returns the same answer.
Does that match your workspace? Specifically: had the omitted files been edited in-session before the query, and did anything read or open them between your two runs? If so we're looking at stale file state rather than an unfinished index, and the tool needs to forward watched-file events (or
didOpen/didChangefor files it edits itself).I've rewritten the issue body to cover both failure modes.
Answering your two questions directly, then a client-side test that I think narrows the fix.
Were the omitted files edited in-session? Split, and the split is the interesting part. Two files were missing from my cold result:
parkour_mcp/github.py(2 refs): yes, edited earlier in the session. Consistent with stale file state.tests/test_github.py(8 refs): never touched, zero commits against it for the whole session. It held 8 of the 13 references.Did anything read or open them between the two runs? No. Between the cold query and the warm one I ran a shell
grepand a network call, neither of which goes through a file-reading tool.So a file nobody edited was also missing, which stale file state does not explain. My original observation still looks like the cold-index race, which seems worth keeping distinct given your settle sweeps did not reproduce that on
ty.Client-side test of mechanism 2. Your standalone client isolates the server; this isolates the Claude Code client. Same symbol, warm server, baseline 13 references across 4 files:
| step | how the file changed | result |
|---|---|---|
| baseline | | 13 |
| new file, +3 refs, written with
cat > f <<'EOF'in Bash | external | 13, invisible || new file, +3 refs, written with the Write tool | tool | 16, Write file visible, Bash file still invisible |
| touch the Bash file with the Edit tool, docstring only, no reference changed | tool | 19, Bash file now visible |
Two things make this clean. The Bash file was created first and still did not appear when the Write file did, so it is not a timing artifact. And the step that revealed it changed zero references, so the notification is the only variable.
Conclusion: Claude Code already sends file state for files it edits through Write and Edit. It sends nothing for files changed by Bash. So the "
didOpen/didChangefor files it edits itself" half of the proposed fix looks already implemented, and the missing piece isworkspace/didChangeWatchedFilesfor external changes.That gap is worse for an agent than for an editor, and for a reason beyond the agent editing its own files: agents shell out constantly. A
git checkout,sed -i, code generator, formatter, or dependency sync all leave the server stale, and the agent's own file tools will never repair a file they do not happen to touch.That's a much better isolation than mine — the ordering control (Bash file created first, still invisible when the Write file appeared) and the zero-reference
Edittouch between them leave basically nothing else to attribute it to.Two corrections on my side. The
didOpen/didChangehalf of my proposed fix is already implemented, so the gap is narrower than I wrote it: external mutation only. And your untouchedtests/test_github.pyholding 8 of the 13 settles the other question — stale file state can't explain a file nobody edited, so your original report is the cold-index race and stands on its own. My settle sweeps not reproducing it ontymeans the trigger conditions aren't characterized yet, not that it isn't there.The point about agents shelling out is the one I'd want a maintainer to read. It isn't just self-inflicted edits:
git checkout,sed -i, formatters, codegen, dependency syncs all leave the server stale, and Write/Edit will only ever repair a file they happen to touch next. In a long session the stale set grows monotonically, and nothing in the result shape reveals it.Given that, the cheap fix might not need a real file watcher — emitting
workspace/didChangeWatchedFilesafter any Bash invocation that touched the workspace would close most of it, and a coarse "invalidate everything under cwd" would be sound if imprecise.Issue body updated with your table and both corrections.