muse-code-sdk 1.3.0__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.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
"""Duplex-agnostic MSP NDJSON connection (spec 638 FR-638-011..015).
|
|
2
|
+
|
|
3
|
+
Faithful asyncio port of ``clients/sdk-ts/src/connection/connection.ts``
|
|
4
|
+
(spec 14990 FR-012/014/015/016). The transport supplies decoded UTF-8 text
|
|
5
|
+
chunks; chunks may be empty and may split anywhere — Python strings are
|
|
6
|
+
whole code points, so the TS surrogate-pair byte reconciliation has no twin
|
|
7
|
+
here (spec 638 Edge Cases): the running byte count is exact by construction
|
|
8
|
+
and stays O(bytes) per chunk.
|
|
9
|
+
|
|
10
|
+
One deliberate adaptation, noted per the port rules: in TS "invoking
|
|
11
|
+
``write()`` IS submission" because the transport's synchronous prefix runs
|
|
12
|
+
before the call returns. Python coroutines have no synchronous prefix, so
|
|
13
|
+
submission here is the write task being scheduled on the loop — the
|
|
14
|
+
submission tail still settles for every accepted frame even when the peer
|
|
15
|
+
never completes the write, which is the property ``close()`` needs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, AsyncIterable, Awaitable, Callable, Protocol
|
|
26
|
+
|
|
27
|
+
from muse_code_msp import ErrorKind, RequestId
|
|
28
|
+
|
|
29
|
+
DEFAULT_FRAME_LIMIT_BYTES = 10_485_760
|
|
30
|
+
|
|
31
|
+
Params = dict[str, Any]
|
|
32
|
+
"""A JSON object payload (params, result, or error data)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DuplexTransport(Protocol):
|
|
36
|
+
"""The two-way text stream a :class:`Connection` speaks MSP over.
|
|
37
|
+
|
|
38
|
+
Implement it to run the protocol over any duplex channel: yield decoded
|
|
39
|
+
UTF-8 chunks on ``incoming`` and accept outgoing frames through
|
|
40
|
+
``write``. Chunks may be empty and may split anywhere; if your transport
|
|
41
|
+
decodes bytes itself, use a streaming decoder so a multi-byte character
|
|
42
|
+
split across byte chunks is never decoded into replacement characters.
|
|
43
|
+
|
|
44
|
+
A transport MAY additionally define ``close(flushed)`` (additive-optional,
|
|
45
|
+
discovered by ``getattr``): ``flushed`` settles once every frame the
|
|
46
|
+
connection had accepted when ``close()`` was called has been HANDED to
|
|
47
|
+
``write()`` — submission, not completion. A process-owning transport
|
|
48
|
+
waits for it inside its own shutdown budget before ending the peer's
|
|
49
|
+
input; it is never GATED by completion, because completion is exactly
|
|
50
|
+
what a wedged peer withholds.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def incoming(self) -> AsyncIterable[str]:
|
|
55
|
+
"""Decoded UTF-8 chunks read from the peer."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
async def write(self, chunk: str) -> None:
|
|
59
|
+
"""Writes one already-framed line (with its trailing newline)."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
ServerRequestHandler = Callable[[Params], Awaitable[Params]]
|
|
64
|
+
"""Handles one server-initiated request; resolves with the result to send."""
|
|
65
|
+
|
|
66
|
+
NotificationHandler = Callable[[Params], None]
|
|
67
|
+
"""Receives every server notification frame, in arrival order."""
|
|
68
|
+
|
|
69
|
+
ProtocolErrorHandler = Callable[["ProtocolError"], None]
|
|
70
|
+
"""Receives each framing/correlation violation detected inbound."""
|
|
71
|
+
|
|
72
|
+
RetryDelay = Callable[[int, "MspError"], Awaitable[None]]
|
|
73
|
+
"""Waits between command attempts; injectable so tests never sleep."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MspError(Exception):
|
|
77
|
+
"""The one typed error family consumers branch on (INV-012).
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
code: The JSON-RPC error code.
|
|
81
|
+
kind: ``error.data.kind`` — the branch key; never message text.
|
|
82
|
+
data: The whole ``error.data`` object, verbatim.
|
|
83
|
+
retryable: ``error.data.retryable`` when the server sent one.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, error: Params) -> None:
|
|
87
|
+
super().__init__(str(error.get("message", "")))
|
|
88
|
+
data = error.get("data")
|
|
89
|
+
data_dict: Params = dict(data) if isinstance(data, dict) else {}
|
|
90
|
+
self.code: int = int(error["code"])
|
|
91
|
+
self.kind: ErrorKind = str(data_dict.get("kind", "unknown"))
|
|
92
|
+
self.data: Params = data_dict
|
|
93
|
+
retryable = data_dict.get("retryable")
|
|
94
|
+
self.retryable: bool | None = retryable if isinstance(retryable, bool) else None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def message(self) -> str:
|
|
98
|
+
"""The server's human-readable message (wording is not an API)."""
|
|
99
|
+
return str(self.args[0]) if self.args else ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProtocolError(Exception):
|
|
103
|
+
"""A local framing/correlation violation, never a server-authored error.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
line: The offending inbound line, preserved as evidence and bounded
|
|
107
|
+
in UTF-8 bytes (FM-007), when one exists.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, message: str, line: str | None = None) -> None:
|
|
111
|
+
super().__init__(message)
|
|
112
|
+
self.line = line
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def message(self) -> str:
|
|
116
|
+
"""The violation description."""
|
|
117
|
+
return str(self.args[0]) if self.args else ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _UnencodableFrameError(ProtocolError):
|
|
121
|
+
"""The OUTBOUND frame itself cannot encode as UTF-8 (a lone surrogate).
|
|
122
|
+
|
|
123
|
+
A caller defect, never transport death: it rejects only that frame's
|
|
124
|
+
outcome, so ``notify()``'s finish-on-write-failure rule must not treat
|
|
125
|
+
it as the pipe dying (PR #30094 review).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class _CommandMemory:
|
|
131
|
+
signature: str
|
|
132
|
+
ack: Params | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class _Pending:
|
|
137
|
+
future: asyncio.Future[Params]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _request_key(request_id: RequestId) -> str:
|
|
141
|
+
kind = "number" if isinstance(request_id, int) else "string"
|
|
142
|
+
return f"{kind}:{request_id}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _utf8_len(text: str) -> int:
|
|
146
|
+
return len(text.encode("utf-8", "surrogatepass"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _truncate_to_utf8_bytes(text: str, limit_bytes: int) -> str:
|
|
150
|
+
"""FM-007: error evidence is bounded in UTF-8 BYTES, never characters.
|
|
151
|
+
|
|
152
|
+
Encode once and back the cut up over continuation bytes (the
|
|
153
|
+
``StderrTail._trim_bytes`` pattern): a per-scalar walk re-encoded every
|
|
154
|
+
character and blocked the event loop for seconds at the frame limit
|
|
155
|
+
(PR #30094 review).
|
|
156
|
+
"""
|
|
157
|
+
data = text.encode("utf-8", "surrogatepass")
|
|
158
|
+
if len(data) <= limit_bytes:
|
|
159
|
+
return text
|
|
160
|
+
end = max(0, limit_bytes)
|
|
161
|
+
while end > 0 and (data[end] & 0xC0) == 0x80:
|
|
162
|
+
end -= 1
|
|
163
|
+
return data[:end].decode("utf-8", "surrogatepass")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _canonical(value: Any) -> str:
|
|
167
|
+
"""Order-insensitive canonical JSON, for signatures and ack identity."""
|
|
168
|
+
if isinstance(value, list):
|
|
169
|
+
return "[" + ",".join(_canonical(item) for item in value) + "]"
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
return (
|
|
172
|
+
"{"
|
|
173
|
+
+ ",".join(
|
|
174
|
+
f"{json.dumps(key)}:{_canonical(value[key])}" for key in sorted(value)
|
|
175
|
+
)
|
|
176
|
+
+ "}"
|
|
177
|
+
)
|
|
178
|
+
return json.dumps(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_uuid_v7_mint() -> Callable[[], str]:
|
|
182
|
+
"""Creates the SDK-owned UUIDv7 command-id source (INV-013).
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A zero-argument callable minting RFC 9562 v7-shaped ids, strictly
|
|
186
|
+
distinct under same-millisecond bursts via the 12-bit sequence.
|
|
187
|
+
"""
|
|
188
|
+
state = {"last_ms": -1, "sequence": 0}
|
|
189
|
+
|
|
190
|
+
def mint() -> str:
|
|
191
|
+
now = time.time_ns() // 1_000_000
|
|
192
|
+
if now <= state["last_ms"]:
|
|
193
|
+
now = state["last_ms"]
|
|
194
|
+
state["sequence"] += 1
|
|
195
|
+
if state["sequence"] > 0x0FFF:
|
|
196
|
+
now += 1
|
|
197
|
+
state["sequence"] = 0
|
|
198
|
+
else:
|
|
199
|
+
state["sequence"] = 0
|
|
200
|
+
state["last_ms"] = now
|
|
201
|
+
raw = bytearray(secrets.token_bytes(16))
|
|
202
|
+
timestamp = now
|
|
203
|
+
for index in range(5, -1, -1):
|
|
204
|
+
raw[index] = timestamp & 0xFF
|
|
205
|
+
timestamp //= 256
|
|
206
|
+
raw[6] = 0x70 | ((state["sequence"] >> 8) & 0x0F)
|
|
207
|
+
raw[7] = state["sequence"] & 0xFF
|
|
208
|
+
raw[8] = (raw[8] & 0x3F) | 0x80
|
|
209
|
+
digest = raw.hex()
|
|
210
|
+
return (
|
|
211
|
+
f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-"
|
|
212
|
+
f"{digest[16:20]}-{digest[20:]}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return mint
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _default_retry_delay(attempt: int, _error: MspError) -> None:
|
|
219
|
+
"""FM-006's full-jitter budget: uniform in 0..ceiling, 50 ms doubling, 2 s cap."""
|
|
220
|
+
ceiling_ms = min(2_000, 50 * 2 ** max(0, attempt - 1))
|
|
221
|
+
await asyncio.sleep(secrets.randbelow(ceiling_ms + 1) / 1000)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _valid_request_id(request_id: object) -> bool:
|
|
225
|
+
if isinstance(request_id, str):
|
|
226
|
+
return True
|
|
227
|
+
return isinstance(request_id, int) and not isinstance(request_id, bool)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _done_future(loop: asyncio.AbstractEventLoop) -> asyncio.Future[None]:
|
|
231
|
+
future: asyncio.Future[None] = loop.create_future()
|
|
232
|
+
future.set_result(None)
|
|
233
|
+
return future
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _both_done(
|
|
237
|
+
loop: asyncio.AbstractEventLoop,
|
|
238
|
+
first: asyncio.Future[None],
|
|
239
|
+
second: asyncio.Future[None],
|
|
240
|
+
) -> asyncio.Future[None]:
|
|
241
|
+
"""A future resolving once both inputs settled (never with an error)."""
|
|
242
|
+
joined: asyncio.Future[None] = loop.create_future()
|
|
243
|
+
|
|
244
|
+
def check(_f: asyncio.Future[None]) -> None:
|
|
245
|
+
if first.done() and second.done() and not joined.done():
|
|
246
|
+
joined.set_result(None)
|
|
247
|
+
|
|
248
|
+
first.add_done_callback(check)
|
|
249
|
+
second.add_done_callback(check)
|
|
250
|
+
return joined
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class Connection:
|
|
254
|
+
"""One MSP connection over newline-delimited JSON.
|
|
255
|
+
|
|
256
|
+
The connection sends your requests and commands, correlates their
|
|
257
|
+
responses, enforces the frame-size limit, and hands inbound
|
|
258
|
+
notifications and server requests to the handlers you register. It is
|
|
259
|
+
transport-agnostic: pair it with any :class:`DuplexTransport`. Construct
|
|
260
|
+
it inside a running event loop (the read loop starts immediately).
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self,
|
|
265
|
+
transport: DuplexTransport,
|
|
266
|
+
*,
|
|
267
|
+
frame_limit_bytes: int | None = None,
|
|
268
|
+
mint_command_id: Callable[[], str] | None = None,
|
|
269
|
+
mint_request_id: Callable[[], RequestId] | None = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Builds the connection and starts its read loop.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
transport: The duplex text channel.
|
|
275
|
+
frame_limit_bytes: Inbound frame-size sanity bound (SS1.1
|
|
276
|
+
default when omitted).
|
|
277
|
+
mint_command_id: Injectable command-id source (INV-013);
|
|
278
|
+
defaults to the UUIDv7 mint.
|
|
279
|
+
mint_request_id: Injectable for deterministic transcript
|
|
280
|
+
clients; values must be unique in flight.
|
|
281
|
+
"""
|
|
282
|
+
self._loop = asyncio.get_running_loop()
|
|
283
|
+
self._transport = transport
|
|
284
|
+
self._frame_limit_bytes = (
|
|
285
|
+
DEFAULT_FRAME_LIMIT_BYTES if frame_limit_bytes is None else frame_limit_bytes
|
|
286
|
+
)
|
|
287
|
+
self._mint_command_id = mint_command_id or create_uuid_v7_mint()
|
|
288
|
+
self._mint_request_id = mint_request_id or self._sequential_request_id
|
|
289
|
+
self._next_request_id = 1
|
|
290
|
+
self._pending: dict[str, _Pending] = {}
|
|
291
|
+
self._commands: dict[str, _CommandMemory] = {}
|
|
292
|
+
self._server_request_handler: ServerRequestHandler | None = None
|
|
293
|
+
self._notification_handler: NotificationHandler | None = None
|
|
294
|
+
self._protocol_error_handler: ProtocolErrorHandler | None = None
|
|
295
|
+
self._write_tail: asyncio.Future[None] = _done_future(self._loop)
|
|
296
|
+
self._submit_tail: asyncio.Future[None] = _done_future(self._loop)
|
|
297
|
+
self._write_failure: BaseException | None = None
|
|
298
|
+
self._segments: list[str] = []
|
|
299
|
+
self._buffer_bytes = 0
|
|
300
|
+
self._dropping_oversized = False
|
|
301
|
+
self._finished = False
|
|
302
|
+
self._closed: asyncio.Future[None] = self._loop.create_future()
|
|
303
|
+
self._read_task = self._loop.create_task(self._read_loop())
|
|
304
|
+
|
|
305
|
+
def _sequential_request_id(self) -> RequestId:
|
|
306
|
+
request_id = self._next_request_id
|
|
307
|
+
self._next_request_id += 1
|
|
308
|
+
return request_id
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def closed(self) -> asyncio.Future[None]:
|
|
312
|
+
"""Settles when the connection has finished (EOF, failure, close).
|
|
313
|
+
|
|
314
|
+
A fresh shield per access: a consumer's ``wait_for`` timeout must
|
|
315
|
+
cancel only its own wait, never the connection's one real future —
|
|
316
|
+
a cancelled ``_closed`` would break ``close()`` for everyone
|
|
317
|
+
(PR #30094 review; a TS Promise cannot be cancelled).
|
|
318
|
+
"""
|
|
319
|
+
return asyncio.shield(self._closed)
|
|
320
|
+
|
|
321
|
+
def _submission_tail(self) -> asyncio.Future[None]:
|
|
322
|
+
"""The friend seam a process-owning transport adopts as ``flushed``.
|
|
323
|
+
|
|
324
|
+
Module-private, like the TS twin's Symbol fence (PR #30094 review):
|
|
325
|
+
it lets a close routed around this class — the public
|
|
326
|
+
``child.close()`` — wait for accepted frames' SUBMISSION the same
|
|
327
|
+
way :meth:`close` does.
|
|
328
|
+
"""
|
|
329
|
+
return self._submit_tail
|
|
330
|
+
|
|
331
|
+
def mint_command_id(self) -> str:
|
|
332
|
+
"""Takes one id from THIS connection's single command-id minter.
|
|
333
|
+
|
|
334
|
+
Exposed for callers that need the id BEFORE the ack: the facade's
|
|
335
|
+
``send_user_turn`` records its optimistic SS4.13 entry under it. A
|
|
336
|
+
second facade-side minter is exactly what INV-013 forbids. Pair with
|
|
337
|
+
``command(..., command_id=...)``, which reuses the id.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
One freshly minted command id.
|
|
341
|
+
"""
|
|
342
|
+
return self._mint_command_id()
|
|
343
|
+
|
|
344
|
+
def request(
|
|
345
|
+
self, method: str, params: Params | None = None
|
|
346
|
+
) -> asyncio.Future[Params]:
|
|
347
|
+
"""Sends one request and correlates its response (SS1.3).
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
method: The MSP method name.
|
|
351
|
+
params: The params object, omitted from the frame when ``None``.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
A future resolving with the result object, or rejecting with an
|
|
355
|
+
:class:`MspError` (typed, INV-012) or :class:`ProtocolError`.
|
|
356
|
+
"""
|
|
357
|
+
outcome: asyncio.Future[Params] = self._loop.create_future()
|
|
358
|
+
if self._finished:
|
|
359
|
+
outcome.set_exception(ProtocolError("connection is closed"))
|
|
360
|
+
return outcome
|
|
361
|
+
request_id = self._mint_request_id()
|
|
362
|
+
if not _valid_request_id(request_id):
|
|
363
|
+
outcome.set_exception(
|
|
364
|
+
ProtocolError("request id must be a string or integer")
|
|
365
|
+
)
|
|
366
|
+
return outcome
|
|
367
|
+
key = _request_key(request_id)
|
|
368
|
+
if key in self._pending:
|
|
369
|
+
outcome.set_exception(
|
|
370
|
+
ProtocolError(f"request id {request_id} is already in flight")
|
|
371
|
+
)
|
|
372
|
+
return outcome
|
|
373
|
+
frame: Params = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
|
374
|
+
if params is not None:
|
|
375
|
+
frame["params"] = params
|
|
376
|
+
self._pending[key] = _Pending(outcome)
|
|
377
|
+
write_outcome = self._enqueue_write(frame)
|
|
378
|
+
|
|
379
|
+
def on_write_done(done: asyncio.Future[None]) -> None:
|
|
380
|
+
error = done.exception()
|
|
381
|
+
if error is not None:
|
|
382
|
+
self._pending.pop(key, None)
|
|
383
|
+
if not outcome.done():
|
|
384
|
+
outcome.set_exception(error)
|
|
385
|
+
|
|
386
|
+
write_outcome.add_done_callback(on_write_done)
|
|
387
|
+
return outcome
|
|
388
|
+
|
|
389
|
+
def notify(self, method: str, params: Params | None = None) -> None:
|
|
390
|
+
"""Sends one notification; a write failure finishes the connection.
|
|
391
|
+
|
|
392
|
+
Exception: a frame this caller cannot serialize/encode (a non-JSON
|
|
393
|
+
value, a lone surrogate) is the caller's defect, detected before
|
|
394
|
+
anything reaches the transport — it raises :class:`ProtocolError`
|
|
395
|
+
synchronously instead of being dropped or finishing a healthy
|
|
396
|
+
connection (spec 638 FM-638-7).
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ProtocolError: The frame is not JSON/UTF-8 encodable.
|
|
400
|
+
"""
|
|
401
|
+
frame: Params = {"jsonrpc": "2.0", "method": method}
|
|
402
|
+
if params is not None:
|
|
403
|
+
frame["params"] = params
|
|
404
|
+
outcome = self._enqueue_write(frame)
|
|
405
|
+
if outcome.done():
|
|
406
|
+
rejection = outcome.exception()
|
|
407
|
+
if isinstance(rejection, _UnencodableFrameError):
|
|
408
|
+
raise rejection
|
|
409
|
+
|
|
410
|
+
def on_done(done: asyncio.Future[None]) -> None:
|
|
411
|
+
error = done.exception()
|
|
412
|
+
if error is not None and not isinstance(error, _UnencodableFrameError):
|
|
413
|
+
self._finish(error)
|
|
414
|
+
|
|
415
|
+
outcome.add_done_callback(on_done)
|
|
416
|
+
|
|
417
|
+
def on_server_request(self, handler: ServerRequestHandler) -> None:
|
|
418
|
+
"""Registers the handler for server-initiated requests."""
|
|
419
|
+
self._server_request_handler = handler
|
|
420
|
+
|
|
421
|
+
def on_notification(self, handler: NotificationHandler) -> None:
|
|
422
|
+
"""Registers the notification handler (arrival order preserved)."""
|
|
423
|
+
self._notification_handler = handler
|
|
424
|
+
|
|
425
|
+
def on_protocol_error(self, handler: ProtocolErrorHandler) -> None:
|
|
426
|
+
"""Registers the inbound framing/correlation violation handler."""
|
|
427
|
+
self._protocol_error_handler = handler
|
|
428
|
+
|
|
429
|
+
async def flush(self) -> None:
|
|
430
|
+
"""Waits for the write tail; rethrows a recorded write failure."""
|
|
431
|
+
await asyncio.shield(self._write_tail)
|
|
432
|
+
if self._write_failure is not None:
|
|
433
|
+
raise self._write_failure
|
|
434
|
+
|
|
435
|
+
def command(
|
|
436
|
+
self,
|
|
437
|
+
method: str,
|
|
438
|
+
params: Params,
|
|
439
|
+
*,
|
|
440
|
+
command_id: str | None = None,
|
|
441
|
+
max_attempts: int | None = None,
|
|
442
|
+
retry_delay: RetryDelay | None = None,
|
|
443
|
+
) -> asyncio.Future[Params]:
|
|
444
|
+
"""Sends one logical command with the INV-013 ``commandId`` contract.
|
|
445
|
+
|
|
446
|
+
Same-id retries on the FM-006 retryable pair (``backpressured`` /
|
|
447
|
+
``overloaded``) with the bounded full-jitter budget; a value-identical
|
|
448
|
+
ack settles a replay; ``inputTooLarge`` (and every other error) is
|
|
449
|
+
never auto-retried.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
method: The command method.
|
|
453
|
+
params: The command params, without ``commandId``.
|
|
454
|
+
command_id: Reuse this id for a reconnect/replay of the same
|
|
455
|
+
logical command.
|
|
456
|
+
max_attempts: Total attempts, including the first (default 3).
|
|
457
|
+
retry_delay: Injectable so deterministic tests do not sleep.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
A future for the ack object (eager: the first attempt is in
|
|
461
|
+
flight when this returns) — or one already rejected, with NO
|
|
462
|
+
attempt in flight, when the params are not JSON-serializable
|
|
463
|
+
(:class:`ProtocolError`, FM-638-7 — the same settle-on-the-future
|
|
464
|
+
posture as :meth:`request`) or the ``commandId`` is reused with a
|
|
465
|
+
different payload.
|
|
466
|
+
"""
|
|
467
|
+
# The prelude and the FIRST attempt run synchronously so the frame
|
|
468
|
+
# joins the submit tail NOW: a command() immediately followed by
|
|
469
|
+
# close() must still write it (PR #30094 review — a deferred task
|
|
470
|
+
# enqueued nothing until the loop's next yield, silently dropping
|
|
471
|
+
# the frame; request()/notify() already enqueue synchronously).
|
|
472
|
+
cid = command_id if command_id is not None else self._mint_command_id()
|
|
473
|
+
command_params = {**params, "commandId": cid}
|
|
474
|
+
try:
|
|
475
|
+
signature = _canonical({"method": method, "params": command_params})
|
|
476
|
+
except (TypeError, ValueError):
|
|
477
|
+
# A param json.dumps cannot serialize is the caller's defect
|
|
478
|
+
# (FM-638-7), surfaced on the future this method promises — never
|
|
479
|
+
# a raw synchronous TypeError. request() with the same params
|
|
480
|
+
# rejects through _enqueue_write; the two surfaces must agree.
|
|
481
|
+
# Nothing is stranded: no _commands entry, no request in flight
|
|
482
|
+
# (PR #30094 review, round 18).
|
|
483
|
+
rejected: asyncio.Future[Params] = self._loop.create_future()
|
|
484
|
+
rejected.set_exception(
|
|
485
|
+
_UnencodableFrameError("outbound frame is not JSON/UTF-8 encodable")
|
|
486
|
+
)
|
|
487
|
+
return rejected
|
|
488
|
+
memory = self._commands.get(cid)
|
|
489
|
+
if memory is not None and memory.signature != signature:
|
|
490
|
+
outcome: asyncio.Future[Params] = self._loop.create_future()
|
|
491
|
+
outcome.set_exception(
|
|
492
|
+
ProtocolError(f"commandId {cid} was reused with a different payload")
|
|
493
|
+
)
|
|
494
|
+
return outcome
|
|
495
|
+
remembered = memory if memory is not None else _CommandMemory(signature)
|
|
496
|
+
self._commands[cid] = remembered
|
|
497
|
+
first = self.request(method, command_params)
|
|
498
|
+
return self._loop.create_task(
|
|
499
|
+
self._command_impl(
|
|
500
|
+
first, method, command_params, cid, remembered, max_attempts, retry_delay
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
async def _command_impl(
|
|
505
|
+
self,
|
|
506
|
+
first: asyncio.Future[Params],
|
|
507
|
+
method: str,
|
|
508
|
+
command_params: Params,
|
|
509
|
+
cid: str,
|
|
510
|
+
remembered: _CommandMemory,
|
|
511
|
+
max_attempts: int | None,
|
|
512
|
+
retry_delay: RetryDelay | None,
|
|
513
|
+
) -> Params:
|
|
514
|
+
attempts = max(1, 3 if max_attempts is None else max_attempts)
|
|
515
|
+
delay = retry_delay if retry_delay is not None else _default_retry_delay
|
|
516
|
+
pending = first
|
|
517
|
+
for attempt in range(1, attempts + 1):
|
|
518
|
+
try:
|
|
519
|
+
ack = await pending
|
|
520
|
+
# session/start and session/resume results omit the redundant
|
|
521
|
+
# commandId echo (contracts/sdk-surface.md); one that carries
|
|
522
|
+
# it must echo exactly (TEST-018).
|
|
523
|
+
if "commandId" in ack and ack["commandId"] != cid:
|
|
524
|
+
raise ProtocolError(
|
|
525
|
+
f"{method} ack commandId {ack['commandId']} did not echo {cid}"
|
|
526
|
+
)
|
|
527
|
+
if remembered.ack is not None and _canonical(
|
|
528
|
+
remembered.ack
|
|
529
|
+
) != _canonical(ack):
|
|
530
|
+
raise ProtocolError(
|
|
531
|
+
f"{method} replay for commandId {cid} did not return a "
|
|
532
|
+
"value-identical ack"
|
|
533
|
+
)
|
|
534
|
+
remembered.ack = dict(ack)
|
|
535
|
+
return ack
|
|
536
|
+
except MspError as error:
|
|
537
|
+
retryable_nothing_admitted = (
|
|
538
|
+
error.code == -32001 and error.kind == "overloaded"
|
|
539
|
+
) or (error.code == -32031 and error.kind == "backpressured")
|
|
540
|
+
if not retryable_nothing_admitted or attempt == attempts:
|
|
541
|
+
raise
|
|
542
|
+
await delay(attempt, error)
|
|
543
|
+
pending = self.request(method, command_params)
|
|
544
|
+
raise ProtocolError("unreachable command retry state")
|
|
545
|
+
|
|
546
|
+
async def close(self) -> None:
|
|
547
|
+
"""Closes the connection without gating teardown on the peer.
|
|
548
|
+
|
|
549
|
+
COMPLETION must not gate teardown — a peer that stopped reading
|
|
550
|
+
never completes a write (#15943). SUBMISSION must: the submission
|
|
551
|
+
tail goes TO the transport's optional ``close``, which waits for it
|
|
552
|
+
inside its own shutdown budget; this class knows nothing about
|
|
553
|
+
processes, signals, or timeouts. A close-less transport supplies no
|
|
554
|
+
budget, so the connection finishes immediately there — rejecting
|
|
555
|
+
in-flight requests and settling ``closed`` — which is all a
|
|
556
|
+
transport that declared no bound can be held to.
|
|
557
|
+
"""
|
|
558
|
+
tail = self._write_tail
|
|
559
|
+
transport_close = getattr(self._transport, "close", None)
|
|
560
|
+
if transport_close is not None:
|
|
561
|
+
result = transport_close(self._submit_tail)
|
|
562
|
+
if result is not None and hasattr(result, "__await__"):
|
|
563
|
+
await result
|
|
564
|
+
await asyncio.shield(tail)
|
|
565
|
+
else:
|
|
566
|
+
self._finish(ProtocolError("connection closed"))
|
|
567
|
+
await asyncio.shield(self._closed)
|
|
568
|
+
|
|
569
|
+
def _enqueue_write(self, frame: Params) -> asyncio.Future[None]:
|
|
570
|
+
outcome: asyncio.Future[None] = self._loop.create_future()
|
|
571
|
+
if self._finished:
|
|
572
|
+
outcome.set_exception(ProtocolError("connection is closed"))
|
|
573
|
+
return outcome
|
|
574
|
+
try:
|
|
575
|
+
# allow_nan=False so a NaN/Infinity float raises ValueError here
|
|
576
|
+
# rather than emitting a bare `NaN`/`Infinity` token — invalid
|
|
577
|
+
# JSON the Rust host cannot parse, which would leave the caller's
|
|
578
|
+
# request hanging until EOF (FR-638-013; the TS twin coerces to
|
|
579
|
+
# null). It joins the caller-defect settle below.
|
|
580
|
+
line = json.dumps(
|
|
581
|
+
frame, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
582
|
+
) + "\n"
|
|
583
|
+
line.encode("utf-8")
|
|
584
|
+
except (TypeError, ValueError, UnicodeEncodeError):
|
|
585
|
+
# A frame this caller cannot serialize/encode — a non-JSON param
|
|
586
|
+
# value (a Path, a set, a NaN float) or a lone surrogate smuggled
|
|
587
|
+
# in via os.fsdecode — is the CALLER's defect, not transport death:
|
|
588
|
+
# reject just this frame and leave the connection (and every
|
|
589
|
+
# sibling in flight) alive. Detected here, the one outbound
|
|
590
|
+
# boundary, so all three writers — request(), notify(), and the
|
|
591
|
+
# server-request reply — get it (PR #30094 review; the TS twin
|
|
592
|
+
# survives the same input).
|
|
593
|
+
outcome.set_exception(
|
|
594
|
+
_UnencodableFrameError("outbound frame is not JSON/UTF-8 encodable")
|
|
595
|
+
)
|
|
596
|
+
return outcome
|
|
597
|
+
submitted: asyncio.Future[None] = self._loop.create_future()
|
|
598
|
+
self._submit_tail = _both_done(self._loop, self._submit_tail, submitted)
|
|
599
|
+
previous_tail = self._write_tail
|
|
600
|
+
next_tail: asyncio.Future[None] = self._loop.create_future()
|
|
601
|
+
self._write_tail = next_tail
|
|
602
|
+
|
|
603
|
+
async def run() -> None:
|
|
604
|
+
await asyncio.shield(previous_tail)
|
|
605
|
+
try:
|
|
606
|
+
if self._finished:
|
|
607
|
+
raise ProtocolError("connection is closed")
|
|
608
|
+
# Scheduling the write task IS submission here (module doc).
|
|
609
|
+
in_flight = self._loop.create_task(self._transport.write(line))
|
|
610
|
+
if not submitted.done():
|
|
611
|
+
submitted.set_result(None)
|
|
612
|
+
await in_flight
|
|
613
|
+
except BaseException as error: # noqa: BLE001 — the tail owns it
|
|
614
|
+
# A frame that can never be submitted must not wedge close().
|
|
615
|
+
if not submitted.done():
|
|
616
|
+
submitted.set_result(None)
|
|
617
|
+
self._write_failure = error
|
|
618
|
+
self._finish(error)
|
|
619
|
+
if not outcome.done():
|
|
620
|
+
outcome.set_exception(error)
|
|
621
|
+
return
|
|
622
|
+
finally:
|
|
623
|
+
if not next_tail.done():
|
|
624
|
+
next_tail.set_result(None)
|
|
625
|
+
if not outcome.done():
|
|
626
|
+
outcome.set_result(None)
|
|
627
|
+
|
|
628
|
+
self._loop.create_task(run())
|
|
629
|
+
return outcome
|
|
630
|
+
|
|
631
|
+
async def _read_loop(self) -> None:
|
|
632
|
+
try:
|
|
633
|
+
async for chunk in self._transport.incoming:
|
|
634
|
+
self._ingest(chunk)
|
|
635
|
+
if not self._dropping_oversized and self._segments:
|
|
636
|
+
self._protocol_error(
|
|
637
|
+
ProtocolError(
|
|
638
|
+
"inbound frame ended without a newline",
|
|
639
|
+
"".join(self._segments),
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
self._finish(ProtocolError("connection reached EOF"))
|
|
643
|
+
except BaseException as error: # noqa: BLE001 — finish carries it
|
|
644
|
+
self._finish(error)
|
|
645
|
+
|
|
646
|
+
def _ingest(self, chunk: str) -> None:
|
|
647
|
+
remaining = chunk
|
|
648
|
+
if self._dropping_oversized:
|
|
649
|
+
newline = remaining.find("\n")
|
|
650
|
+
if newline < 0:
|
|
651
|
+
return
|
|
652
|
+
self._dropping_oversized = False
|
|
653
|
+
remaining = remaining[newline + 1 :]
|
|
654
|
+
if not remaining:
|
|
655
|
+
return
|
|
656
|
+
self._segments.append(remaining)
|
|
657
|
+
self._buffer_bytes += _utf8_len(remaining)
|
|
658
|
+
# FR-638-011: the retained buffer is newline-free on entry (the drain
|
|
659
|
+
# below runs to exhaustion), so only the fresh chunk can carry a
|
|
660
|
+
# newline — ingest stays O(bytes), never a full-buffer rescan.
|
|
661
|
+
if "\n" in remaining:
|
|
662
|
+
buffer = "".join(self._segments)
|
|
663
|
+
while True:
|
|
664
|
+
newline = buffer.find("\n")
|
|
665
|
+
if newline < 0:
|
|
666
|
+
break
|
|
667
|
+
line = buffer[:newline]
|
|
668
|
+
buffer = buffer[newline + 1 :]
|
|
669
|
+
self._buffer_bytes -= _utf8_len(line) + 1
|
|
670
|
+
self._line(line)
|
|
671
|
+
self._segments = [buffer] if buffer else []
|
|
672
|
+
if self._buffer_bytes > self._frame_limit_bytes:
|
|
673
|
+
joined = "".join(self._segments)
|
|
674
|
+
self._protocol_error(
|
|
675
|
+
ProtocolError(
|
|
676
|
+
f"inbound frame exceeds {self._frame_limit_bytes} bytes",
|
|
677
|
+
_truncate_to_utf8_bytes(joined, self._frame_limit_bytes),
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
self._segments = []
|
|
681
|
+
self._buffer_bytes = 0
|
|
682
|
+
self._dropping_oversized = True
|
|
683
|
+
|
|
684
|
+
def _line(self, raw: str) -> None:
|
|
685
|
+
line = raw[:-1] if raw.endswith("\r") else raw
|
|
686
|
+
if not line.strip():
|
|
687
|
+
return
|
|
688
|
+
if _utf8_len(line) > self._frame_limit_bytes:
|
|
689
|
+
self._protocol_error(
|
|
690
|
+
ProtocolError(
|
|
691
|
+
f"inbound frame exceeds {self._frame_limit_bytes} bytes",
|
|
692
|
+
_truncate_to_utf8_bytes(line, self._frame_limit_bytes),
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
return
|
|
696
|
+
try:
|
|
697
|
+
parsed = json.loads(line)
|
|
698
|
+
except ValueError:
|
|
699
|
+
self._protocol_error(
|
|
700
|
+
ProtocolError("inbound frame is not valid JSON", line)
|
|
701
|
+
)
|
|
702
|
+
return
|
|
703
|
+
if not isinstance(parsed, dict) or parsed.get("jsonrpc") != "2.0":
|
|
704
|
+
self._protocol_error(
|
|
705
|
+
ProtocolError("inbound frame is not a JSON-RPC 2.0 object", line)
|
|
706
|
+
)
|
|
707
|
+
return
|
|
708
|
+
method = parsed.get("method")
|
|
709
|
+
frame_id = parsed.get("id")
|
|
710
|
+
if isinstance(method, str):
|
|
711
|
+
if isinstance(frame_id, (str, int)) and not isinstance(frame_id, bool):
|
|
712
|
+
if not isinstance(frame_id, int) or frame_id <= 0:
|
|
713
|
+
self._protocol_error(
|
|
714
|
+
ProtocolError(
|
|
715
|
+
"server request id must be a positive integer", line
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
return
|
|
719
|
+
task = self._loop.create_task(self._server_request(parsed))
|
|
720
|
+
|
|
721
|
+
def on_done(done: asyncio.Future[None]) -> None:
|
|
722
|
+
# A Ctrl-C that cancels the run cancels this handler task;
|
|
723
|
+
# `Future.exception()` would then raise CancelledError back
|
|
724
|
+
# into the callback and log a spurious traceback in the
|
|
725
|
+
# consumer's shutdown (PR #30094 review).
|
|
726
|
+
if done.cancelled():
|
|
727
|
+
return
|
|
728
|
+
error = done.exception()
|
|
729
|
+
if error is not None:
|
|
730
|
+
self._finish(error)
|
|
731
|
+
|
|
732
|
+
task.add_done_callback(on_done)
|
|
733
|
+
return
|
|
734
|
+
if self._notification_handler is not None:
|
|
735
|
+
self._notification_handler(parsed)
|
|
736
|
+
return
|
|
737
|
+
if not isinstance(frame_id, (str, int)) or isinstance(frame_id, bool):
|
|
738
|
+
self._protocol_error(ProtocolError("response has no usable id", line))
|
|
739
|
+
return
|
|
740
|
+
self._response(parsed, frame_id, line)
|
|
741
|
+
|
|
742
|
+
def _response(self, frame: Params, frame_id: RequestId, line: str) -> None:
|
|
743
|
+
pending = self._pending.get(_request_key(frame_id))
|
|
744
|
+
if pending is None:
|
|
745
|
+
self._protocol_error(
|
|
746
|
+
ProtocolError(f"response for unknown request id {frame_id}", line)
|
|
747
|
+
)
|
|
748
|
+
return
|
|
749
|
+
if pending.future.done():
|
|
750
|
+
# The caller cancelled its await (or an asyncio.wait_for timed
|
|
751
|
+
# out): the request is abandoned, not unknown — the late reply is
|
|
752
|
+
# dropped and the CONNECTION stays healthy (spec 638 Edge Cases:
|
|
753
|
+
# cancellation is a consumer-side act and must not corrupt the
|
|
754
|
+
# connection; PR #30094 review, thread 2 — settling a done future
|
|
755
|
+
# raised InvalidStateError inside the read loop and tore
|
|
756
|
+
# everything down).
|
|
757
|
+
del self._pending[_request_key(frame_id)]
|
|
758
|
+
return
|
|
759
|
+
has_result = "result" in frame
|
|
760
|
+
has_error = "error" in frame
|
|
761
|
+
if has_result == has_error:
|
|
762
|
+
# FR-638-013: the MATCHED caller must settle — with no local
|
|
763
|
+
# timeout (INV-006) an unrejected caller would hang until EOF.
|
|
764
|
+
violation = ProtocolError(
|
|
765
|
+
"response must carry exactly one of result or error", line
|
|
766
|
+
)
|
|
767
|
+
del self._pending[_request_key(frame_id)]
|
|
768
|
+
pending.future.set_exception(violation)
|
|
769
|
+
self._protocol_error(violation)
|
|
770
|
+
return
|
|
771
|
+
del self._pending[_request_key(frame_id)]
|
|
772
|
+
if has_result:
|
|
773
|
+
result = frame.get("result")
|
|
774
|
+
if not isinstance(result, dict):
|
|
775
|
+
pending.future.set_exception(
|
|
776
|
+
ProtocolError("response result must be an object", line)
|
|
777
|
+
)
|
|
778
|
+
return
|
|
779
|
+
pending.future.set_result(result)
|
|
780
|
+
return
|
|
781
|
+
error = frame.get("error")
|
|
782
|
+
if (
|
|
783
|
+
not isinstance(error, dict)
|
|
784
|
+
or not isinstance(error.get("code"), int)
|
|
785
|
+
or isinstance(error.get("code"), bool)
|
|
786
|
+
or not isinstance(error.get("message"), str)
|
|
787
|
+
):
|
|
788
|
+
pending.future.set_exception(
|
|
789
|
+
ProtocolError("error response has an invalid error object", line)
|
|
790
|
+
)
|
|
791
|
+
return
|
|
792
|
+
data = error.get("data")
|
|
793
|
+
if not isinstance(data, dict) or not isinstance(data.get("kind"), str):
|
|
794
|
+
pending.future.set_exception(
|
|
795
|
+
ProtocolError("MSP error response has no data.kind", line)
|
|
796
|
+
)
|
|
797
|
+
return
|
|
798
|
+
pending.future.set_exception(MspError(error))
|
|
799
|
+
|
|
800
|
+
async def _server_request(self, request: Params) -> None:
|
|
801
|
+
request_id = request["id"]
|
|
802
|
+
try:
|
|
803
|
+
if self._server_request_handler is None:
|
|
804
|
+
raise MspError(
|
|
805
|
+
{
|
|
806
|
+
"code": -32601,
|
|
807
|
+
"message": f"method not found: {request['method']}",
|
|
808
|
+
"data": {"kind": "methodNotFound", "retryable": False},
|
|
809
|
+
}
|
|
810
|
+
)
|
|
811
|
+
result = await self._server_request_handler(request)
|
|
812
|
+
# FM-009: a failed reply write finishes the connection; it must
|
|
813
|
+
# not escape this fire-and-forget task as an unhandled exception.
|
|
814
|
+
reply = self._enqueue_write(
|
|
815
|
+
{"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
816
|
+
)
|
|
817
|
+
reply.add_done_callback(
|
|
818
|
+
lambda done: self._reply_done(request_id, done)
|
|
819
|
+
)
|
|
820
|
+
except MspError as typed:
|
|
821
|
+
self._write_error_reply(request_id, typed)
|
|
822
|
+
except Exception as error: # noqa: BLE001 — mapped to -32603
|
|
823
|
+
self._write_error_reply(
|
|
824
|
+
request_id,
|
|
825
|
+
MspError(
|
|
826
|
+
{
|
|
827
|
+
"code": -32603,
|
|
828
|
+
"message": str(error) or "server request handler failed",
|
|
829
|
+
"data": {"kind": "internal"},
|
|
830
|
+
}
|
|
831
|
+
),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
def _write_error_reply(self, request_id: object, typed: MspError) -> None:
|
|
835
|
+
reply = self._enqueue_write(
|
|
836
|
+
{
|
|
837
|
+
"jsonrpc": "2.0",
|
|
838
|
+
"id": request_id,
|
|
839
|
+
"error": {
|
|
840
|
+
"code": typed.code,
|
|
841
|
+
"message": typed.message,
|
|
842
|
+
"data": typed.data,
|
|
843
|
+
},
|
|
844
|
+
}
|
|
845
|
+
)
|
|
846
|
+
reply.add_done_callback(
|
|
847
|
+
lambda done: self._reply_done(request_id, done)
|
|
848
|
+
) # FM-009
|
|
849
|
+
|
|
850
|
+
def _reply_done(self, request_id: object, done: asyncio.Future[None]) -> None:
|
|
851
|
+
"""FM-638-7 for reply writes, minus the caller-defect arm.
|
|
852
|
+
|
|
853
|
+
A reply frame that cannot serialize/encode (a handler returned or
|
|
854
|
+
raised a non-JSON value — a set, a lone surrogate) is the HANDLER's
|
|
855
|
+
defect, not transport death: the peer is still waiting on this id, so
|
|
856
|
+
answer it with a fixed-ASCII ``-32603`` instead of finishing the
|
|
857
|
+
connection (the third writer after request()/notify()). The fallback
|
|
858
|
+
frame cannot re-enter this arm because ``_line`` admits a server
|
|
859
|
+
request only with a positive-INTEGER id, so ``request_id`` is always
|
|
860
|
+
an int and the fallback is ASCII by construction (spec 638 FM-638-7).
|
|
861
|
+
"""
|
|
862
|
+
error = done.exception()
|
|
863
|
+
if error is None:
|
|
864
|
+
return
|
|
865
|
+
if isinstance(error, _UnencodableFrameError):
|
|
866
|
+
fallback = self._enqueue_write(
|
|
867
|
+
{
|
|
868
|
+
"jsonrpc": "2.0",
|
|
869
|
+
"id": request_id,
|
|
870
|
+
"error": {
|
|
871
|
+
"code": -32603,
|
|
872
|
+
"message": "server request handler produced a "
|
|
873
|
+
"non-encodable reply",
|
|
874
|
+
"data": {"kind": "internal"},
|
|
875
|
+
},
|
|
876
|
+
}
|
|
877
|
+
)
|
|
878
|
+
fallback.add_done_callback(self._consume_reply_failure)
|
|
879
|
+
return
|
|
880
|
+
self._finish(error)
|
|
881
|
+
|
|
882
|
+
def _consume_reply_failure(self, done: asyncio.Future[None]) -> None:
|
|
883
|
+
error = done.exception()
|
|
884
|
+
if error is not None:
|
|
885
|
+
self._finish(error)
|
|
886
|
+
|
|
887
|
+
def _protocol_error(self, error: ProtocolError) -> None:
|
|
888
|
+
if self._protocol_error_handler is not None:
|
|
889
|
+
self._protocol_error_handler(error)
|
|
890
|
+
|
|
891
|
+
def _finish(self, reason: object) -> None:
|
|
892
|
+
if self._finished:
|
|
893
|
+
return
|
|
894
|
+
self._finished = True
|
|
895
|
+
error = (
|
|
896
|
+
reason
|
|
897
|
+
if isinstance(reason, BaseException)
|
|
898
|
+
else ProtocolError(str(reason))
|
|
899
|
+
)
|
|
900
|
+
for pending in self._pending.values():
|
|
901
|
+
if not pending.future.done():
|
|
902
|
+
pending.future.set_exception(error)
|
|
903
|
+
self._pending.clear()
|
|
904
|
+
if not self._closed.done():
|
|
905
|
+
self._closed.set_result(None)
|