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.
@@ -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)