Preflight Checklist
What's Wrong?
In an interactive session, the LSP client (plugin-registered server via
.lsp.json) sends textDocument/didOpen the first time a file is queried and
then never notifies the server of any write again — no didChange, no
didSave — not even for changes made by Claude's own Edit tool. The server's
buffer stays frozen at first-query content for the entire session, so every
LSP answer after the first write silently describes a past version of the
file: findReferences misses new call sites, hover reports old signatures,
documentSymbol lists renamed symbols under their old names. In one measured
session the buffer stayed frozen for 40+ minutes; a hover past the frozen
buffer's last line errored with No line in <file> at position … while the
file on disk had more lines.
The headless -p client handles the same scenario correctly: same binary,
same plugin, same edit — it sends a rangeless full-text didChange with a
properly incremented version, then didSave, and answers are fresh.
There is also no recovery path: killing the server process leaves the client
with a zombie connection refusing every subsequent call (Cannot send request … server is running), and restartOnCrash did not respawn it. Only a new
session gets a fresh server.
Languages with order-sensitive compilation (F#) are hit hardest — a stale
buffer for one file also poisons answers about every file depending on it —
but the defect is language-independent and likely explains stale-diagnostics
reports in other languages (e.g. #64239 for TypeScript).
Distinct from #30622 (constant version in didChange): here no didChange is
sent at all in interactive mode — and at least fsautocomplete applies a
rangeless full-text didChange regardless of version, so the missing
notification, not the version, is what freezes it. Note #17063 claims -p
sends no lifecycle notifications; on 2.1.215 we measured the opposite (-p
sends didOpen/didChange/didSave correctly), so the asymmetry has flipped.
What Should Happen?
The interactive client should notify the server of writes the same way the
headless -p client already does: a didChange (full-text is fine) with an
incrementing version after each tool edit, so LSP answers describe the file
as it currently is.
Error Messages/Logs
Client→server traffic captured by a transparent logging wrapper registered as
the `.lsp.json` command (script in Steps to Reproduce). Noise trimmed.
Interactive session — query, Edit-tool change, query, out-of-band change (sed), query:
req initialize
ntf initialized
ntf workspace/didChangeConfiguration
ntf textDocument/didOpen # Book.fs, full text
req textDocument/documentSymbol # answer correct
# <- Edit tool rewrites Book.fs here
req textDocument/documentSymbol # NO didChange was sent; stale answer
# <- sed rewrites Book.fs here
req textDocument/documentSymbol # still nothing; stale answer
Headless `-p` session — identical scenario, same machine, same plugin:
req initialize
ntf initialized
ntf workspace/didChangeConfiguration
ntf textDocument/didOpen # Book.fs, full text
req textDocument/documentSymbol # answer correct
ntf textDocument/didChange # version=2, one rangeless full-text change
ntf textDocument/didSave
req textDocument/documentSymbol # fresh answer
req shutdown
Steps to Reproduce
-
Save this pass-through logger as /abs/path/log_lsp.py (any LSP server,
any language):
#!/usr/bin/env python3
# log_lsp.py SERVER_CMD... — pass-through that logs client->server methods
import json, os, subprocess, sys, threading
srv = subprocess.Popen(sys.argv[1:], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
threading.Thread(target=lambda: [sys.stdout.buffer.write(c) or sys.stdout.buffer.flush()
for c in iter(lambda: srv.stdout.read1(65536), b"")], daemon=True).start()
log = open(os.environ.get("LSP_LOG", "/tmp/lsp-wire.log"), "a")
stdin = sys.stdin.buffer
while True:
length, line = None, stdin.readline()
if not line: break
raw = line
while line.strip():
if line.lower().startswith(b"content-length:"): length = int(line.split(b":",1)[1])
line = stdin.readline(); raw += line
body = stdin.read(length); raw += body
try:
m = json.loads(body)
kind = "req" if "id" in m and "method" in m else "ntf" if "method" in m else "res"
v = m.get("params", {}).get("textDocument", {}).get("version")
log.write(f"{kind} {m.get('method')}" + (f" v={v}" if v else "") + "\n"); log.flush()
except Exception: pass
srv.stdin.write(raw); srv.stdin.flush()
-
In a plugin's .lsp.json, wrap the registered server with it:
{ "yourlang": { "command": "python3",
"args": ["/abs/path/log_lsp.py", "your-language-server"], ... } }
(Reproduced with fsautocomplete 0.83.0 registered for .fs.)
-
Open an interactive session in a project of that language.
-
Ask for any LSP query on a file (documentSymbol or hover), so the client
opens it.
-
Let Claude edit that file with the Edit tool (e.g. rename a function).
-
Ask the same LSP query again.
-
Observe in /tmp/lsp-wire.log: no didChange after the edit, and the
second answer describes the pre-edit file.
-
Repeat the same steps with claude -p: the log shows
ntf textDocument/didChange v=2 and the second answer is fresh.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
No response
Claude Code Version
2.1.216 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
Workaround we ship for F#: a disk-syncing stdio proxy between the client and
fsautocomplete that re-reads changed files before each request —
https://github.com/diegopego/claude-code-fsharp-lsp/blob/master/tools/fsac_sync_proxy.py
It fixes one language; every other .lsp.json-served language still has the
frozen-buffer behavior. Fixing the interactive client to send the same
didChange the -p client already sends would make it unnecessary.
Preflight Checklist
What's Wrong?
In an interactive session, the LSP client (plugin-registered server via
.lsp.json) sendstextDocument/didOpenthe first time a file is queried andthen never notifies the server of any write again — no
didChange, nodidSave— not even for changes made by Claude's own Edit tool. The server'sbuffer stays frozen at first-query content for the entire session, so every
LSP answer after the first write silently describes a past version of the
file: findReferences misses new call sites, hover reports old signatures,
documentSymbol lists renamed symbols under their old names. In one measured
session the buffer stayed frozen for 40+ minutes; a hover past the frozen
buffer's last line errored with
No line in <file> at position …while thefile on disk had more lines.
The headless
-pclient handles the same scenario correctly: same binary,same plugin, same edit — it sends a rangeless full-text
didChangewith aproperly incremented version, then
didSave, and answers are fresh.There is also no recovery path: killing the server process leaves the client
with a zombie connection refusing every subsequent call (
Cannot send request … server is running), andrestartOnCrashdid not respawn it. Only a newsession gets a fresh server.
Languages with order-sensitive compilation (F#) are hit hardest — a stale
buffer for one file also poisons answers about every file depending on it —
but the defect is language-independent and likely explains stale-diagnostics
reports in other languages (e.g. #64239 for TypeScript).
Distinct from #30622 (constant version in didChange): here no didChange is
sent at all in interactive mode — and at least fsautocomplete applies a
rangeless full-text didChange regardless of version, so the missing
notification, not the version, is what freezes it. Note #17063 claims
-psends no lifecycle notifications; on 2.1.215 we measured the opposite (
-psends didOpen/didChange/didSave correctly), so the asymmetry has flipped.
What Should Happen?
The interactive client should notify the server of writes the same way the
headless
-pclient already does: adidChange(full-text is fine) with anincrementing version after each tool edit, so LSP answers describe the file
as it currently is.
Error Messages/Logs
Steps to Reproduce
Save this pass-through logger as
/abs/path/log_lsp.py(any LSP server,any language):
In a plugin's
.lsp.json, wrap the registered server with it:{ "yourlang": { "command": "python3", "args": ["/abs/path/log_lsp.py", "your-language-server"], ... } }(Reproduced with fsautocomplete 0.83.0 registered for
.fs.)Open an interactive session in a project of that language.
Ask for any LSP query on a file (documentSymbol or hover), so the client
opens it.
Let Claude edit that file with the Edit tool (e.g. rename a function).
Ask the same LSP query again.
Observe in
/tmp/lsp-wire.log: nodidChangeafter the edit, and thesecond answer describes the pre-edit file.
Repeat the same steps with
claude -p: the log showsntf textDocument/didChange v=2and the second answer is fresh.Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
No response
Claude Code Version
2.1.216 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
Workaround we ship for F#: a disk-syncing stdio proxy between the client and
fsautocomplete that re-reads changed files before each request —
https://github.com/diegopego/claude-code-fsharp-lsp/blob/master/tools/fsac_sync_proxy.py
It fixes one language; every other
.lsp.json-served language still has thefrozen-buffer behavior. Fixing the interactive client to send the same
didChange the
-pclient already sends would make it unnecessary.