Skip to content

Commit 655ace4

Browse files
committed
Add a test to demonstrate regression
1 parent f4ba64e commit 655ace4

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ test = [
5858
"matplotlib",
5959
"pyqt5",
6060
"flaky",
61+
"websockets>=10.3",
6162
]
6263

6364
[project.entry-points.pylsp]

test/test_python_lsp.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import asyncio
2+
import json
3+
import os
4+
import socket
5+
import subprocess
6+
import sys
7+
import threading
8+
import time
9+
10+
import pytest
11+
import websockets
12+
13+
NUM_CLIENTS = 2
14+
NUM_REQUESTS = 5
15+
TEST_PORT = 5102
16+
HOST = "127.0.0.1"
17+
MAX_STARTUP_SECONDS = 5.0
18+
CHECK_INTERVAL = 0.1
19+
20+
21+
@pytest.fixture(scope="module", autouse=True)
22+
def ws_server_subprocess():
23+
cmd = [
24+
sys.executable,
25+
"-m",
26+
"pylsp.__main__",
27+
"--ws",
28+
"--host",
29+
HOST,
30+
"--port",
31+
str(TEST_PORT),
32+
]
33+
34+
proc = subprocess.Popen(
35+
cmd,
36+
stdout=subprocess.PIPE,
37+
stderr=subprocess.PIPE,
38+
env=os.environ.copy(),
39+
)
40+
41+
deadline = time.time() + MAX_STARTUP_SECONDS
42+
while True:
43+
try:
44+
with socket.create_connection(
45+
("127.0.0.1", TEST_PORT), timeout=CHECK_INTERVAL
46+
):
47+
break
48+
except (ConnectionRefusedError, OSError):
49+
if time.time() > deadline:
50+
proc.kill()
51+
out, err = proc.communicate(timeout=1)
52+
raise RuntimeError(
53+
f"Server didn’t start listening on port {TEST_PORT} in time.\n"
54+
f"STDOUT:\n{out.decode()}\nSTDERR:\n{err.decode()}"
55+
)
56+
time.sleep(CHECK_INTERVAL)
57+
58+
yield # run the tests
59+
60+
proc.terminate()
61+
try:
62+
proc.wait(timeout=2)
63+
except subprocess.TimeoutExpired:
64+
proc.kill()
65+
66+
67+
TEST_DOC = """\
68+
def test():
69+
'''Test documentation'''
70+
test()
71+
"""
72+
73+
74+
def test_concurrent_ws_requests():
75+
errors = set()
76+
lock = threading.Lock()
77+
78+
def thread_target(i: int):
79+
async def do_initialize(idx):
80+
uri = f"ws://{HOST}:{TEST_PORT}"
81+
async with websockets.connect(uri) as ws:
82+
# send initialize
83+
init_request = {
84+
"jsonrpc": "2.0",
85+
"id": 4 * idx,
86+
"method": "initialize",
87+
"params": {},
88+
}
89+
did_open_request = {
90+
"jsonrpc": "2.0",
91+
"id": 4 * (idx + 1),
92+
"method": "textDocument/didOpen",
93+
"params": {
94+
"textDocument": {
95+
"uri": "test.py",
96+
"languageId": "python",
97+
"version": 0,
98+
"text": TEST_DOC,
99+
}
100+
},
101+
}
102+
103+
async def send_request(request: dict):
104+
await asyncio.wait_for(
105+
ws.send(json.dumps(request, ensure_ascii=False)), timeout=5
106+
)
107+
108+
async def get_json_reply():
109+
raw = await asyncio.wait_for(ws.recv(), timeout=60)
110+
obj = json.loads(raw)
111+
return obj
112+
113+
try:
114+
await send_request(init_request)
115+
await get_json_reply()
116+
await send_request(did_open_request)
117+
await get_json_reply()
118+
requests = []
119+
for i in range(NUM_REQUESTS):
120+
hover_request = {
121+
"jsonrpc": "2.0",
122+
"id": 4 * (idx + 2 + i),
123+
"method": "textDocument/definition",
124+
"params": {
125+
"textDocument": {
126+
"uri": "test.py",
127+
},
128+
"position": {
129+
"line": 3,
130+
"character": 2,
131+
},
132+
},
133+
}
134+
completion_request = {
135+
"jsonrpc": "2.0",
136+
"id": 4 * (idx + 3 + i),
137+
"method": "textDocument/completion",
138+
"params": {
139+
"textDocument": {
140+
"uri": "test.py",
141+
},
142+
"position": {
143+
"line": 3,
144+
"character": 2,
145+
},
146+
},
147+
}
148+
requests.append(send_request(hover_request))
149+
requests.append(send_request(completion_request))
150+
# send many requests in parallel
151+
await asyncio.gather(*requests)
152+
# collect replies
153+
for i in range(NUM_REQUESTS):
154+
hover = await get_json_reply()
155+
assert hover
156+
completion = await get_json_reply()
157+
assert completion
158+
except (json.JSONDecodeError, asyncio.TimeoutError) as e:
159+
return e
160+
return None
161+
162+
error = asyncio.run(do_initialize(i))
163+
with lock:
164+
errors.add(error)
165+
166+
# launch threads
167+
threads = []
168+
for i in range(1, NUM_CLIENTS + 1):
169+
t = threading.Thread(target=thread_target, args=(i,))
170+
t.start()
171+
threads.append(t)
172+
173+
# wait for them all
174+
for t in threads:
175+
t.join(timeout=50)
176+
assert not t.is_alive(), f"Worker thread {t} hung!"
177+
178+
assert not any(filter(errors))

0 commit comments

Comments
 (0)