github / github/copilot-cli

LSP requests silently fail when `textDocument/didOpen` is sent concurrently

オープン
#2,095 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る
area:networking area:tools
主要言語
Shell
スター
11.2k
フォーク
1.9k
平均マージ
14時間 16分
マージ済み PR(30日)
6

説明

## Summary

I used GH copilot (Opus 4.6) to draft this, but reviewed the repro script.

Related: https://github.com/astral-sh/ty/issues/3061

---

When Copilot CLI sends multiple LSP operations in parallel (e.g., `workspace/symbol` + `textDocument/references`), each operation triggers its own `textDocument/didOpen` → request → `textDocument/didClose` cycle. The interleaved `didOpen` notifications cause ty to return error `-32801` ("content modified") or silently drop responses for in-flight requests.

**Both sides have issues**, but they're different:

## Bug 1 — ty: violates the ContentModified spec

The LSP 3.17 spec for error code `-32801` (ContentModified) says:

> **A server should NOT send this error code if it detects a content change in its unprocessed messages.** The result even computed on an older state might still be useful for the client.
>
> If a client decides that a result is not of any use anymore the client should cancel the request.

ty violates this: when it receives `textDocument/didOpen` for file B while processing `workspace/symbol`, it cancels the in-flight request with `-32801` (or drops it entirely). The spec explicitly says the server should **not** do this for content changes detected in unprocessed messages — it should complete the request with potentially stale results and let the client decide whether to discard them.

Additionally, the `didOpen` was for a **different file** than the one being queried, so I don't think it should have been cancelled either.

## Bug 2 — Copilot CLI: does not retry on ContentModified

When ty does return `-32801`, Copilot CLI reports "no results" to the user instead of retrying. Since `-32801` is a transient error, the client should retry after a short delay.

## Affected versions

| Component | Version |
|---|---|
| **GitHub Copilot CLI** | 1.0.6 |
| **ty** | 0.0.23 |

## Observed message sequence (via LSP proxy)

When the user invokes `findReferences` and `workspaceSymbol` in parallel:

```
client→ty: textDocument/didOpen sarray.py ← for workspace/symbol
client→ty: workspace/symbol ← query
client→ty: textDocument/didOpen ocr_sclc_task.py ← for findReferences (DIFFERENT file)
client→ty: textDocument/references
client→ty: textDocument/documentSymbol
ty→client: publishDiagnostics ← ty re-indexes...
ty→client: publishDiagnostics
client→ty: textDocument/didClose sarray.py
client→ty: textDocument/didClose ocr_sclc_task.py
```

Result: `workspace/symbol` response is silently dropped. `findReferences` returns "content modified".

When the same operations are sent **sequentially** (one at a time), they all succeed.

## Reproduce

repro_content_modified.py (click to expand)

```python
#!/usr/bin/env python3
"""
Minimal repro: Copilot CLI sends textDocument/didOpen concurrently with
workspace/symbol (and other requests), causing ty to return error -32801
("content modified"). Copilot does not retry, so the request silently fails.

## Affected versions

- ty: 0.0.23
- GitHub Copilot CLI: 1.0.6

## Usage

python3 repro_content_modified.py
"""
import json
import os
import select
import shutil
import subprocess
import sys
import tempfile
import time

def build_msg(msg_dict: dict) -> bytes:
body = json.dumps(msg_dict).encode()
return f"Content-Length: {len(body)}\r\n\r\n".encode() + body

def read_msg(stream):
headers = {}
while True:
line = stream.readline()
if not line:
return None
line = line.decode("utf-8", errors="replace").strip()
if not line:
break
if ":" in line:
key, _, val = line.partition(":")
headers[key.strip().lower()] = val.strip()
length = int(headers.get("content-length", 0))
if length == 0:
return None
body = stream.read(length)
return json.loads(body)

def drain(proc, timeout=0.5):
"""Read and discard all pending messages."""
msgs = []
while select.select([proc.stdout], [], [], timeout)[0]:
msg = read_msg(proc.stdout)
if msg is None:
break
msgs.append(msg)
return msgs

def init_ty(workspace):
"""Start ty, complete handshake including workspace/configuration."""
proc = subprocess.Popen(
["ty", "server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
uri = f"file://{workspace}"

# initialize
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"processId": os.getpid(),
"rootUri": uri,
"capabilities": {
"workspace": {"workspaceFolders": True, "configuration": True},
},
"workspaceFolders": [{"uri": uri, "name": "repro"}],
},
}))
proc.stdin.flush()
resp = read_msg(proc.stdout)
info = resp["result"].get("serverInfo", {})
print(f"Server: {info.get('name')} {info.get('version')}")

# initialized
proc.stdin.write(build_msg({"jsonrpc": "2.0", "method": "initialized", "params": {}}))
proc.stdin.flush()

# workspace/configuration — respond correctly (bug 1 workaround)
time.sleep(0.5)
config_req = read_msg(proc.stdout)
items = config_req["params"]["items"]
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "id": config_req["id"],
"result": [None for _ in items],
}))
proc.stdin.flush()
time.sleep(1)
drain(proc) # discard initial diagnostics

return proc, uri

def main():
workspace = tempfile.mkdtemp(prefix="ty_repro_")
with open(os.path.join(workspace, "foo.py"), "w") as f:
f.write("class FooClass:\n x: int = 1\n")
with open(os.path.join(workspace, "bar.py"), "w") as f:
f.write("from foo import FooClass\ny = FooClass()\n")

proc, uri = init_ty(workspace)
print("1. ty initialized OK\n")

foo_uri = f"file://{os.path.join(workspace, 'foo.py')}"
bar_uri = f"file://{os.path.join(workspace, 'bar.py')}"

# ── Simulate what Copilot CLI does ──────────────────────────────
# Observed in proxy logs, Copilot sends these in rapid succession:
# client→ty: textDocument/didOpen (file A)
# client→ty: workspace/symbol (the actual query)
# client→ty: textDocument/didOpen (file B)
#
# The second didOpen triggers ty to re-check, which cancels the
# in-flight workspace/symbol with error -32801 "content modified".
# Copilot does not retry — the user sees "no results".

print("2. Sending: didOpen(foo) + workspace/symbol + didOpen(bar)")
print(" (simulates Copilot CLI's concurrent request pattern)\n")

# All three written before flush — simulates concurrent sends
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": {"textDocument": {
"uri": foo_uri, "languageId": "python", "version": 1,
"text": "class FooClass:\n x: int = 1\n",
}},
}))
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "id": 10, "method": "workspace/symbol",
"params": {"query": "FooClass"},
}))
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": {"textDocument": {
"uri": bar_uri, "languageId": "python", "version": 1,
"text": "from foo import FooClass\ny = FooClass()\n",
}},
}))
proc.stdin.flush()

# Read responses for up to 5 seconds, looking for id=10
found_error = False
found_result = False
deadline = time.time() + 5
while time.time() < deadline:
if not select.select([proc.stdout], [], [], 0.5)[0]:
continue
msg = read_msg(proc.stdout)
if msg is None:
break
msg_id = msg.get("id")
if msg_id != 10:
continue
if "error" in msg:
code = msg["error"].get("code")
message = msg["error"].get("message", "")
print(f"3. workspace/symbol → ERROR {code}: {message}")
if code == -32801:
found_error = True
break
elif "result" in msg:
n = len(msg["result"])
print(f"3. workspace/symbol → OK: {n} result(s)")
found_result = True
break

if not found_error and not found_result:
print("3. workspace/symbol → no response (timeout)")

print()
if found_error:
print("BUG REPRODUCED.")
print()
print("The concurrent didOpen caused ty to cancel workspace/symbol")
print("with 'content modified' (-32801).")
print()
print("Suggested fixes:")
print()
print(" ty (server):")
print(" - Don't cancel in-flight requests when didOpen arrives for")
print(" a DIFFERENT file. LSP 3.17 §3.17.12 says a server should")
print(" NOT return -32801 for changes in unprocessed messages.")
print()
print(" Copilot CLI (client):")
print(" a) Serialize didOpen notifications before sending requests, or")
print(" b) Retry requests that fail with -32801 (transient error)")
elif found_result:
print("Bug did NOT reproduce (race is timing-dependent).")
print("Try running again — it reproduces ~80% of the time.")
else:
# Check if ty is alive
if proc.poll() is not None:
print("ty crashed (exit code {})".format(proc.poll()))
else:
print("ty silently dropped the workspace/symbol response.")
print()
# Prove it works without the concurrent didOpen
print("4. Retrying workspace/symbol WITHOUT concurrent didOpen...")
proc.stdin.write(build_msg({
"jsonrpc": "2.0", "id": 20, "method": "workspace/symbol",
"params": {"query": "FooClass"},
}))
proc.stdin.flush()
deadline2 = time.time() + 5
while time.time() < deadline2:
if not select.select([proc.stdout], [], [], 0.5)[0]:
continue
msg = read_msg(proc.stdout)
if msg is None:
break
if msg.get("id") == 20:
if "result" in msg:
n = len(msg["result"])
names = [s.get("name", "?") for s in msg["result"]]
print(f" workspace/symbol → OK: {n} result(s): {names}")
print()
print("BUG CONFIRMED: workspace/symbol works in isolation")
print("but is silently dropped when sent concurrently with didOpen.")
print()
print("Suggested fixes:")
print()
print(" ty (server):")
print(" - Don't cancel in-flight requests when didOpen arrives for")
print(" a DIFFERENT file. LSP 3.17 §3.17.12 says a server should")
print(" NOT return -32801 for changes in unprocessed messages.")
print()
print(" Copilot CLI (client):")
print(" a) Serialize didOpen notifications before sending requests, or")
print(" b) Retry requests that fail with -32801 (transient error)")
elif "error" in msg:
print(f" workspace/symbol → ERROR: {msg['error']}")
break

proc.terminate()
proc.wait(timeout=3)
shutil.rmtree(workspace, ignore_errors=True)

if __name__ == "__main__":
main()
```

```bash
python3 repro_content_modified.py
```

### Expected output

```
Server: ty 0.0.23
1. ty initialized OK

2. Sending: didOpen(foo) + workspace/symbol + didOpen(bar)
(simulates Copilot CLI's concurrent request pattern)

3. workspace/symbol → ERROR -32801: content modified

BUG REPRODUCED.

The concurrent didOpen caused ty to cancel workspace/symbol
with 'content modified' (-32801).

Suggested fixes:

ty (server):
- Don't cancel in-flight requests when didOpen arrives for
a DIFFERENT file. LSP 3.17 §3.17.12 says a server should
NOT return -32801 for changes in unprocessed messages.

Copilot CLI (client):
a) Serialize didOpen notifications before sending requests, or
b) Retry requests that fail with -32801 (transient error)
```

## LSP spec references

- [ErrorCodes.ContentModified (-32801)](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#errorCodes): "A server should NOT send this error code if it detects a content change in its unprocessed messages."
- [textDocument/didOpen](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen): Notification to open a document; does not modify content of other documents.

## Suggested fixes

### For ty (astral-sh/ty)
- Do not cancel in-flight requests when `didOpen` arrives for a different file
- Per the spec, complete requests with potentially stale results rather than returning `-32801` for changes detected in unprocessed messages

### For Copilot CLI
- Retry requests that fail with `-32801` (ContentModified) after a short delay
- Consider serializing `didOpen` notifications before sending the associated request, rather than batching them all together

## Environment

- Copilot CLI 1.0.6
- ty 0.0.23 (astral-sh/ty)
- Linux aarch64
- Python 3.11

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。