deft-subspace-kernel 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deft_subspace/kernel/__init__.py +93 -0
- deft_subspace/kernel/abort.py +173 -0
- deft_subspace/kernel/channel.py +244 -0
- deft_subspace/kernel/clock.py +39 -0
- deft_subspace/kernel/errors.py +114 -0
- deft_subspace/kernel/framing.py +171 -0
- deft_subspace/kernel/process.py +614 -0
- deft_subspace/kernel/py.typed +0 -0
- deft_subspace/kernel/rpc.py +815 -0
- deft_subspace/kernel/testing.py +97 -0
- deft_subspace/kernel/trace.py +143 -0
- deft_subspace_kernel-0.1.0a1.dist-info/METADATA +41 -0
- deft_subspace_kernel-0.1.0a1.dist-info/RECORD +15 -0
- deft_subspace_kernel-0.1.0a1.dist-info/WHEEL +4 -0
- deft_subspace_kernel-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""One side of a JSON-RPC 2.0 connection.
|
|
2
|
+
|
|
3
|
+
Every outbound request has exactly one owner, the peer, and ends exactly once,
|
|
4
|
+
in `_settle()`: with the answer, or with a `LocalError` saying why we stopped
|
|
5
|
+
waiting (`aborted`, `timeout`, `closed`). Late answers are reported, never
|
|
6
|
+
delivered.
|
|
7
|
+
|
|
8
|
+
Inbound requests run concurrently, so a handler that waits on a call back to
|
|
9
|
+
the other side never stops other messages from being read. Notification
|
|
10
|
+
handlers start in arrival order (a synchronous handler runs to completion
|
|
11
|
+
before the next message is read).
|
|
12
|
+
|
|
13
|
+
When the other side ends its stream, outbound requests fail with `closed` at
|
|
14
|
+
once, but inbound requests already running may still answer (see
|
|
15
|
+
`drain_timeout_ms`), so `printf '{"id":1,"method":"x"}\n' | agent` is answered.
|
|
16
|
+
|
|
17
|
+
Handlers and middleware may be plain functions or coroutine functions.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import inspect
|
|
24
|
+
import math
|
|
25
|
+
import sys
|
|
26
|
+
from collections import deque
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
from .abort import AbortController, AbortSignal
|
|
32
|
+
from .channel import Channel, ChannelClosed, EndOfChannel
|
|
33
|
+
from .clock import Clock, system_clock
|
|
34
|
+
from .errors import ErrorCode, LocalError, RpcError
|
|
35
|
+
from .framing import EncodeError
|
|
36
|
+
|
|
37
|
+
JsonRpcId = str | int | float | None
|
|
38
|
+
"""JSON-RPC ids are opaque. `0` and `""` are as valid as any other id, and
|
|
39
|
+
JSON-RPC 2.0 also allows `null` (discouraged) for requests we receive.
|
|
40
|
+
Requests we send always get an int."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class InboundContext:
|
|
45
|
+
method: str
|
|
46
|
+
#: Fires when the other side cancels this request, or the connection closes.
|
|
47
|
+
signal: AbortSignal
|
|
48
|
+
#: The peer, for calling back to the other side while handling.
|
|
49
|
+
peer: RpcPeer
|
|
50
|
+
#: The request's id. `None` for notifications (see `is_request`); a request's id may also be None.
|
|
51
|
+
id: JsonRpcId = None
|
|
52
|
+
is_request: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
RequestHandler = Callable[[Any, InboundContext], Any]
|
|
56
|
+
NotificationHandler = Callable[[Any, InboundContext], Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Call:
|
|
61
|
+
"""A message as middleware sees it."""
|
|
62
|
+
|
|
63
|
+
kind: Literal["request", "notification"]
|
|
64
|
+
method: str
|
|
65
|
+
params: Any
|
|
66
|
+
id: JsonRpcId = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
InboundMiddleware = Callable[[Call, Callable[[], Awaitable[Any]], InboundContext], Awaitable[Any]]
|
|
70
|
+
"""Wraps inbound handling. Await `next()` to run the handler (and inner middleware)."""
|
|
71
|
+
OutboundMiddleware = Callable[[Call, Callable[[Any], Awaitable[Any]]], Awaitable[Any]]
|
|
72
|
+
"""Wraps outbound calls. `await next(params)` sends; it returns the result (requests) or None (notifications)."""
|
|
73
|
+
|
|
74
|
+
PeerState = Literal["idle", "open", "draining", "closed"]
|
|
75
|
+
"""`idle`: not started. `open`: reading and writing. `draining`: the other side
|
|
76
|
+
ended its stream; running handlers finish and answer, then the peer closes.
|
|
77
|
+
`closed`: nothing more is read or written."""
|
|
78
|
+
|
|
79
|
+
DEFAULT_DRAIN_MS = 1000
|
|
80
|
+
|
|
81
|
+
DiagnosticKind = Literal["invalid-message", "late-response", "handler-failed", "duplicate-request-id", "send-failed"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class PeerDiagnostic:
|
|
86
|
+
kind: DiagnosticKind
|
|
87
|
+
message: str
|
|
88
|
+
detail: Any = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class CloseInfo:
|
|
93
|
+
"""`local`: close() was called. `remote-ended`: the other side's stream ended (also when
|
|
94
|
+
close() is called while the peer drains after that: the other side ended it first).
|
|
95
|
+
`error`: the stream failed."""
|
|
96
|
+
|
|
97
|
+
reason: Literal["local", "remote-ended", "error"]
|
|
98
|
+
error: BaseException | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class CancelOutbound:
|
|
103
|
+
method: str
|
|
104
|
+
params: Callable[[JsonRpcId], Any]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class CancelInbound:
|
|
109
|
+
method: str
|
|
110
|
+
id: Callable[[Any], Any]
|
|
111
|
+
"""The id of the request to cancel, or `NO_ID`."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _NoId:
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return "NO_ID"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
NO_ID: Any = _NoId()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cancel_request_id(params: Any) -> Any:
|
|
123
|
+
request_id = params.get("requestId", NO_ID) if isinstance(params, dict) else NO_ID
|
|
124
|
+
return request_id if request_id is None or isinstance(request_id, (str, int, float)) and not isinstance(request_id, bool) else NO_ID
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
cancel_request: dict[str, Any] = {
|
|
128
|
+
"cancel_outbound": CancelOutbound("$/cancel_request", lambda request_id: {"requestId": request_id}),
|
|
129
|
+
"cancel_inbound": CancelInbound("$/cancel_request", _cancel_request_id),
|
|
130
|
+
}
|
|
131
|
+
"""Request cancellation as ACP and LSP do it: `$/cancel_request` with `{requestId}`, both ways.
|
|
132
|
+
|
|
133
|
+
RpcPeer(channel, **cancel_request)
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_id(value: Any) -> bool:
|
|
138
|
+
if value is None or isinstance(value, str):
|
|
139
|
+
return True
|
|
140
|
+
if isinstance(value, bool):
|
|
141
|
+
return False
|
|
142
|
+
if isinstance(value, int):
|
|
143
|
+
return True
|
|
144
|
+
return isinstance(value, float) and math.isfinite(value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _resolve(value: Any) -> Any:
|
|
148
|
+
return await value if inspect.isawaitable(value) else value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class _Pending:
|
|
153
|
+
method: str
|
|
154
|
+
future: asyncio.Future[Any]
|
|
155
|
+
cleanups: list[Callable[[], None]] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
#: Python 3.12+ can start a task eagerly: it runs at once, in its own task, until it first
|
|
159
|
+
#: waits, so a write or a handler that does not wait costs no trip through the event loop.
|
|
160
|
+
#:
|
|
161
|
+
#: Not when the loop has a task factory (`loop.set_task_factory`): an eager task would
|
|
162
|
+
#: bypass it, so then tasks are created as usual. And a handler that raises SystemExit or
|
|
163
|
+
#: KeyboardInterrupt before its first `await` raises it inside the read loop, which ends
|
|
164
|
+
#: with it; asyncio lets it out of the event loop then, as it does from a lazy task.
|
|
165
|
+
_EAGER = sys.version_info >= (3, 12)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _new_task(coroutine: Any, name: str) -> asyncio.Task[Any]:
|
|
169
|
+
loop = asyncio.get_running_loop()
|
|
170
|
+
if _EAGER and loop.get_task_factory() is None:
|
|
171
|
+
return asyncio.Task(coroutine, loop=loop, name=name, eager_start=True) # type: ignore[call-arg]
|
|
172
|
+
return loop.create_task(coroutine, name=name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _being_cancelled() -> bool:
|
|
176
|
+
"""Whether the running task has been asked to cancel (and has not uncancelled)."""
|
|
177
|
+
task = asyncio.current_task()
|
|
178
|
+
return task is not None and task.cancelling() > 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _id_key(value: Any) -> Any:
|
|
182
|
+
# 1 and 1.0 are the same JSON number; "1" is not.
|
|
183
|
+
if isinstance(value, float) and value.is_integer():
|
|
184
|
+
return ("n", int(value))
|
|
185
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
186
|
+
return ("n", value)
|
|
187
|
+
return ("s", value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class RpcPeer:
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
channel: Channel[Any],
|
|
194
|
+
*,
|
|
195
|
+
name: str = "peer",
|
|
196
|
+
clock: Clock | None = None,
|
|
197
|
+
timeout_ms: float | None = None,
|
|
198
|
+
cancel_outbound: CancelOutbound | None = None,
|
|
199
|
+
cancel_inbound: CancelInbound | None = None,
|
|
200
|
+
inbound: list[InboundMiddleware] | None = None,
|
|
201
|
+
outbound: list[OutboundMiddleware] | None = None,
|
|
202
|
+
tap: Callable[[Literal["in", "out"], Any], None] | None = None,
|
|
203
|
+
on_diagnostic: Callable[[PeerDiagnostic], None] | None = None,
|
|
204
|
+
drain_timeout_ms: float = DEFAULT_DRAIN_MS,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Options:
|
|
207
|
+
|
|
208
|
+
- `timeout_ms`: default timeout for outbound requests. Default: none.
|
|
209
|
+
- `cancel_outbound`: how to tell the other side we stopped waiting (abort or timeout),
|
|
210
|
+
e.g. `$/cancel_request` (see `cancel_request`). Default: nothing is sent.
|
|
211
|
+
- `cancel_inbound`: which inbound notification cancels one of our handlers, and how to find its id.
|
|
212
|
+
- `tap`: sees every message in both directions, e.g. for tracing. Must not raise.
|
|
213
|
+
- `drain_timeout_ms`: when the other side ends its stream, how long request handlers
|
|
214
|
+
still running may take to answer before they are aborted. 0 aborts them at once;
|
|
215
|
+
infinity waits for them. Default 1000.
|
|
216
|
+
"""
|
|
217
|
+
self.name = name
|
|
218
|
+
self._channel = channel
|
|
219
|
+
self._clock = clock or system_clock
|
|
220
|
+
self._timeout_ms = timeout_ms
|
|
221
|
+
self._cancel_outbound = cancel_outbound
|
|
222
|
+
self._cancel_inbound = cancel_inbound
|
|
223
|
+
self._inbound_chain = list(inbound or [])
|
|
224
|
+
self._outbound_chain = list(outbound or [])
|
|
225
|
+
self._tap_fn = tap
|
|
226
|
+
self._on_diagnostic = on_diagnostic
|
|
227
|
+
self._drain_ms = drain_timeout_ms
|
|
228
|
+
self._request_handlers: dict[str, RequestHandler] = {}
|
|
229
|
+
self._notification_handlers: dict[str, NotificationHandler] = {}
|
|
230
|
+
self._pending: dict[Any, _Pending] = {}
|
|
231
|
+
self._inbound: dict[Any, AbortController] = {}
|
|
232
|
+
self._connection = AbortController()
|
|
233
|
+
self._next_id = 0
|
|
234
|
+
self._state: PeerState = "idle"
|
|
235
|
+
self._running = 0 # inbound request handlers still running
|
|
236
|
+
self._drain_timer: Any = None
|
|
237
|
+
self._close_info: CloseInfo | None = None
|
|
238
|
+
self._closed_future: asyncio.Future[CloseInfo] | None = None
|
|
239
|
+
self._close_listeners: list[Callable[[CloseInfo], None]] = []
|
|
240
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
241
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
242
|
+
# Writes go out in the order they were made, one at a time: a writer task runs while
|
|
243
|
+
# there is something to write (and closes the channel once the peer has closed).
|
|
244
|
+
self._outbox: deque[tuple[dict[str, Any], asyncio.Future[None]]] = deque()
|
|
245
|
+
self._writer_task: asyncio.Task[None] | None = None
|
|
246
|
+
self._writing = False
|
|
247
|
+
|
|
248
|
+
# ---- state ---------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def is_closed(self) -> bool:
|
|
252
|
+
"""True once the connection has closed for any reason."""
|
|
253
|
+
return self._state == "closed"
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def state(self) -> PeerState:
|
|
257
|
+
return self._state
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def close_info(self) -> CloseInfo | None:
|
|
261
|
+
return self._close_info
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def pending_count(self) -> int:
|
|
265
|
+
"""Outbound requests still waiting for an answer."""
|
|
266
|
+
return len(self._pending)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def signal(self) -> AbortSignal:
|
|
270
|
+
"""Aborted when the connection closes."""
|
|
271
|
+
return self._connection.signal
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def closed(self) -> asyncio.Future[CloseInfo]:
|
|
275
|
+
"""Resolves with the CloseInfo once the connection has closed."""
|
|
276
|
+
if self._closed_future is None:
|
|
277
|
+
self._closed_future = asyncio.get_running_loop().create_future()
|
|
278
|
+
if self._close_info is not None:
|
|
279
|
+
self._closed_future.set_result(self._close_info)
|
|
280
|
+
return self._closed_future
|
|
281
|
+
|
|
282
|
+
def on_close(self, listener: Callable[[CloseInfo], None]) -> None:
|
|
283
|
+
"""Call `listener(info)` when the connection closes (at once, if it has)."""
|
|
284
|
+
if self._close_info is not None:
|
|
285
|
+
listener(self._close_info)
|
|
286
|
+
else:
|
|
287
|
+
self._close_listeners.append(listener)
|
|
288
|
+
|
|
289
|
+
# ---- handlers ------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def on_request(self, method: str, handler: RequestHandler) -> RpcPeer:
|
|
292
|
+
"""Handle a request. `'*'` handles every method without its own handler (see `context.method`)."""
|
|
293
|
+
self._request_handlers[method] = handler
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def on_notification(self, method: str, handler: NotificationHandler) -> RpcPeer:
|
|
297
|
+
"""Handle a notification. `'*'` handles every method without its own handler."""
|
|
298
|
+
self._notification_handlers[method] = handler
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
def start(self) -> RpcPeer:
|
|
302
|
+
"""Start reading. Register handlers first. Needs a running event loop."""
|
|
303
|
+
if self._state != "idle":
|
|
304
|
+
return self
|
|
305
|
+
self._state = "open"
|
|
306
|
+
self._read_task = asyncio.get_running_loop().create_task(self._read_loop(), name=f"{self.name}:read")
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
# ---- outbound ------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
async def request(
|
|
312
|
+
self,
|
|
313
|
+
method: str,
|
|
314
|
+
params: Any = None,
|
|
315
|
+
*,
|
|
316
|
+
signal: AbortSignal | None = None,
|
|
317
|
+
timeout_ms: float | None = None,
|
|
318
|
+
) -> Any:
|
|
319
|
+
"""Send a request and wait for its result.
|
|
320
|
+
|
|
321
|
+
Stops waiting (with a LocalError) when `signal` fires (`aborted`), after `timeout_ms`
|
|
322
|
+
(`timeout`; overrides the peer's default; 0 or infinity means no limit), or when the
|
|
323
|
+
connection closes (`closed`). Cancelling the awaiting task also gives up on the request
|
|
324
|
+
(and tells the other side), then re-raises CancelledError.
|
|
325
|
+
"""
|
|
326
|
+
call = Call("request", method, params)
|
|
327
|
+
return await self._run_outbound(call, lambda final: self._send_request(method, final, signal, timeout_ms))
|
|
328
|
+
|
|
329
|
+
async def notify(self, method: str, params: Any = None) -> None:
|
|
330
|
+
call = Call("notification", method, params)
|
|
331
|
+
|
|
332
|
+
async def send(final: Any) -> None:
|
|
333
|
+
message = self._message(method=method)
|
|
334
|
+
if final is not None:
|
|
335
|
+
message["params"] = final
|
|
336
|
+
await self._write(message)
|
|
337
|
+
|
|
338
|
+
await self._run_outbound(call, send)
|
|
339
|
+
|
|
340
|
+
async def send_raw(self, message: dict[str, Any]) -> None:
|
|
341
|
+
"""Write a message exactly as given (`jsonrpc: '2.0'` is added if missing), outside request tracking.
|
|
342
|
+
|
|
343
|
+
For proxies and adapters, e.g. answering an id this peer did not receive.
|
|
344
|
+
"""
|
|
345
|
+
await self._write(message if "jsonrpc" in message else {"jsonrpc": "2.0", **message})
|
|
346
|
+
|
|
347
|
+
async def close(self) -> CloseInfo:
|
|
348
|
+
"""Close the connection. Pending requests fail with `closed`; running handlers are aborted.
|
|
349
|
+
|
|
350
|
+
While draining, this ends the drain early and the reason stays `remote-ended`.
|
|
351
|
+
"""
|
|
352
|
+
self._finish(CloseInfo("remote-ended" if self._state == "draining" else "local"))
|
|
353
|
+
task = self._read_task
|
|
354
|
+
if task is not None and not task.done() and task is not asyncio.current_task():
|
|
355
|
+
task.cancel()
|
|
356
|
+
try:
|
|
357
|
+
await task
|
|
358
|
+
except asyncio.CancelledError:
|
|
359
|
+
if _being_cancelled():
|
|
360
|
+
raise # our caller's cancellation, not the reader's
|
|
361
|
+
except BaseException: # noqa: BLE001
|
|
362
|
+
pass
|
|
363
|
+
writer = self._writer_task
|
|
364
|
+
if writer is not None and not writer.done() and writer is not asyncio.current_task():
|
|
365
|
+
try:
|
|
366
|
+
await asyncio.wait_for(asyncio.shield(writer), self._close_wait_s)
|
|
367
|
+
except TimeoutError:
|
|
368
|
+
# The channel is not taking writes (a full pipe nobody reads): drop them.
|
|
369
|
+
writer.cancel()
|
|
370
|
+
try:
|
|
371
|
+
await writer
|
|
372
|
+
except asyncio.CancelledError:
|
|
373
|
+
if _being_cancelled():
|
|
374
|
+
raise # our caller's cancellation, not the writer's
|
|
375
|
+
except BaseException: # noqa: BLE001
|
|
376
|
+
pass
|
|
377
|
+
await self._abort_channel()
|
|
378
|
+
except Exception: # noqa: BLE001
|
|
379
|
+
pass
|
|
380
|
+
return self._close_info # type: ignore[return-value]
|
|
381
|
+
|
|
382
|
+
#: How long close() waits for queued writes before it drops them.
|
|
383
|
+
_close_wait_s = 1.0
|
|
384
|
+
|
|
385
|
+
async def _abort_channel(self) -> None:
|
|
386
|
+
while self._outbox:
|
|
387
|
+
_message, future = self._outbox.popleft()
|
|
388
|
+
if not future.done():
|
|
389
|
+
future.set_exception(LocalError("closed", f"{self.name}: connection closed"))
|
|
390
|
+
abort = getattr(self._channel, "abort", None)
|
|
391
|
+
try:
|
|
392
|
+
await _resolve(abort() if abort is not None else self._channel.close())
|
|
393
|
+
except Exception: # noqa: BLE001
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
def _run_outbound(self, call: Call, send: Callable[[Any], Awaitable[Any]]) -> Awaitable[Any]:
|
|
397
|
+
chain = self._outbound_chain
|
|
398
|
+
if not chain:
|
|
399
|
+
return send(call.params)
|
|
400
|
+
|
|
401
|
+
def dispatch(index: int, params: Any) -> Awaitable[Any]:
|
|
402
|
+
if index >= len(chain):
|
|
403
|
+
return send(params)
|
|
404
|
+
middleware = chain[index]
|
|
405
|
+
return middleware(Call(call.kind, call.method, params, call.id), lambda nxt: dispatch(index + 1, nxt))
|
|
406
|
+
|
|
407
|
+
return dispatch(0, call.params)
|
|
408
|
+
|
|
409
|
+
async def _send_request(self, method: str, params: Any, signal: AbortSignal | None, timeout_ms: float | None) -> Any:
|
|
410
|
+
# While draining nothing more is read, so no answer could arrive.
|
|
411
|
+
if self._state in ("draining", "closed"):
|
|
412
|
+
raise LocalError("closed", f"{self.name}: connection closed before {method} was sent", method=method)
|
|
413
|
+
if signal is not None and signal.aborted:
|
|
414
|
+
raise LocalError("aborted", f"{self.name}: {method} aborted before it was sent", method=method, cause=signal.reason)
|
|
415
|
+
|
|
416
|
+
request_id = self._next_id
|
|
417
|
+
self._next_id += 1
|
|
418
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
419
|
+
pending = _Pending(method, future)
|
|
420
|
+
key = _id_key(request_id)
|
|
421
|
+
self._pending[key] = pending
|
|
422
|
+
|
|
423
|
+
limit = self._timeout_ms if timeout_ms is None else timeout_ms
|
|
424
|
+
if limit is not None and limit > 0 and math.isfinite(limit):
|
|
425
|
+
|
|
426
|
+
def on_timeout() -> None:
|
|
427
|
+
self._give_up(request_id, LocalError("timeout", f"{self.name}: {method} timed out after {limit:g} ms", method=method))
|
|
428
|
+
|
|
429
|
+
handle = self._clock.set_timeout(on_timeout, limit)
|
|
430
|
+
pending.cleanups.append(lambda: self._clock.clear_timeout(handle))
|
|
431
|
+
|
|
432
|
+
if signal is not None:
|
|
433
|
+
sig = signal
|
|
434
|
+
|
|
435
|
+
def on_abort(reason: Any) -> None:
|
|
436
|
+
self._give_up(request_id, LocalError("aborted", f"{self.name}: {method} aborted", method=method, cause=reason))
|
|
437
|
+
|
|
438
|
+
pending.cleanups.append(sig.add_listener(on_abort))
|
|
439
|
+
|
|
440
|
+
message = self._message(id=request_id, method=method)
|
|
441
|
+
if params is not None:
|
|
442
|
+
message["params"] = params
|
|
443
|
+
|
|
444
|
+
def on_written(written: asyncio.Future[None]) -> None:
|
|
445
|
+
error = written.exception() if not written.cancelled() else None
|
|
446
|
+
if error is not None:
|
|
447
|
+
self._settle(key, error=LocalError("send-failed", f"{self.name}: could not send {method}", method=method, cause=error))
|
|
448
|
+
|
|
449
|
+
self._write(message).add_done_callback(on_written)
|
|
450
|
+
try:
|
|
451
|
+
return await future
|
|
452
|
+
except asyncio.CancelledError:
|
|
453
|
+
if key in self._pending:
|
|
454
|
+
self._give_up(request_id, LocalError("aborted", f"{self.name}: {method} aborted", method=method))
|
|
455
|
+
raise
|
|
456
|
+
|
|
457
|
+
def _give_up(self, request_id: JsonRpcId, error: LocalError) -> None:
|
|
458
|
+
"""We stopped waiting: settle, then tell the other side if configured."""
|
|
459
|
+
if not self._settle(_id_key(request_id), error=error):
|
|
460
|
+
return
|
|
461
|
+
cancel = self._cancel_outbound
|
|
462
|
+
if cancel is not None and self._state == "open":
|
|
463
|
+
self._write_quiet(self._message(method=cancel.method, params=cancel.params(request_id)))
|
|
464
|
+
|
|
465
|
+
_UNSET: Any = object()
|
|
466
|
+
|
|
467
|
+
def _settle(self, key: Any, value: Any = _UNSET, error: BaseException | None = None) -> bool:
|
|
468
|
+
"""The only place a pending request ends (`key` is `_id_key(id)`). Returns False if it had already ended."""
|
|
469
|
+
pending = self._pending.pop(key, None)
|
|
470
|
+
if pending is None:
|
|
471
|
+
return False
|
|
472
|
+
for cleanup in pending.cleanups:
|
|
473
|
+
try:
|
|
474
|
+
cleanup()
|
|
475
|
+
except Exception: # noqa: BLE001
|
|
476
|
+
pass
|
|
477
|
+
if not pending.future.done():
|
|
478
|
+
if error is not None:
|
|
479
|
+
pending.future.set_exception(error)
|
|
480
|
+
else:
|
|
481
|
+
pending.future.set_result(value)
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
# ---- inbound -------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
async def _read_loop(self) -> None:
|
|
487
|
+
try:
|
|
488
|
+
while True:
|
|
489
|
+
try:
|
|
490
|
+
message = await self._channel.receive()
|
|
491
|
+
except EndOfChannel:
|
|
492
|
+
break
|
|
493
|
+
self._receive(message)
|
|
494
|
+
if self._state == "closed":
|
|
495
|
+
# A handler closed the peer (on 3.12+ it runs eagerly, inside this loop):
|
|
496
|
+
# nothing more is read, even what is already buffered.
|
|
497
|
+
break
|
|
498
|
+
self._drain()
|
|
499
|
+
except asyncio.CancelledError:
|
|
500
|
+
self._finish(self._close_info or CloseInfo("local"))
|
|
501
|
+
raise
|
|
502
|
+
except Exception as error: # noqa: BLE001
|
|
503
|
+
self._finish(self._close_info or CloseInfo("error", error))
|
|
504
|
+
|
|
505
|
+
def _drain(self) -> None:
|
|
506
|
+
"""The other side ended its stream: fail what waits on it, let running handlers answer, then close."""
|
|
507
|
+
if self._state != "open":
|
|
508
|
+
return
|
|
509
|
+
self._state = "draining"
|
|
510
|
+
self._fail_pending(CloseInfo("remote-ended"))
|
|
511
|
+
limit = self._drain_ms
|
|
512
|
+
if self._running == 0 or not limit > 0:
|
|
513
|
+
self._finish(CloseInfo("remote-ended"))
|
|
514
|
+
elif math.isfinite(limit):
|
|
515
|
+
self._drain_timer = self._clock.set_timeout(lambda: self._finish(CloseInfo("remote-ended")), limit)
|
|
516
|
+
|
|
517
|
+
def _receive(self, message: Any) -> None:
|
|
518
|
+
if self._state == "closed":
|
|
519
|
+
return # nothing is dispatched on a closed connection
|
|
520
|
+
self._tap("in", message)
|
|
521
|
+
if not isinstance(message, dict):
|
|
522
|
+
self._diagnose("invalid-message", "Message is not a JSON object", message)
|
|
523
|
+
if isinstance(message, list):
|
|
524
|
+
self._reply_error(None, ErrorCode.INVALID_REQUEST, "Batches are not supported")
|
|
525
|
+
return
|
|
526
|
+
method = message.get("method")
|
|
527
|
+
has_id = "id" in message
|
|
528
|
+
message_id = message.get("id")
|
|
529
|
+
if isinstance(method, str):
|
|
530
|
+
if has_id:
|
|
531
|
+
if _is_id(message_id):
|
|
532
|
+
self._handle_request(message_id, method, message.get("params"))
|
|
533
|
+
else:
|
|
534
|
+
self._reply_error(None, ErrorCode.INVALID_REQUEST, "Request id must be a string, number or null")
|
|
535
|
+
return
|
|
536
|
+
self._handle_notification(method, message.get("params"))
|
|
537
|
+
return
|
|
538
|
+
if has_id and ("result" in message or "error" in message):
|
|
539
|
+
self._handle_response(message)
|
|
540
|
+
return
|
|
541
|
+
self._diagnose("invalid-message", "Message is not a request, notification or response", message)
|
|
542
|
+
if has_id and _is_id(message_id):
|
|
543
|
+
self._reply_error(message_id, ErrorCode.INVALID_REQUEST, "Invalid request")
|
|
544
|
+
|
|
545
|
+
def _handle_response(self, message: dict[str, Any]) -> None:
|
|
546
|
+
message_id = message.get("id")
|
|
547
|
+
key = _id_key(message_id) if _is_id(message_id) and message_id is not None else None
|
|
548
|
+
if key is None or key not in self._pending:
|
|
549
|
+
self._diagnose("late-response", f"Response for unknown or finished request {message_id!r}", message)
|
|
550
|
+
return
|
|
551
|
+
error = message.get("error")
|
|
552
|
+
if error is not None:
|
|
553
|
+
if (
|
|
554
|
+
isinstance(error, dict)
|
|
555
|
+
and isinstance(error.get("code"), int)
|
|
556
|
+
and not isinstance(error.get("code"), bool)
|
|
557
|
+
and isinstance(error.get("message"), str)
|
|
558
|
+
):
|
|
559
|
+
rpc = RpcError(error["code"], error["message"], error["data"]) if "data" in error else RpcError(error["code"], error["message"])
|
|
560
|
+
self._settle(key, error=rpc)
|
|
561
|
+
else:
|
|
562
|
+
method = self._pending[key].method
|
|
563
|
+
self._settle(
|
|
564
|
+
key,
|
|
565
|
+
error=LocalError("invalid-response", f"{self.name}: malformed error for {method}", method=method, cause=error),
|
|
566
|
+
)
|
|
567
|
+
return
|
|
568
|
+
self._settle(key, value=message.get("result"))
|
|
569
|
+
|
|
570
|
+
def _handle_request(self, request_id: JsonRpcId, method: str, params: Any) -> None:
|
|
571
|
+
key = _id_key(request_id)
|
|
572
|
+
if key in self._inbound:
|
|
573
|
+
self._diagnose("duplicate-request-id", f"Request id {request_id!r} reused while in flight")
|
|
574
|
+
controller = AbortController()
|
|
575
|
+
if self._connection.signal.aborted: # a listener added now would never be called
|
|
576
|
+
controller.abort(LocalError("closed", "connection closed"))
|
|
577
|
+
remove_close = self._connection.signal.add_listener(lambda _reason: controller.abort(LocalError("closed", "connection closed")))
|
|
578
|
+
self._inbound[key] = controller
|
|
579
|
+
context = InboundContext(method=method, signal=controller.signal, peer=self, id=request_id, is_request=True)
|
|
580
|
+
|
|
581
|
+
async def run() -> None:
|
|
582
|
+
try:
|
|
583
|
+
handler = self._request_handlers.get(method) or self._request_handlers.get("*")
|
|
584
|
+
|
|
585
|
+
async def handle() -> Any:
|
|
586
|
+
if handler is None:
|
|
587
|
+
raise RpcError.method_not_found(method)
|
|
588
|
+
return await _resolve(handler(params, context))
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
if self._inbound_chain:
|
|
592
|
+
result = await self._run_inbound(Call("request", method, params, request_id), context, handle)
|
|
593
|
+
elif handler is None:
|
|
594
|
+
raise RpcError.method_not_found(method)
|
|
595
|
+
else:
|
|
596
|
+
result = handler(params, context)
|
|
597
|
+
if inspect.isawaitable(result):
|
|
598
|
+
result = await result
|
|
599
|
+
reply = self._message(id=request_id, result=result)
|
|
600
|
+
except asyncio.CancelledError:
|
|
601
|
+
if self._state != "closed":
|
|
602
|
+
self._reply_error(request_id, ErrorCode.REQUEST_CANCELLED, "Request cancelled")
|
|
603
|
+
raise
|
|
604
|
+
except Exception as error: # noqa: BLE001
|
|
605
|
+
if self._state == "closed":
|
|
606
|
+
return
|
|
607
|
+
if isinstance(error, RpcError):
|
|
608
|
+
reply = self._message(id=request_id, error=error.to_json())
|
|
609
|
+
elif controller.signal.aborted:
|
|
610
|
+
reply = self._message(id=request_id, error={"code": ErrorCode.REQUEST_CANCELLED, "message": "Request cancelled"})
|
|
611
|
+
else:
|
|
612
|
+
self._diagnose("handler-failed", f"Handler for {method} raised", error)
|
|
613
|
+
message = str(error) or type(error).__name__
|
|
614
|
+
reply = self._message(id=request_id, error={"code": ErrorCode.INTERNAL_ERROR, "message": message})
|
|
615
|
+
await self._answer(request_id, method, reply)
|
|
616
|
+
finally:
|
|
617
|
+
if self._inbound.get(key) is controller:
|
|
618
|
+
del self._inbound[key]
|
|
619
|
+
remove_close()
|
|
620
|
+
self._running -= 1
|
|
621
|
+
if self._running == 0 and self._state == "draining":
|
|
622
|
+
self._finish(CloseInfo("remote-ended"))
|
|
623
|
+
|
|
624
|
+
self._running += 1
|
|
625
|
+
# Started eagerly where it can be: a handler that answers without waiting answers now.
|
|
626
|
+
self._spawn(run(), f"{self.name}:{method}", eager=True)
|
|
627
|
+
|
|
628
|
+
def _handle_notification(self, method: str, params: Any) -> None:
|
|
629
|
+
cancel = self._cancel_inbound
|
|
630
|
+
if cancel is not None and method == cancel.method:
|
|
631
|
+
try:
|
|
632
|
+
target = cancel.id(params)
|
|
633
|
+
except Exception as error: # noqa: BLE001
|
|
634
|
+
self._diagnose("invalid-message", f"Could not read the request id from {method}", error)
|
|
635
|
+
target = NO_ID
|
|
636
|
+
if target is not NO_ID:
|
|
637
|
+
controller = self._inbound.get(_id_key(target))
|
|
638
|
+
if controller is not None:
|
|
639
|
+
controller.abort(LocalError("aborted", "cancelled by the other side"))
|
|
640
|
+
handler = self._notification_handlers.get(method) or self._notification_handlers.get("*")
|
|
641
|
+
if handler is None:
|
|
642
|
+
return
|
|
643
|
+
context = InboundContext(method=method, signal=self._connection.signal, peer=self)
|
|
644
|
+
|
|
645
|
+
def fail(error: BaseException) -> None:
|
|
646
|
+
self._diagnose("handler-failed", f"Notification handler for {method} raised", error)
|
|
647
|
+
|
|
648
|
+
if not self._inbound_chain:
|
|
649
|
+
# No middleware: a plain handler runs now, in arrival order.
|
|
650
|
+
try:
|
|
651
|
+
result = handler(params, context)
|
|
652
|
+
except Exception as error: # noqa: BLE001
|
|
653
|
+
fail(error)
|
|
654
|
+
return
|
|
655
|
+
if inspect.isawaitable(result):
|
|
656
|
+
self._spawn(self._guard(result, fail), f"{self.name}:{method}")
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
async def handle() -> Any:
|
|
660
|
+
return await _resolve(handler(params, context))
|
|
661
|
+
|
|
662
|
+
self._spawn(self._guard(self._run_inbound(Call("notification", method, params), context, handle), fail), f"{self.name}:{method}")
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
async def _guard(awaitable: Awaitable[Any], fail: Callable[[BaseException], None]) -> None:
|
|
666
|
+
try:
|
|
667
|
+
await awaitable
|
|
668
|
+
except Exception as error: # noqa: BLE001
|
|
669
|
+
fail(error)
|
|
670
|
+
|
|
671
|
+
async def _run_inbound(self, call: Call, context: InboundContext, handle: Callable[[], Awaitable[Any]]) -> Any:
|
|
672
|
+
chain = self._inbound_chain
|
|
673
|
+
|
|
674
|
+
async def dispatch(index: int) -> Any:
|
|
675
|
+
if index >= len(chain):
|
|
676
|
+
return await handle()
|
|
677
|
+
return await chain[index](call, lambda: dispatch(index + 1), context)
|
|
678
|
+
|
|
679
|
+
return await dispatch(0)
|
|
680
|
+
|
|
681
|
+
# ---- plumbing ------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def _spawn(self, coroutine: Any, name: str, *, eager: bool = False) -> asyncio.Task[Any]:
|
|
684
|
+
task = _new_task(coroutine, name) if eager else asyncio.get_running_loop().create_task(coroutine, name=name)
|
|
685
|
+
if not task.done():
|
|
686
|
+
self._tasks.add(task)
|
|
687
|
+
task.add_done_callback(self._tasks.discard)
|
|
688
|
+
return task
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _message(**body: Any) -> dict[str, Any]:
|
|
692
|
+
return {"jsonrpc": "2.0", **body}
|
|
693
|
+
|
|
694
|
+
async def _answer(self, request_id: JsonRpcId, method: str, reply: dict[str, Any]) -> None:
|
|
695
|
+
"""Send the answer to a request. One that cannot be encoded becomes an InternalError."""
|
|
696
|
+
try:
|
|
697
|
+
await self._write(reply)
|
|
698
|
+
except Exception as error: # noqa: BLE001
|
|
699
|
+
if isinstance(error, LocalError) and error.kind == "closed":
|
|
700
|
+
return
|
|
701
|
+
self._diagnose("send-failed", f"Could not send the answer for {method}", error)
|
|
702
|
+
if isinstance(error, EncodeError):
|
|
703
|
+
self._reply_error(request_id, ErrorCode.INTERNAL_ERROR, f"The answer for {method} could not be encoded")
|
|
704
|
+
|
|
705
|
+
def _reply_error(self, request_id: JsonRpcId, code: int, message: str) -> None:
|
|
706
|
+
self._write_quiet(self._message(id=request_id, error={"code": code, "message": message}))
|
|
707
|
+
|
|
708
|
+
def _write(self, message: dict[str, Any]) -> asyncio.Future[None]:
|
|
709
|
+
"""Queue a message. The future settles once it was handed to the channel (or could not be)."""
|
|
710
|
+
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
711
|
+
if self._state == "closed":
|
|
712
|
+
future.set_exception(LocalError("closed", f"{self.name}: connection closed"))
|
|
713
|
+
return future
|
|
714
|
+
self._tap("out", message)
|
|
715
|
+
self._outbox.append((message, future))
|
|
716
|
+
self._ensure_writer()
|
|
717
|
+
return future
|
|
718
|
+
|
|
719
|
+
def _write_quiet(self, message: dict[str, Any]) -> None:
|
|
720
|
+
future = self._write(message)
|
|
721
|
+
future.add_done_callback(lambda f: f.cancelled() or f.exception())
|
|
722
|
+
|
|
723
|
+
def _ensure_writer(self) -> None:
|
|
724
|
+
"""Start the writer unless it is running; it writes what is queued (in order), then
|
|
725
|
+
closes the channel if the peer has closed. Started eagerly where it can be, so a write
|
|
726
|
+
to a channel that takes it at once is done before this returns."""
|
|
727
|
+
if self._writing:
|
|
728
|
+
return
|
|
729
|
+
self._writing = True
|
|
730
|
+
try:
|
|
731
|
+
self._writer_task = _new_task(self._write_loop(), f"{self.name}:write")
|
|
732
|
+
except BaseException:
|
|
733
|
+
self._writing = False
|
|
734
|
+
raise
|
|
735
|
+
|
|
736
|
+
async def _write_loop(self) -> None:
|
|
737
|
+
try:
|
|
738
|
+
while self._outbox:
|
|
739
|
+
message, future = self._outbox.popleft()
|
|
740
|
+
try:
|
|
741
|
+
await self._channel.send(message)
|
|
742
|
+
except asyncio.CancelledError:
|
|
743
|
+
if not future.done():
|
|
744
|
+
future.set_exception(LocalError("closed", f"{self.name}: connection closed"))
|
|
745
|
+
raise
|
|
746
|
+
except BaseException as error: # noqa: BLE001
|
|
747
|
+
if not future.done():
|
|
748
|
+
future.set_exception(error if isinstance(error, Exception) else ChannelClosed(str(error)))
|
|
749
|
+
if isinstance(error, ChannelClosed) and self._state == "draining":
|
|
750
|
+
# The other side stopped reading too: no answer can get through.
|
|
751
|
+
self._finish(CloseInfo("remote-ended"))
|
|
752
|
+
else:
|
|
753
|
+
if not future.done():
|
|
754
|
+
future.set_result(None)
|
|
755
|
+
if self._close_info is not None:
|
|
756
|
+
try:
|
|
757
|
+
await self._channel.close()
|
|
758
|
+
except Exception: # noqa: BLE001
|
|
759
|
+
pass
|
|
760
|
+
finally:
|
|
761
|
+
self._writing = False
|
|
762
|
+
|
|
763
|
+
def _tap(self, direction: Literal["in", "out"], message: Any) -> None:
|
|
764
|
+
if self._tap_fn is None:
|
|
765
|
+
return
|
|
766
|
+
try:
|
|
767
|
+
self._tap_fn(direction, message)
|
|
768
|
+
except Exception: # noqa: BLE001 - taps are observers; they never change behaviour
|
|
769
|
+
pass
|
|
770
|
+
|
|
771
|
+
def _diagnose(self, kind: DiagnosticKind, message: str, detail: Any = None) -> None:
|
|
772
|
+
if self._on_diagnostic is None:
|
|
773
|
+
return
|
|
774
|
+
try:
|
|
775
|
+
self._on_diagnostic(PeerDiagnostic(kind, message, detail))
|
|
776
|
+
except Exception: # noqa: BLE001
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
def _finish(self, info: CloseInfo) -> None:
|
|
780
|
+
if self._state == "closed":
|
|
781
|
+
return
|
|
782
|
+
self._state = "closed"
|
|
783
|
+
self._close_info = info
|
|
784
|
+
if self._drain_timer is not None:
|
|
785
|
+
self._clock.clear_timeout(self._drain_timer)
|
|
786
|
+
self._drain_timer = None
|
|
787
|
+
self._connection.abort(LocalError("closed", f"{self.name}: connection closed"))
|
|
788
|
+
self._fail_pending(info)
|
|
789
|
+
# Flush what was queued, then end our side.
|
|
790
|
+
try:
|
|
791
|
+
self._ensure_writer()
|
|
792
|
+
except RuntimeError:
|
|
793
|
+
pass
|
|
794
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
795
|
+
self._closed_future.set_result(info)
|
|
796
|
+
listeners, self._close_listeners = self._close_listeners, []
|
|
797
|
+
for listener in listeners:
|
|
798
|
+
try:
|
|
799
|
+
listener(info)
|
|
800
|
+
except Exception: # noqa: BLE001
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
def _fail_pending(self, info: CloseInfo) -> None:
|
|
804
|
+
if info.reason == "remote-ended":
|
|
805
|
+
text = f"{self.name}: the other side closed the connection"
|
|
806
|
+
elif info.reason == "error":
|
|
807
|
+
text = f"{self.name}: the connection failed"
|
|
808
|
+
else:
|
|
809
|
+
text = f"{self.name}: connection closed"
|
|
810
|
+
for key in list(self._pending):
|
|
811
|
+
pending = self._pending.get(key)
|
|
812
|
+
if pending is None:
|
|
813
|
+
continue
|
|
814
|
+
method = pending.method
|
|
815
|
+
self._settle(key, error=LocalError("closed", f"{text} while waiting for {method}", method=method, cause=info.error))
|