357 lines
13 KiB
Python
357 lines
13 KiB
Python
#!/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 raw_frame(body: bytes) -> bytes:
|
|
return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body
|
|
|
|
|
|
def content_type_frame(message: dict) -> bytes:
|
|
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
header = (
|
|
f"Content-Length: {len(body)}\r\n"
|
|
"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n"
|
|
).encode("ascii")
|
|
return header + 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 assert_json_rpc_errors(server: Path) -> None:
|
|
payload = b"".join([
|
|
raw_frame(b'{"jsonrpc":"2.0",'),
|
|
raw_frame(b"[]"),
|
|
raw_frame(b'{"jsonrpc":"1.0","id":7,"method":"initialize"}'),
|
|
frame({
|
|
"jsonrpc": "2.0",
|
|
"id": 8,
|
|
"method": "initialize",
|
|
"params": {},
|
|
}),
|
|
frame({"jsonrpc": "2.0", "id": 10, "method": "shutdown"}),
|
|
frame({"jsonrpc": "2.0", "method": "exit"}),
|
|
])
|
|
result = run_raw(server, payload)
|
|
if result.returncode != 0:
|
|
raise RuntimeError("JSON-RPC error sequence should shut down cleanly")
|
|
|
|
responses = read_messages(result.stdout)
|
|
if responses[0].get("id", "missing") is not None:
|
|
raise RuntimeError(
|
|
f"ParseError response should use a null id: {responses[0]!r}")
|
|
if responses[0].get("error", {}).get("code") != -32700:
|
|
raise RuntimeError("Malformed JSON should return ParseError")
|
|
if responses[1].get("id", "missing") is not None:
|
|
raise RuntimeError("InvalidRequest response should use a null id")
|
|
if responses[1].get("error", {}).get("code") != -32600:
|
|
raise RuntimeError("A non-object JSON value should return InvalidRequest")
|
|
if response_by_id(result.stdout, 7).get("error", {}).get("code") != -32600:
|
|
raise RuntimeError("jsonrpc other than 2.0 should return InvalidRequest")
|
|
client = LspClient(server)
|
|
try:
|
|
client.send({
|
|
"jsonrpc": "2.0",
|
|
"id": 11,
|
|
"method": "initialize",
|
|
"params": {},
|
|
})
|
|
client.read()
|
|
client.send({"jsonrpc": "2.0", "id": 9, "method": "missing/method"})
|
|
if client.read().get("error", {}).get("code") != -32601:
|
|
raise RuntimeError("Unknown request method should return MethodNotFound")
|
|
client.send({"jsonrpc": "2.0", "id": 12, "method": "shutdown"})
|
|
client.read()
|
|
client.send({"jsonrpc": "2.0", "method": "exit"})
|
|
if client.close_input() != 0:
|
|
raise RuntimeError("unknown method sequence should shut down cleanly")
|
|
finally:
|
|
client.kill()
|
|
|
|
|
|
def assert_cancellation_and_failures(server: Path) -> None:
|
|
client = LspClient(server)
|
|
try:
|
|
client.send({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {},
|
|
})
|
|
if client.read().get("id") != 1:
|
|
raise RuntimeError("missing initialize response")
|
|
|
|
client.send({"jsonrpc": "2.0", "id": 2, "method": "test/block"})
|
|
client.send({"jsonrpc": "2.0", "id": "2", "method": "test/block"})
|
|
client.send({
|
|
"jsonrpc": "2.0",
|
|
"method": "$/cancelRequest",
|
|
"params": {"id": 2},
|
|
})
|
|
cancelled = client.read(timeout=1)
|
|
if cancelled.get("id") != 2 or \
|
|
cancelled.get("error", {}).get("code") != -32800:
|
|
raise RuntimeError("integer request id should be cancelled independently")
|
|
|
|
client.send({
|
|
"jsonrpc": "2.0",
|
|
"method": "$/cancelRequest",
|
|
"params": {"id": "2"},
|
|
})
|
|
string_cancelled = client.read(timeout=1)
|
|
if string_cancelled.get("id") != "2" or \
|
|
string_cancelled.get("error", {}).get("code") != -32800:
|
|
raise RuntimeError("string request id should be cancelled independently")
|
|
|
|
client.send({"jsonrpc": "2.0", "id": 3, "method": "test/throw"})
|
|
failed = client.read(timeout=1)
|
|
if failed.get("id") != 3 or \
|
|
failed.get("error", {}).get("code") != -32603:
|
|
raise RuntimeError("request exceptions should return InternalError")
|
|
|
|
client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"})
|
|
client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"})
|
|
duplicate = client.read(timeout=1)
|
|
if duplicate.get("id") != 4 or \
|
|
duplicate.get("error", {}).get("code") != -32600:
|
|
raise RuntimeError("duplicate active request id should return InvalidRequest")
|
|
|
|
client.send({
|
|
"jsonrpc": "2.0",
|
|
"method": "$/cancelRequest",
|
|
"params": {"id": 4},
|
|
})
|
|
cancelled_original = client.read(timeout=1)
|
|
if cancelled_original.get("id") != 4 or \
|
|
cancelled_original.get("error", {}).get("code") != -32800:
|
|
raise RuntimeError("duplicate id must not replace the original request")
|
|
|
|
client.send({"jsonrpc": "2.0", "method": "test/throwNotification"})
|
|
client.send({"jsonrpc": "2.0", "id": 5, "method": "shutdown"})
|
|
shutdown = client.read(timeout=1)
|
|
if shutdown.get("id") != 5 or shutdown.get("result", "missing") is not None:
|
|
raise RuntimeError("notification exception should not stop the server")
|
|
|
|
client.send({"jsonrpc": "2.0", "method": "exit"})
|
|
if client.close_input() != 0:
|
|
raise RuntimeError("fixture should exit cleanly after cancellation tests")
|
|
finally:
|
|
client.kill()
|
|
|
|
|
|
def assert_framing(server: Path) -> None:
|
|
invalid_payloads = [
|
|
b"Content-Length: 2junk\r\n\r\n{}",
|
|
b"Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}",
|
|
b"Content-Length: 0\r\n\r\n",
|
|
b"Content-Length: 16777217\r\n\r\n",
|
|
b"Content-Length: 10\r\n\r\n{}",
|
|
b"Content-Length: 2\n\n{}",
|
|
b"X-Length: 2\r\n\r\n{}",
|
|
b"Content-Length: 2\r\nContent-Type: application/json\r\n\r\n{}",
|
|
]
|
|
for index, payload in enumerate(invalid_payloads):
|
|
result = run_raw(server, payload)
|
|
if result.returncode != 1:
|
|
raise RuntimeError(f"invalid framing case {index} should return code 1")
|
|
if result.stdout:
|
|
raise RuntimeError(f"invalid framing case {index} should not process its body")
|
|
|
|
valid_payload = b"".join([
|
|
content_type_frame({
|
|
"jsonrpc": "2.0",
|
|
"id": 31,
|
|
"method": "initialize",
|
|
"params": {},
|
|
}),
|
|
frame({"jsonrpc": "2.0", "id": 32, "method": "shutdown"}),
|
|
frame({"jsonrpc": "2.0", "method": "exit"}),
|
|
])
|
|
valid = run_raw(server, valid_payload)
|
|
if valid.returncode != 0:
|
|
raise RuntimeError("canonical Content-Type should be accepted")
|
|
if response_by_id(valid.stdout, 31).get("error"):
|
|
raise RuntimeError("initialize with canonical Content-Type should succeed")
|
|
if response_by_id(valid.stdout, 32).get("result", "missing") is not None:
|
|
raise RuntimeError("continuous framing should reach shutdown")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--server", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
|
|
assert_lifecycle(args.server)
|
|
assert_json_rpc_errors(args.server)
|
|
assert_cancellation_and_failures(args.server)
|
|
assert_framing(args.server)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|