[BUG] LSP: Edit while the server is starting is not synced; the deferred didOpen sends the first edit's text as version 2
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The first Edit of a file starts its language server. Until the server answers initialize, any further Edit of that file is not synced: the client logs Cannot send notification to LSP server '…': server is starting and gives up. When the server becomes ready, the client sends didOpen with version: 2 but with the text from the first edit. From then on the server's copy of the document differs from the file on disk, its diagnostics describe text that no longer exists, and nothing resyncs it until the file is edited again.
Slow-starting servers make this the common case: jdtls takes several seconds to answer initialize even on a warm workspace, so when the first Java file of a session gets two Edits in one response, jdtls ends up with the first edit only and the model gets errors for code it has already fixed.
What Should Happen?
Document sync during startup should be queued and replayed in order once the server is ready, or the deferred didOpen should carry the file's content at the moment it is sent. A change that was never sent shouldn't bump the document version.
Error Messages/Logs
[ERROR] Cannot send notification to LSP server 'plugin:fake-lsp:demo': server is starting
[ERROR] Failed to sync file open /…/a.demo: Cannot send notification to LSP server 'plugin:fake-lsp:demo': server is starting
[ERROR] LSP: Failed to notify server of file change for /…/a.demo: Failed to sync file open /…/a.demo: Cannot send notification to LSP server 'plugin:fake-lsp:demo': server is starting
[DEBUG] LSP server plugin:fake-lsp:demo initialized
[DEBUG] LSP: Sent didOpen for /…/a.demo (languageId: demo)
What the server received (fake-lsp.log) while a.demo contains eins:
{"received": "initialize", "version": null, "text": null}
{"received": "initialized", "version": null, "text": null}
{"received": "textDocument/didOpen", "version": 2, "text": "uno\n"}
Steps to Reproduce
<details>
<summary>Minimal plugin with a fake language server (Python 3, no dependencies)</summary>
plugin/.claude-plugin/plugin.json
{ "name": "fake-lsp" }
plugin/.lsp.json
{
"demo": {
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/fake-lsp.py"],
"extensionToLanguage": { ".demo": "demo" }
}
}
plugin/fake-lsp.py — logs every notification it gets to $FAKE_LSP_LOG; FAKE_LSP_INIT_DELAY delays the initialize answer; FAKE_LSP_CLEAR=1 publishes an error for b.demo 1 s after initialized, clears it 1 s later and publishes it again 8 s after that.
import json
import os
import sys
import threading
import time
LOG = os.environ.get("FAKE_LSP_LOG", "/tmp/fake-lsp.log")
INIT_DELAY = float(os.environ.get("FAKE_LSP_INIT_DELAY", "0"))
CLEAR = os.environ.get("FAKE_LSP_CLEAR") == "1"
lock = threading.Lock()
def read():
headers = {}
while True:
line = sys.stdin.buffer.readline()
if not line:
sys.exit(0)
if not line.strip():
break
key, _, value = line.decode().partition(":")
headers[key.strip().lower()] = value.strip()
return json.loads(sys.stdin.buffer.read(int(headers["content-length"])))
def send(msg):
body = json.dumps(msg).encode()
with lock:
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
sys.stdout.buffer.flush()
def log(event):
with lock, open(LOG, "a") as f:
f.write(json.dumps(event) + "\n")
def publish(uri, diagnostics):
log({"sent": "publishDiagnostics", "uri": uri, "count": len(diagnostics)})
send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": diagnostics}})
def error_then_clear_then_error(root_uri):
uri = root_uri + "/b.demo"
error = [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}},
"severity": 1, "message": "error in b.demo"}]
time.sleep(1)
publish(uri, error)
time.sleep(1)
publish(uri, [])
time.sleep(8)
publish(uri, error)
root_uri = ""
while True:
msg = read()
method = msg.get("method")
params = msg.get("params") or {}
document = params.get("textDocument") or {}
log({"received": method, "version": document.get("version"),
"text": document.get("text", (params.get("contentChanges") or [{}])[-1].get("text"))})
if method == "initialize":
root_uri = params["rootUri"]
time.sleep(INIT_DELAY)
send({"jsonrpc": "2.0", "id": msg["id"],
"result": {"capabilities": {"textDocumentSync": {"openClose": True, "change": 1, "save": True}}}})
elif method == "initialized" and CLEAR:
threading.Thread(target=error_then_clear_then_error, args=(root_uri,), daemon=True).start()
elif method == "exit":
sys.exit(0)
elif "id" in msg and method:
send({"jsonrpc": "2.0", "id": msg["id"], "result": None})
</details>
mkdir demo && cd demo && echo one > a.demo
FAKE_LSP_LOG=$PWD/fake-lsp.log FAKE_LSP_INIT_DELAY=15 claude -p --plugin-dir ../plugin --debug-file debug.log \
--allowedTools Read Edit "Bash(python3:*)" -- "Follow these steps exactly, one tool call per response, in order. \
1) Read a.demo. 2) Edit a.demo: replace 'one' with 'uno'. 3) Edit a.demo: replace 'uno' with 'eins'. \
4) Run: python3 -c 'import time; time.sleep(20)'. Then stop."
cat a.demo fake-lsp.log
FAKE_LSP_INIT_DELAY=15 stands in for a server that needs 15 s to answer initialize.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.294 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Non-interactive/CI environment
Additional Information
Same behaviour on 2.1.284 and in interactive sessions with jdtls. #67037 described the request side of the same startup window (closed as stale); here the startup window silently corrupts the server's view of the document.