🐛 fix(core): enforce strict LSP lifecycle

This commit is contained in:
csh
2026-07-13 21:35:40 +08:00
parent 5edd4b3637
commit 06a0da5124
21 changed files with 555 additions and 664 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
import argparse
import json
import os
import select
import subprocess
import time
from pathlib import Path
def frame(message: dict) -> bytes:
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body
def read_messages(data: bytes) -> list[dict]:
messages = []
offset = 0
while offset < len(data):
header_end = data.find(b"\r\n\r\n", offset)
if header_end < 0:
raise RuntimeError("Incomplete LSP response header")
header = data[offset:header_end].decode("ascii")
fields = dict(line.split(": ", 1) for line in header.split("\r\n"))
length = int(fields["Content-Length"])
body_start = header_end + 4
body_end = body_start + length
if body_end > len(data):
raise RuntimeError("Incomplete LSP response body")
messages.append(json.loads(data[body_start:body_end].decode("utf-8")))
offset = body_end
return messages
def response_by_id(data: bytes, request_id: int | str) -> dict:
return next(message for message in read_messages(data)
if message.get("id") == request_id)
def run_raw(server: Path, payload: bytes) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
[str(server), "--core-server-fixture"],
input=payload,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=5,
check=False,
)
def run_batch(server: Path, messages: list[dict]) -> subprocess.CompletedProcess[bytes]:
return run_raw(server, b"".join(frame(message) for message in messages))
class LspClient:
def __init__(self, server: Path) -> None:
self.proc = subprocess.Popen(
[str(server), "--core-server-fixture"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.buffer = b""
def send(self, message: dict) -> None:
if self.proc.stdin is None:
raise RuntimeError("Fixture stdin is unavailable")
self.proc.stdin.write(frame(message))
self.proc.stdin.flush()
def read(self, timeout: float = 1.0) -> dict:
if self.proc.stdout is None:
raise RuntimeError("Fixture stdout is unavailable")
deadline = time.monotonic() + timeout
while True:
header_end = self.buffer.find(b"\r\n\r\n")
if header_end >= 0:
header = self.buffer[:header_end].decode("ascii")
fields = dict(line.split(": ", 1)
for line in header.split("\r\n"))
length = int(fields["Content-Length"])
body_start = header_end + 4
body_end = body_start + length
if len(self.buffer) >= body_end:
body = self.buffer[body_start:body_end]
self.buffer = self.buffer[body_end:]
return json.loads(body.decode("utf-8"))
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Timed out waiting for LSP response")
ready, _, _ = select.select(
[self.proc.stdout.fileno()], [], [], remaining)
if not ready:
continue
chunk = os.read(self.proc.stdout.fileno(), 4096)
if not chunk:
raise RuntimeError("Fixture closed stdout before a response")
self.buffer += chunk
def close_input(self) -> int:
if self.proc.stdin is not None:
self.proc.stdin.close()
return self.proc.wait(timeout=5)
def kill(self) -> None:
if self.proc.poll() is None:
self.proc.kill()
self.proc.wait(timeout=5)
def assert_lifecycle(server: Path) -> None:
before_init = run_batch(server, [
{"jsonrpc": "2.0", "id": 1, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
])
if before_init.returncode != 1:
raise RuntimeError("shutdown before initialize should exit with code 1")
if response_by_id(before_init.stdout, 1)["error"]["code"] != -32002:
raise RuntimeError("shutdown before initialize should return ServerNotInitialized")
repeated = run_batch(server, [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "id": 3, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
])
if repeated.returncode != 0:
raise RuntimeError("shutdown followed by exit should return code 0")
if response_by_id(repeated.stdout, 2)["error"]["code"] != -32600:
raise RuntimeError("repeated initialize should return InvalidRequest")
if response_by_id(repeated.stdout, 3).get("result", "missing") is not None:
raise RuntimeError("shutdown should return a null result")
direct_exit = run_batch(
server, [{"jsonrpc": "2.0", "method": "exit"}])
if direct_exit.returncode != 1:
raise RuntimeError("exit before shutdown should return code 1")
client = LspClient(server)
try:
client.send({
"jsonrpc": "2.0",
"id": 11,
"method": "initialize",
"params": {},
})
if client.read().get("id") != 11:
raise RuntimeError("missing initialize response")
client.send({"jsonrpc": "2.0", "id": 12, "method": "shutdown"})
if client.read().get("result", "missing") is not None:
raise RuntimeError("shutdown should return a null result")
time.sleep(0.1)
if client.proc.poll() is not None:
raise RuntimeError("server exited before receiving exit notification")
client.send({"jsonrpc": "2.0", "method": "exit"})
if client.close_input() != 0:
raise RuntimeError("orderly lifecycle should return code 0")
finally:
client.kill()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", type=Path, required=True)
args = parser.parse_args()
assert_lifecycle(args.server)
return 0
if __name__ == "__main__":
raise SystemExit(main())