Skip to content

[BUG] Interactive LSP client never sends didChange after Edit-tool writes — server buffers stay frozen at first-query content (headless -p syncs correctly) #79744

Description

@diegopego

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

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

  1. 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()
  2. 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.)

  3. Open an interactive session in a project of that language.

  4. Ask for any LSP query on a file (documentSymbol or hover), so the client
    opens it.

  5. Let Claude edit that file with the Edit tool (e.g. rename a function).

  6. Ask the same LSP query again.

  7. Observe in /tmp/lsp-wire.log: no didChange after the edit, and the
    second answer describes the pre-edit file.

  8. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions