Skip to content

Commit 9122bd8

Browse files
[投稿] 添加脚本: Cipilot — by Viển
1 parent 00f43e0 commit 9122bd8

1 file changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
"""
2+
Minimal async JSON-RPC 2.0 client for stdio transport
3+
4+
This uses threading to handle blocking IO in an async-friendly way.
5+
Much simpler and more reliable than pure asyncio subprocess.
6+
"""
7+
8+
import asyncio
9+
import inspect
10+
import json
11+
import threading
12+
import uuid
13+
from collections.abc import Awaitable, Callable
14+
from typing import Any
15+
16+
17+
class JsonRpcError(Exception):
18+
"""JSON-RPC error response"""
19+
20+
def __init__(self, code: int, message: str, data: Any = None):
21+
self.code = code
22+
self.message = message
23+
self.data = data
24+
super().__init__(f"JSON-RPC Error {code}: {message}")
25+
26+
27+
class ProcessExitedError(Exception):
28+
"""Error raised when the CLI process exits unexpectedly"""
29+
30+
pass
31+
32+
33+
RequestHandler = Callable[[dict], dict | Awaitable[dict]]
34+
35+
36+
class JsonRpcClient:
37+
"""
38+
Minimal async JSON-RPC 2.0 client for stdio transport
39+
40+
Uses threads for blocking IO but provides async interface.
41+
"""
42+
43+
def __init__(self, process):
44+
"""
45+
Create client from subprocess.Popen with stdin/stdout pipes
46+
47+
Args:
48+
process: subprocess.Popen with stdin=PIPE, stdout=PIPE
49+
"""
50+
self.process = process
51+
self.pending_requests: dict[str, asyncio.Future] = {}
52+
self.notification_handler: Callable[[str, dict], None] | None = None
53+
self.request_handlers: dict[str, RequestHandler] = {}
54+
self._running = False
55+
self._read_thread: threading.Thread | None = None
56+
self._stderr_thread: threading.Thread | None = None
57+
self._loop: asyncio.AbstractEventLoop | None = None
58+
self._write_lock = threading.Lock()
59+
self._pending_lock = threading.Lock()
60+
self._process_exit_error: str | None = None
61+
self._stderr_output: list[str] = []
62+
self._stderr_lock = threading.Lock()
63+
self.on_close: Callable[[], None] | None = None
64+
65+
def start(self, loop: asyncio.AbstractEventLoop | None = None):
66+
"""Start listening for messages in background thread"""
67+
if not self._running:
68+
self._running = True
69+
# Always use the provided loop or get the running loop
70+
self._loop = loop or asyncio.get_running_loop()
71+
self._read_thread = threading.Thread(target=self._read_loop, daemon=True)
72+
self._read_thread.start()
73+
# Start stderr reader thread if process has stderr
74+
if hasattr(self.process, "stderr") and self.process.stderr:
75+
self._stderr_thread = threading.Thread(target=self._stderr_loop, daemon=True)
76+
self._stderr_thread.start()
77+
78+
def _stderr_loop(self):
79+
"""Read stderr in background to capture error messages"""
80+
try:
81+
while self._running:
82+
if not self.process.stderr:
83+
break
84+
line = self.process.stderr.readline()
85+
if not line:
86+
break
87+
with self._stderr_lock:
88+
self._stderr_output.append(
89+
line.decode("utf-8") if isinstance(line, bytes) else line
90+
)
91+
except Exception:
92+
pass # Ignore errors reading stderr
93+
94+
def get_stderr_output(self) -> str:
95+
"""Get captured stderr output"""
96+
with self._stderr_lock:
97+
return "".join(self._stderr_output).strip()
98+
99+
async def stop(self):
100+
"""Stop listening and clean up"""
101+
self._running = False
102+
if self._read_thread:
103+
self._read_thread.join(timeout=1.0)
104+
if self._stderr_thread:
105+
self._stderr_thread.join(timeout=1.0)
106+
107+
async def request(
108+
self, method: str, params: dict | None = None, timeout: float | None = None
109+
) -> Any:
110+
"""
111+
Send a JSON-RPC request and wait for response
112+
113+
Args:
114+
method: Method name
115+
params: Optional parameters
116+
timeout: Optional request timeout in seconds. If None (default),
117+
waits indefinitely for the server to respond.
118+
119+
Returns:
120+
The result from the response
121+
122+
Raises:
123+
JsonRpcError: If server returns an error
124+
asyncio.TimeoutError: If request times out (only when timeout is set)
125+
"""
126+
request_id = str(uuid.uuid4())
127+
128+
# Use the stored loop to ensure consistency with the reader thread
129+
if not self._loop:
130+
raise RuntimeError("Client not started. Call start() first.")
131+
132+
future = self._loop.create_future()
133+
with self._pending_lock:
134+
self.pending_requests[request_id] = future
135+
136+
message = {
137+
"jsonrpc": "2.0",
138+
"id": request_id,
139+
"method": method,
140+
"params": params or {},
141+
}
142+
143+
await self._send_message(message)
144+
145+
try:
146+
if timeout is not None:
147+
return await asyncio.wait_for(future, timeout=timeout)
148+
return await future
149+
finally:
150+
with self._pending_lock:
151+
self.pending_requests.pop(request_id, None)
152+
153+
async def notify(self, method: str, params: dict | None = None):
154+
"""
155+
Send a JSON-RPC notification (no response expected)
156+
157+
Args:
158+
method: Method name
159+
params: Optional parameters
160+
"""
161+
message = {
162+
"jsonrpc": "2.0",
163+
"method": method,
164+
"params": params or {},
165+
}
166+
await self._send_message(message)
167+
168+
def set_notification_handler(self, handler: Callable[[str, dict], None]):
169+
"""Set handler for incoming notifications from server"""
170+
self.notification_handler = handler
171+
172+
def set_request_handler(self, method: str, handler: RequestHandler):
173+
if handler is None:
174+
self.request_handlers.pop(method, None)
175+
else:
176+
self.request_handlers[method] = handler
177+
178+
async def _send_message(self, message: dict):
179+
"""Send a JSON-RPC message with Content-Length header"""
180+
loop = self._loop or asyncio.get_event_loop()
181+
182+
def write():
183+
content = json.dumps(message, separators=(",", ":"))
184+
content_bytes = content.encode("utf-8")
185+
header = f"Content-Length: {len(content_bytes)}\r\n\r\n"
186+
with self._write_lock:
187+
self.process.stdin.write(header.encode("utf-8"))
188+
self.process.stdin.write(content_bytes)
189+
self.process.stdin.flush()
190+
191+
# Run in thread pool to avoid blocking
192+
await loop.run_in_executor(None, write)
193+
194+
def _read_loop(self):
195+
"""Read messages from the stream (runs in thread)"""
196+
try:
197+
while self._running:
198+
message = self._read_message()
199+
if message:
200+
self._handle_message(message)
201+
else:
202+
# No message means stream closed - process likely exited
203+
break
204+
except EOFError:
205+
# Stream closed - check if process exited
206+
pass
207+
except Exception as e:
208+
if self._running:
209+
# Store error for pending requests
210+
self._process_exit_error = str(e)
211+
212+
# Process exited or read failed - fail all pending requests
213+
if self._running:
214+
self._fail_pending_requests()
215+
if self.on_close is not None:
216+
self.on_close()
217+
218+
def _fail_pending_requests(self):
219+
"""Fail all pending requests when process exits"""
220+
# Build error message with stderr output
221+
stderr_output = self.get_stderr_output()
222+
return_code = None
223+
if hasattr(self.process, "poll"):
224+
return_code = self.process.poll()
225+
226+
if stderr_output:
227+
error_msg = f"CLI process exited with code {return_code}\nstderr: {stderr_output}"
228+
elif return_code is not None:
229+
error_msg = f"CLI process exited with code {return_code}"
230+
else:
231+
error_msg = "CLI process exited unexpectedly"
232+
233+
# Fail all pending requests
234+
with self._pending_lock:
235+
for request_id, future in list(self.pending_requests.items()):
236+
if not future.done():
237+
exc = ProcessExitedError(error_msg)
238+
loop = future.get_loop()
239+
loop.call_soon_threadsafe(future.set_exception, exc)
240+
241+
def _read_exact(self, num_bytes: int) -> bytes:
242+
"""
243+
Read exactly num_bytes, handling partial/short reads from pipes.
244+
245+
Args:
246+
num_bytes: Number of bytes to read
247+
248+
Returns:
249+
Bytes read from stream
250+
251+
Raises:
252+
EOFError: If stream ends before reading all bytes
253+
"""
254+
chunks = []
255+
remaining = num_bytes
256+
while remaining > 0:
257+
chunk = self.process.stdout.read(remaining)
258+
if not chunk:
259+
raise EOFError("Unexpected end of stream while reading JSON-RPC message")
260+
chunks.append(chunk)
261+
remaining -= len(chunk)
262+
return b"".join(chunks)
263+
264+
def _read_message(self) -> dict | None:
265+
"""
266+
Read a single JSON-RPC message with Content-Length header (blocking)
267+
268+
Returns:
269+
Parsed JSON message or None if connection closed
270+
"""
271+
# Read header line
272+
header_line = self.process.stdout.readline()
273+
if not header_line:
274+
return None
275+
276+
# Parse Content-Length
277+
header = header_line.decode("utf-8").strip()
278+
if not header.startswith("Content-Length:"):
279+
return None
280+
281+
content_length = int(header.split(":")[1].strip())
282+
283+
# Read empty line
284+
self.process.stdout.readline()
285+
286+
# Read exact content using loop to handle short reads
287+
content_bytes = self._read_exact(content_length)
288+
content = content_bytes.decode("utf-8")
289+
290+
return json.loads(content)
291+
292+
def _handle_message(self, message: dict):
293+
"""Handle an incoming message (response or notification)"""
294+
# Check if it's a response to our request
295+
if "id" in message:
296+
with self._pending_lock:
297+
future = self.pending_requests.get(message["id"])
298+
299+
if future is not None:
300+
loop = future.get_loop()
301+
302+
if "error" in message:
303+
error = message["error"]
304+
exc = JsonRpcError(
305+
error.get("code", -1),
306+
error.get("message", "Unknown error"),
307+
error.get("data"),
308+
)
309+
loop.call_soon_threadsafe(future.set_exception, exc)
310+
elif "result" in message:
311+
loop.call_soon_threadsafe(future.set_result, message["result"])
312+
else:
313+
exc = ValueError("Invalid JSON-RPC response")
314+
loop.call_soon_threadsafe(future.set_exception, exc)
315+
return
316+
317+
# Check if it's a notification from server
318+
if "method" in message and "id" not in message:
319+
if self.notification_handler and self._loop:
320+
method = message["method"]
321+
params = message.get("params", {})
322+
# Schedule notification handler on the event loop for thread safety
323+
self._loop.call_soon_threadsafe(self.notification_handler, method, params)
324+
return
325+
326+
# Otherwise handle as incoming request (tool.call, etc.)
327+
if "method" in message and "id" in message:
328+
self._handle_request(message)
329+
330+
def _handle_request(self, message: dict):
331+
method = message.get("method", "")
332+
handler = self.request_handlers.get(method)
333+
if not handler:
334+
if self._loop:
335+
asyncio.run_coroutine_threadsafe(
336+
self._send_error_response(
337+
message["id"], -32601, f"Method not found: {message['method']}", None
338+
),
339+
self._loop,
340+
)
341+
return
342+
if not self._loop:
343+
return
344+
asyncio.run_coroutine_threadsafe(
345+
self._dispatch_request(message, handler),
346+
self._loop,
347+
)
348+
349+
async def _dispatch_request(self, message: dict, handler: RequestHandler):
350+
try:
351+
params = message.get("params", {})
352+
outcome = handler(params)
353+
if inspect.isawaitable(outcome):
354+
outcome = await outcome
355+
if outcome is not None and not isinstance(outcome, dict):
356+
raise ValueError(
357+
f"Request handler must return a dict, got {type(outcome).__name__}"
358+
)
359+
await self._send_response(message["id"], outcome)
360+
except JsonRpcError as exc:
361+
await self._send_error_response(message["id"], exc.code, exc.message, exc.data)
362+
except Exception as exc: # pylint: disable=broad-except
363+
await self._send_error_response(message["id"], -32603, str(exc), None)
364+
365+
async def _send_response(self, request_id: str, result: dict | None):
366+
response = {
367+
"jsonrpc": "2.0",
368+
"id": request_id,
369+
"result": result,
370+
}
371+
await self._send_message(response)
372+
373+
async def _send_error_response(
374+
self, request_id: str, code: int, message: str, data: dict | None
375+
):
376+
response = {
377+
"jsonrpc": "2.0",
378+
"id": request_id,
379+
"error": {
380+
"code": code,
381+
"message": message,
382+
"data": data,
383+
},
384+
}
385+
await self._send_message(response)

0 commit comments

Comments
 (0)