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,1067 @@
1
+ """The owned-process stdio binding and type-state handshake (spec 638 S2).
2
+
3
+ Port of ``clients/sdk-ts/src/connection/spawn.ts``: ``MuseServeChild`` owns
4
+ one spawned MSP host with SS2.11 diagnostics (stderr ring from birth, total
5
+ exit mapping) and the bounded close ladder (14990 FR-017a/b semantics,
6
+ FR-638-016/017/018): stdin EOF first, one shared shutdown budget, ``SIGTERM``,
7
+ one ``SIGKILL`` escalation after a fixed grace, resolution on the observed
8
+ exit, POSIX process-group ownership with a direct-child fallback.
9
+
10
+ Python observation rule (spec 638 FR-638-017): asyncio exposes ONE
11
+ ``returncode`` — ``>= 0`` is the exit-code row for that code, ``< 0`` is the
12
+ crash row carrying signal ``-returncode``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import codecs
19
+ import os
20
+ import signal as signal_module
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from typing import (
24
+ TYPE_CHECKING,
25
+ Any,
26
+ AsyncIterator,
27
+ Awaitable,
28
+ Callable,
29
+ Literal,
30
+ Mapping,
31
+ Sequence,
32
+ TypedDict,
33
+ )
34
+
35
+ from ..errors import MuseHostMismatchError
36
+ from .connection import Connection, ProtocolError
37
+ from .validation import parse_initialize_result, parse_spawn_options
38
+
39
+ if TYPE_CHECKING:
40
+ from muse_code_msp import InitializeParams, InitializeResult, RequestId
41
+
42
+ DEFAULT_STDERR_MAX_BYTES = 8 * 1024
43
+ DEFAULT_STDERR_MAX_LINES = 100
44
+ DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000
45
+ """SS2.1.2's default host drain timeout: the whole EOF sequence's budget."""
46
+ SIGTERM_GRACE_MS = 2_000
47
+ """SIGTERM -> SIGKILL grace. Not a knob: by the time it runs the host has had
48
+ its whole drain window, so this covers only a signal handler's final flush.
49
+ Also bounds ``_classified``'s post-exit stderr-drain wait (py3.10 settles
50
+ ``wait()`` at the reap, before pipe EOF), so tuning it down also shrinks the
51
+ window for capturing a host's final stderr evidence."""
52
+ OnStderr = Callable[[str], None]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ProcessExit:
57
+ """How the host process ended: exactly one of the two is non-``None``.
58
+
59
+ Attributes:
60
+ code: The exit code, for a voluntary exit.
61
+ signal: The ending signal's name (e.g. ``"SIGTERM"``), for a killed
62
+ host — Python's negative ``returncode`` decomposed.
63
+ """
64
+
65
+ code: int | None
66
+ signal: str | None
67
+
68
+ @staticmethod
69
+ def from_returncode(returncode: int) -> "ProcessExit":
70
+ """Decomposes asyncio's single ``returncode`` (spec 638 FR-638-017)."""
71
+ if returncode >= 0:
72
+ return ProcessExit(code=returncode, signal=None)
73
+ number = -returncode
74
+ try:
75
+ name = signal_module.Signals(number).name
76
+ except ValueError:
77
+ # Real-time signals (SIGRTMIN..SIGRTMAX and beyond) have no
78
+ # Signals member on this platform; the row must stay TOTAL
79
+ # (INV-011 carry) — name the number rather than raise.
80
+ name = f"signal {number}"
81
+ return ProcessExit(code=None, signal=name)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ExitClassification:
86
+ """The SDK's reading of a host exit (SS2.11; total per INV-011 carry).
87
+
88
+ Branch on ``kind``. Every arm but ``cleanShutdown`` carries its exit
89
+ evidence (code or signal plus the bounded stderr tail); the documented
90
+ error exits also carry a stable retry posture. Exit 1 deliberately makes
91
+ NO retry claim (SS2.11 marks no posture for it), so ``retry`` is ``None``
92
+ there and on the crash row.
93
+
94
+ Attributes:
95
+ kind: The SS2.11 row.
96
+ exit_code: The observed exit code, where one exists.
97
+ exit_signal: The observed ending signal's name, crash row only.
98
+ stderr_tail: The bounded evidence captured from birth (INV-010:
99
+ evidence, never input).
100
+ retry: The stable retry posture, where SS2.11 states one.
101
+ """
102
+
103
+ kind: Literal[
104
+ "cleanShutdown",
105
+ "unhandledError",
106
+ "usageError",
107
+ "configError",
108
+ "leaseUnavailable",
109
+ "sdkSurfaceUnavailable",
110
+ "crash",
111
+ ]
112
+ exit_code: int | None = None
113
+ exit_signal: str | None = None
114
+ stderr_tail: tuple[str, ...] = ()
115
+ retry: Literal["never", "fix-config", "after-lease-release"] | None = None
116
+
117
+
118
+ def classify_exit(
119
+ process_exit: ProcessExit, stderr_tail: Sequence[str]
120
+ ) -> ExitClassification:
121
+ """Maps one observed exit onto its SS2.11 row (total: INV-011 carry).
122
+
123
+ Args:
124
+ process_exit: The observed ``(code, signal)`` decomposition.
125
+ stderr_tail: The bounded evidence at classification time.
126
+
127
+ Returns:
128
+ Exactly one row; unrecognized non-zero codes are the crash row,
129
+ never a guessed condition.
130
+ """
131
+ evidence = tuple(stderr_tail)
132
+ code = process_exit.code
133
+ if code == 0:
134
+ return ExitClassification(kind="cleanShutdown")
135
+ if code == 1:
136
+ return ExitClassification(kind="unhandledError", exit_code=1, stderr_tail=evidence)
137
+ if code == 2:
138
+ return ExitClassification(
139
+ kind="usageError", exit_code=2, stderr_tail=evidence, retry="never"
140
+ )
141
+ if code == 3:
142
+ return ExitClassification(
143
+ kind="configError", exit_code=3, stderr_tail=evidence, retry="fix-config"
144
+ )
145
+ if code == 4:
146
+ # Exit 4 is `sessionInUse` arriving early: another live client holds
147
+ # the lease, and it frees itself once that client exits (SS2.11).
148
+ return ExitClassification(
149
+ kind="leaseUnavailable",
150
+ exit_code=4,
151
+ stderr_tail=evidence,
152
+ retry="after-lease-release",
153
+ )
154
+ if code == 5:
155
+ return ExitClassification(
156
+ kind="sdkSurfaceUnavailable",
157
+ exit_code=5,
158
+ stderr_tail=evidence,
159
+ retry="never",
160
+ )
161
+ return ExitClassification(
162
+ kind="crash",
163
+ exit_code=code,
164
+ exit_signal=process_exit.signal,
165
+ stderr_tail=evidence,
166
+ )
167
+
168
+
169
+ class StderrTail:
170
+ """A client-local evidence budget; its contents never influence behavior.
171
+
172
+ Bounded to the 8 KiB / 100-line defaults (a client budget, not a wire
173
+ fact). The byte cut advances past UTF-8 continuation bytes rather than
174
+ manufacturing replacement text.
175
+ """
176
+
177
+ def __init__(self) -> None:
178
+ """Starts empty."""
179
+ self._text = ""
180
+
181
+ def push(self, chunk: str) -> None:
182
+ """Appends one raw stderr chunk and re-trims both budgets."""
183
+ self._text += chunk
184
+ self._trim_lines()
185
+ self._trim_bytes()
186
+
187
+ def lines(self) -> tuple[str, ...]:
188
+ """The retained evidence, split on newlines (no trailing empty)."""
189
+ if not self._text:
190
+ return ()
191
+ lines = self._text.split("\n")
192
+ if lines and lines[-1] == "":
193
+ lines.pop()
194
+ return tuple(lines)
195
+
196
+ def _trim_lines(self) -> None:
197
+ line_count = self._text.count("\n") + (0 if self._text.endswith("\n") else 1)
198
+ if self._text.endswith("\n"):
199
+ line_count = self._text.count("\n")
200
+ while line_count > DEFAULT_STDERR_MAX_LINES:
201
+ first_break = self._text.find("\n")
202
+ if first_break < 0:
203
+ break
204
+ self._text = self._text[first_break + 1 :]
205
+ line_count -= 1
206
+
207
+ def _trim_bytes(self) -> None:
208
+ data = self._text.encode("utf-8")
209
+ if len(data) <= DEFAULT_STDERR_MAX_BYTES:
210
+ return
211
+ start = len(data) - DEFAULT_STDERR_MAX_BYTES
212
+ while start < len(data) and (data[start] & 0xC0) == 0x80:
213
+ start += 1
214
+ self._text = data[start:].decode("utf-8")
215
+
216
+
217
+ class ShutdownDeadline:
218
+ """One clearable deadline: ``expired`` settles once, ``clear`` disarms it."""
219
+
220
+ def __init__(self, budget_ms: int) -> None:
221
+ """Arms the deadline on the running loop."""
222
+ self._event = asyncio.Event()
223
+ self._handle = asyncio.get_running_loop().call_later(
224
+ budget_ms / 1000, self._event.set
225
+ )
226
+
227
+ async def expired(self) -> None:
228
+ """Settles when the budget elapses; never raises."""
229
+ await self._event.wait()
230
+
231
+ def clear(self) -> None:
232
+ """Disarms the timer so nothing pins the event loop (FR-638-017)."""
233
+ self._handle.cancel()
234
+
235
+
236
+ DeadlineFactory = Callable[[int], ShutdownDeadline]
237
+
238
+
239
+ class ConnectionOptions(TypedDict, total=False):
240
+ """Typed tuning for the connection machine (spec 638 FR-638-011/013).
241
+
242
+ Exactly :class:`Connection`'s three keyword options, as a ``TypedDict``
243
+ so ``mypy --strict`` rejects a misspelled key or wrong value type at
244
+ type-check time instead of after the host process was already spawned
245
+ (PR #30094 review; the TS twin types this field as an interface).
246
+ """
247
+
248
+ frame_limit_bytes: int
249
+ mint_command_id: Callable[[], str]
250
+ mint_request_id: Callable[[], "RequestId"]
251
+
252
+
253
+ class ChildStdioTransport:
254
+ """Duplex transport over an already-spawned child's stdio.
255
+
256
+ Module-internal (never the package barrel), exactly like the TS twin:
257
+ the deterministic close()-race tests construct it around stub children.
258
+
259
+ Attributes:
260
+ child: The owned ``asyncio.subprocess.Process``.
261
+ """
262
+
263
+ def __init__(
264
+ self,
265
+ child: asyncio.subprocess.Process,
266
+ on_stderr: OnStderr | None = None,
267
+ shutdown_timeout_ms: int = DEFAULT_SHUTDOWN_TIMEOUT_MS,
268
+ owns_process_group: bool = False,
269
+ deadline_factory: DeadlineFactory | None = None,
270
+ ) -> None:
271
+ """Wires the reader tasks and the exit future.
272
+
273
+ Args:
274
+ child: A process spawned with piped stdio.
275
+ on_stderr: Raw stderr chunks, drained from birth, never parsed.
276
+ shutdown_timeout_ms: The FR-017a drain budget. The C-638-2
277
+ spawn seam (``validation.parse_spawn_options``) owns the
278
+ range refusal on every public spawn surface; this
279
+ module-internal ctor trusts its callers (PR #31707 review —
280
+ a second downstream copy guarded nothing).
281
+ owns_process_group: True ONLY when the boundary that spawned
282
+ this child made it a group leader (never inferred).
283
+ deadline_factory: Deterministic-test seam; production uses the
284
+ real timer deadline. Deliberately NOT surfaced on any
285
+ public spawn signature (PR #30094 review; TS keeps
286
+ ``deadlineFactory`` on this module-internal ctor only).
287
+ """
288
+ self.child = child
289
+ self._shutdown_timeout_ms = shutdown_timeout_ms
290
+ self._owns_process_group = owns_process_group
291
+ self._make_deadline: DeadlineFactory = deadline_factory or ShutdownDeadline
292
+ self._closing: asyncio.Task[None] | None = None
293
+ self._default_flushed: Callable[[], Awaitable[None]] | None = None
294
+ self._stdout_consumed = False
295
+ self._stderr_task: asyncio.Task[None] | None = None
296
+ if child.stderr is not None and on_stderr is not None:
297
+ self._stderr_task = asyncio.ensure_future(
298
+ self._drain_stderr(on_stderr)
299
+ )
300
+ # Settled by the close ladder when the watcher has recorded a reap
301
+ # but a held stdio pipe keeps Process.wait() from ever returning.
302
+ self._reaped: asyncio.Future[int] = (
303
+ asyncio.get_running_loop().create_future()
304
+ )
305
+ self._exited: asyncio.Task[ProcessExit] = asyncio.ensure_future(
306
+ self._await_exit()
307
+ )
308
+
309
+ @property
310
+ def exited(self) -> Awaitable[ProcessExit]:
311
+ """Settles with the observed :class:`ProcessExit`.
312
+
313
+ A fresh shield per access: a consumer's ``wait_for`` timeout must
314
+ cancel only its own wait, never the SDK's one real exit watcher —
315
+ a cancelled watcher would break ``close()`` and orphan the host
316
+ (PR #30094 review; a TS Promise cannot be cancelled).
317
+ """
318
+ return asyncio.shield(self._exited)
319
+
320
+ async def _await_exit(self) -> ProcessExit:
321
+ # Process.wait() settles only once every stdio pipe hits EOF, so the
322
+ # close ladder's forced-reap future (`_reaped`) races it. But a
323
+ # consumer may just await `child.exit` WITHOUT ever calling close()
324
+ # (nothing then sets `_reaped`), and a leaked helper holding a pipe
325
+ # keeps wait() blocked after the host is already reaped — hanging
326
+ # child.exit forever (FR-638-017; reproduced on py3.10 AND py3.12).
327
+ # So also settle on the observed REAP: a slow returncode poll that
328
+ # the fast wait() beats on every clean exit, and that only matters
329
+ # in the held-pipe case (PR #30094 review, round 11 P0).
330
+ wait_task = asyncio.ensure_future(self.child.wait())
331
+ try:
332
+ while True:
333
+ done, _ = await asyncio.wait(
334
+ {wait_task, self._reaped},
335
+ timeout=SIGTERM_GRACE_MS / 1000,
336
+ return_when=asyncio.FIRST_COMPLETED,
337
+ )
338
+ if wait_task in done:
339
+ return ProcessExit.from_returncode(wait_task.result())
340
+ if self._reaped.done() or self.child.returncode is not None:
341
+ break
342
+ finally:
343
+ if not wait_task.done():
344
+ wait_task.cancel()
345
+ try:
346
+ await wait_task
347
+ except asyncio.CancelledError:
348
+ pass
349
+ returncode = (
350
+ self._reaped.result()
351
+ if self._reaped.done()
352
+ else self.child.returncode
353
+ )
354
+ assert returncode is not None
355
+ return ProcessExit.from_returncode(returncode)
356
+
357
+ async def _drain_stderr(self, on_stderr: OnStderr) -> None:
358
+ stream = self.child.stderr
359
+ if stream is None: # pragma: no cover - guarded at construction
360
+ return
361
+ # One decoder across the loop (the _decoded_stdout twin): a
362
+ # per-chunk decode turned a multi-byte character straddling two
363
+ # reads into replacement characters in the evidence tail
364
+ # (PR #30094 review).
365
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
366
+ while True:
367
+ chunk = await stream.read(8192)
368
+ if not chunk:
369
+ tail = decoder.decode(b"", final=True)
370
+ if tail:
371
+ on_stderr(tail)
372
+ return
373
+ text = decoder.decode(chunk)
374
+ if text:
375
+ on_stderr(text)
376
+
377
+ def _adopt_flush_source(self, source: Callable[[], Awaitable[None]]) -> None:
378
+ """Module-private friend seam used by the handshake that owns both sides."""
379
+ self._default_flushed = source
380
+
381
+ @property
382
+ def incoming(self) -> AsyncIterator[str]:
383
+ """The host's stdout as decoded UTF-8 text chunks.
384
+
385
+ An incremental decoder carries a multi-byte character split across
386
+ byte chunks over to the next one (the transport contract's streaming
387
+ rule) instead of decoding halves into replacement characters.
388
+ """
389
+ return self._decoded_stdout()
390
+
391
+ async def _decoded_stdout(self) -> AsyncIterator[str]:
392
+ # Mark the consumer here, when the generator is actually ITERATED —
393
+ # not in the `incoming` getter, where a bare `hasattr`/`getattr`
394
+ # discovery sweep (the transport Protocol documents that idiom) would
395
+ # falsely flag a consumer and make every later close() wait the full
396
+ # grace for a stdout EOF nobody drains (PR #30094 review).
397
+ self._stdout_consumed = True
398
+ stream = self.child.stdout
399
+ if stream is None: # pragma: no cover - guarded at construction
400
+ return
401
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
402
+ while True:
403
+ chunk = await stream.read(65536)
404
+ if not chunk:
405
+ tail = decoder.decode(b"", final=True)
406
+ if tail:
407
+ yield tail
408
+ return
409
+ yield decoder.decode(chunk)
410
+
411
+ async def write(self, chunk: str) -> None:
412
+ """One awaited stdin write (backpressure honored, never buffered unbounded).
413
+
414
+ Raises:
415
+ ProtocolError: The host's stdin is already closed.
416
+ """
417
+ stdin = self.child.stdin
418
+ if stdin is None or stdin.is_closing():
419
+ raise ProtocolError("spawned MSP host stdin is closed")
420
+ stdin.write(chunk.encode("utf-8"))
421
+ await stdin.drain()
422
+
423
+ async def close(self, flushed: Awaitable[None] | None = None) -> None:
424
+ """The single owner of shutdown; memoized so concurrent calls share it.
425
+
426
+ EOF first, then bounded: SS2.1.2 gives the host a drain window and
427
+ says the remainder past it has crash semantics, so past the window
428
+ the SDK ends the process it owns rather than waiting on an EOF that
429
+ may never come.
430
+
431
+ Args:
432
+ flushed: A submission-tail promise from the connection; awaited
433
+ INSIDE the one shutdown budget so it cannot double the bound.
434
+ """
435
+ if self._closing is None:
436
+ source = (
437
+ flushed
438
+ if flushed is not None
439
+ else (self._default_flushed() if self._default_flushed else None)
440
+ )
441
+ self._closing = asyncio.ensure_future(self._shutdown(source))
442
+ elif flushed is not None:
443
+ # A losing concurrent close's submission promise must not be
444
+ # dropped un-awaited; it shares the winner's ride best-effort.
445
+ asyncio.ensure_future(_swallow(flushed))
446
+ await asyncio.shield(self._closing)
447
+
448
+ async def _shutdown(self, flushed: Awaitable[None] | None) -> None:
449
+ deadline = self._make_deadline(self._shutdown_timeout_ms)
450
+ flush_wait: asyncio.Task[None] | None = None
451
+ try:
452
+ if flushed is not None:
453
+ flush_wait = asyncio.ensure_future(_swallow(flushed))
454
+ await self._before_deadline(flush_wait, deadline)
455
+ await self._shutdown_after_flush(deadline)
456
+ finally:
457
+ # If the shutdown is itself CANCELLED (Ctrl-C during asyncio.run
458
+ # teardown) the awaits above unwind before any kill rung — from
459
+ # the FLUSH WAIT as well as from inside the ladder, so the guard
460
+ # lives here, the one finally every close passes through, not in
461
+ # the ladder (whose own finally missed a cancel parked on the
462
+ # flush wait — PR #30094 review, round 18). One SYNCHRONOUS
463
+ # SIGKILL keeps never-orphan unconditional (FM-638-2): signalling
464
+ # is safe under CancelledError and a no-op once the host is
465
+ # reaped.
466
+ self._kill_now()
467
+ deadline.clear()
468
+ if flush_wait is not None and not flush_wait.done():
469
+ # The deadline won: nothing later awaits the flush half, so
470
+ # settle its cancellation NOW — no pending task may remain
471
+ # after close() settles (FR-638-017; PR #30094 review).
472
+ flush_wait.cancel()
473
+ try:
474
+ await flush_wait
475
+ except asyncio.CancelledError:
476
+ pass
477
+
478
+ async def _shutdown_after_flush(self, deadline: ShutdownDeadline) -> None:
479
+ # EOF first, SYNCHRONOUSLY: with no drain() (see _end_stdin) the EOF
480
+ # attempt cannot block, so it precedes the ladder outright instead of
481
+ # riding a task a cancel could strand pending (FR-638-017
482
+ # no-leftover-task; PR #30094 review, round 19).
483
+ self._end_stdin()
484
+ await self._await_exit_or_terminate(deadline)
485
+
486
+ def _end_stdin(self) -> None:
487
+ stdin = self.child.stdin
488
+ if stdin is None or stdin.is_closing():
489
+ return
490
+ try:
491
+ if stdin.can_write_eof():
492
+ stdin.write_eof()
493
+ # Deliberately NO drain() here: a wedged write() may already be
494
+ # parked in stdin.drain(), and before the GH-74116 backport
495
+ # (< 3.10.8) asyncio's _drain_helper allows one waiter — a second
496
+ # dies with AssertionError that close() would re-raise after the
497
+ # host is already dead. write_eof() already marks the transport
498
+ # closing, every outcome here is swallowed, and _abort_stdin owns
499
+ # the pinned pipe (PR #30094 review, round 18); dropping the
500
+ # drain also made this whole method synchronous (round 19).
501
+ except (BrokenPipeError, ConnectionResetError, RuntimeError):
502
+ # RuntimeError: asyncio's own "socket transport closed" race —
503
+ # the same benign teardown overlap one layer down.
504
+ return
505
+
506
+ async def _await_exit_or_terminate(self, deadline: ShutdownDeadline) -> None:
507
+ # Every stage completes the moment the host is REAPED, not when its
508
+ # pipes close: a detached helper that inherited a stdio pipe holds
509
+ # asyncio's Process.wait() open forever after the child itself is
510
+ # gone. Python 3.10 settles wait() at the reap, 3.12 only at pipe
511
+ # EOF — so acting on `returncode is not None` here keeps close()
512
+ # bounded on BOTH (PR #30094 review). `_settled` wins first on a
513
+ # clean drain, so the reap poll adds no latency there. A cancel that
514
+ # lands ANYWHERE in the shutdown (here or the flush wait) is backed
515
+ # by `_shutdown`'s finally-SIGKILL, so this ladder carries no
516
+ # kill-on-cancel guard of its own (round 18).
517
+ await self._drain_window(deadline)
518
+ if self._reaped_or_settled():
519
+ await self._finalize_reaped()
520
+ return
521
+ self._signal(signal_module.SIGTERM)
522
+ grace = ShutdownDeadline(SIGTERM_GRACE_MS)
523
+ try:
524
+ await self._drain_window(grace)
525
+ finally:
526
+ grace.clear()
527
+ if self._reaped_or_settled():
528
+ await self._finalize_reaped()
529
+ return
530
+ self._kill_now()
531
+ await self._reaped_exit()
532
+
533
+ def _kill_now(self) -> None:
534
+ """One synchronous, reap-guarded kill — the never-orphan backstop
535
+ FM-638-2 mandates wherever nothing can await (the shutdown's
536
+ ``finally`` under a cancel, the ladder's last rung, a GC-finalized
537
+ ``initialize()``). Windows has no ``SIGKILL`` attribute; ``SIGTERM``
538
+ is ``TerminateProcess`` there, the same unconditional end
539
+ (PR #30094 review, rounds 17–19)."""
540
+ if self.child.returncode is None:
541
+ self._signal(getattr(signal_module, "SIGKILL", signal_module.SIGTERM))
542
+
543
+ def _reaped_or_settled(self) -> bool:
544
+ return self.child.returncode is not None or self._exited.done()
545
+
546
+ async def _reap_poll(self) -> None:
547
+ # Teardown-only bound (never the green drain path, which `_settled`
548
+ # wins): wakes the drain window once the watcher records the exit so
549
+ # a 3.12 held-pipe host skips the rest of the budget.
550
+ while self.child.returncode is None:
551
+ await asyncio.sleep(0.02)
552
+
553
+ async def _drain_window(self, deadline: ShutdownDeadline) -> None:
554
+ """Waits until the host settles, is reaped, or the deadline expires.
555
+
556
+ Owns and cleans up its own tasks so nothing outlives close()
557
+ (FR-638-017). Mints no ``_make_deadline`` — the one shutdown budget
558
+ stays a single injected deadline for the shared-budget contract.
559
+ """
560
+ settled = asyncio.ensure_future(_swallow(self._settled()))
561
+ expiry = asyncio.ensure_future(deadline.expired())
562
+ reap = asyncio.ensure_future(self._reap_poll())
563
+ try:
564
+ await asyncio.wait(
565
+ {settled, expiry, reap}, return_when=asyncio.FIRST_COMPLETED
566
+ )
567
+ finally:
568
+ for task in (settled, expiry, reap):
569
+ if not task.done():
570
+ task.cancel()
571
+ await asyncio.wait({settled, expiry, reap})
572
+ for task in (settled, expiry, reap):
573
+ if not task.cancelled():
574
+ task.exception()
575
+
576
+ async def _finalize_reaped(self) -> None:
577
+ """The single owner of post-reap teardown, reached by EVERY close
578
+ path (both ladder stages and the SIGKILL rung).
579
+
580
+ Settling `_reaped` makes `_exited` — and every close() awaiting it —
581
+ return regardless of a pipe a leaked helper still holds. Then give
582
+ both pipes a bounded grace to reach EOF on their own so a cleanly
583
+ exited host's final stderr/stdout is captured, and only past that
584
+ grace cut what a helper still holds: cancel the stderr drain, drop
585
+ our stdin end, and close the subprocess transport (which ends the
586
+ stdout read loop too, so a facade close never hangs on `_closed`).
587
+ FR-638-017 sanctions dropping output only past this bounded window
588
+ (PR #30094 review — zdwmeta's 3.10 facade-close gap).
589
+ """
590
+ if self.child.returncode is not None and not self._reaped.done():
591
+ self._reaped.set_result(self.child.returncode)
592
+ eof_wait = asyncio.ensure_future(self._await_pipes_eof())
593
+ _, pending = await asyncio.wait({eof_wait}, timeout=SIGTERM_GRACE_MS / 1000)
594
+ if pending:
595
+ eof_wait.cancel()
596
+ await asyncio.wait({eof_wait})
597
+ drain = self._stderr_task
598
+ if drain is not None and not drain.done():
599
+ drain.cancel()
600
+ await asyncio.wait({drain})
601
+ if drain is not None and not drain.cancelled():
602
+ # Retrieve a settled drain's exception (a raising on_stderr
603
+ # callback) so GC never logs it as unretrieved.
604
+ drain.exception()
605
+ await self._abort_stdin()
606
+ proc_transport = getattr(self.child, "_transport", None)
607
+ if proc_transport is not None:
608
+ try:
609
+ proc_transport.close()
610
+ except Exception:
611
+ # Best-effort, like the signal rungs: a pipe already
612
+ # mid-teardown must not rethrow out of close().
613
+ pass
614
+ await self._settled()
615
+
616
+ async def _await_pipes_eof(self) -> None:
617
+ # Wait for the host's final output to be consumed BEFORE the forced
618
+ # close: the stderr drain to finish and stdout to hit EOF. On a clean
619
+ # exit both happen at once (the host closed its write ends); a leaked
620
+ # helper holding one keeps it open, and the caller's grace then bounds
621
+ # the wait before the forced close (FR-638-017). `at_eof()` is a
622
+ # non-consuming read of the StreamReader the connection drains.
623
+ drain = self._stderr_task
624
+ if drain is not None:
625
+ await _swallow(drain)
626
+ # Only a live stdout consumer (the connection's read loop) has a tail
627
+ # worth preserving; with none, forcing the pipe shut drops nothing.
628
+ if not self._stdout_consumed:
629
+ return
630
+ stdout = self.child.stdout
631
+ while stdout is not None and not stdout.at_eof():
632
+ await asyncio.sleep(0.02)
633
+
634
+ async def _abort_stdin(self) -> None:
635
+ # Drop our end of the stdin pipe outright: `stdin.close()` is a
636
+ # no-op after `_end_stdin`'s `write_eof()` (the write transport
637
+ # returns early once closing), so a wedged 2 MiB write a helper
638
+ # inheriting fd 0 never drains still pins the pipe and hangs
639
+ # `_end_stdin`'s `drain()`. abort() drops the buffer and fires
640
+ # connection_lost (the TS twin's `stdin.destroy()`; PR #30094
641
+ # review). Best-effort like the signal rungs.
642
+ stdin = self.child.stdin
643
+ if stdin is None or stdin.transport is None:
644
+ return
645
+ try:
646
+ stdin.transport.abort()
647
+ except Exception:
648
+ pass
649
+ # Retrieve the write pipe's close result so py3.10 does not log a
650
+ # "Future exception was never retrieved" BrokenPipeError at teardown
651
+ # (3.12 suppresses it; nothing else awaits wait_closed). Bounded and
652
+ # swallowing — abort() has already fired connection_lost.
653
+ try:
654
+ await asyncio.wait_for(stdin.wait_closed(), SIGTERM_GRACE_MS / 1000)
655
+ except Exception:
656
+ pass
657
+
658
+ async def _reaped_exit(self) -> None:
659
+ # Past SIGKILL: wait for the REAP, not the pipes. 3.10 settles wait()
660
+ # at the reap; 3.12 needs the returncode check because a held pipe
661
+ # keeps wait() blocked. SIGKILL guarantees the reap, so this is
662
+ # bounded (FR-638-017; PR #30094 review).
663
+ while self.child.returncode is None:
664
+ if await self._settled_within(SIGTERM_GRACE_MS):
665
+ break
666
+ await self._finalize_reaped()
667
+
668
+ def _signal(self, sig: signal_module.Signals) -> None:
669
+ """One signal per escalation stage, group-first where owned (FR-017b).
670
+
671
+ A group signal that fails for ANY reason (expected: the group is
672
+ already gone) falls through to the direct child; the ladder never
673
+ rethrows out of a stage.
674
+ """
675
+ pid = self.child.pid
676
+ if self._owns_process_group and sys.platform != "win32":
677
+ try:
678
+ os.killpg(pid, sig)
679
+ return
680
+ except OSError:
681
+ pass # fall through to the direct-child path
682
+ try:
683
+ self.child.send_signal(sig)
684
+ except ProcessLookupError:
685
+ pass # already exited: the no-op the TS twin gets from Node
686
+
687
+ async def _settled(self) -> None:
688
+ try:
689
+ await asyncio.shield(self._exited)
690
+ except asyncio.CancelledError: # pragma: no cover - teardown only
691
+ raise
692
+ except Exception:
693
+ # A spawn/watch failure means no live child: shutdown complete.
694
+ return
695
+
696
+ async def _settled_within(self, budget_ms: int) -> bool:
697
+ # Deliberately ambient (NOT the injected factory): the SIGTERM grace
698
+ # is outside the one shared shutdown budget the seam models.
699
+ deadline = ShutdownDeadline(budget_ms)
700
+ try:
701
+ return await self._before_deadline(self._settled(), deadline)
702
+ finally:
703
+ deadline.clear()
704
+
705
+ @staticmethod
706
+ async def _before_deadline(
707
+ work: Awaitable[Any], deadline: ShutdownDeadline
708
+ ) -> bool:
709
+ # ensure_future returns a caller's own Task unchanged, so a caller
710
+ # that must cancel an abandoned wait (the flush half) can hold it.
711
+ # Work that can FAIL arrives pre-wrapped in _swallow; the settle
712
+ # waits below never raise Exception.
713
+ work_task = asyncio.ensure_future(work)
714
+ expiry_task = asyncio.ensure_future(deadline.expired())
715
+ done, _ = await asyncio.wait(
716
+ {work_task, expiry_task}, return_when=asyncio.FIRST_COMPLETED
717
+ )
718
+ if work_task in done:
719
+ expiry_task.cancel()
720
+ return True
721
+ # Keep the work task alive: a later stage may await the same settle.
722
+ return False
723
+
724
+
725
+ async def _swallow(work: Awaitable[Any]) -> None:
726
+ try:
727
+ await work
728
+ except Exception:
729
+ return
730
+
731
+
732
+ class MuseServeChild:
733
+ """One owned MSP host process with SS2.11 diagnostics and total exit mapping."""
734
+
735
+ def __init__(
736
+ self,
737
+ child: asyncio.subprocess.Process,
738
+ on_stderr: OnStderr | None,
739
+ shutdown_timeout_ms: int,
740
+ owns_process_group: bool,
741
+ ) -> None:
742
+ """Internal: use :meth:`spawn`."""
743
+ self._tail = StderrTail()
744
+
745
+ def _tap(chunk: str) -> None:
746
+ self._tail.push(chunk)
747
+ if on_stderr is not None:
748
+ on_stderr(chunk)
749
+
750
+ self._transport = ChildStdioTransport(
751
+ child,
752
+ _tap,
753
+ shutdown_timeout_ms,
754
+ owns_process_group,
755
+ )
756
+ self._exit: asyncio.Task[ExitClassification] = asyncio.ensure_future(
757
+ self._classified()
758
+ )
759
+
760
+ @property
761
+ def exit(self) -> Awaitable[ExitClassification]:
762
+ """Settles with the :class:`ExitClassification` of the observed exit
763
+ (never a synthesized one, INV-006 carry).
764
+
765
+ A fresh shield per access: a consumer's ``wait_for`` timeout must
766
+ not cancel the SDK's own watcher and break ``close()``
767
+ (PR #30094 review).
768
+ """
769
+ return asyncio.shield(self._exit)
770
+
771
+ async def _classified(self) -> ExitClassification:
772
+ status = await asyncio.shield(self._transport._exited)
773
+ # Wait for the drain task to settle so the final chunks the pipe
774
+ # delivered with the exit are in the tail — but BOUND it: on a
775
+ # NATURAL exit with no close() (a consumer just awaiting child.exit),
776
+ # a leaked helper holding stderr means the drain never sees EOF, and
777
+ # an unbounded wait would hang child.exit forever. Past the grace,
778
+ # cut the held pipe so classification proceeds with the tail so far
779
+ # (PR #30094 review). A raising on_stderr's exception is retrieved so
780
+ # GC never logs it as unretrieved.
781
+ drain = self._transport._stderr_task
782
+ if drain is not None:
783
+ _, pending = await asyncio.wait(
784
+ {drain}, timeout=SIGTERM_GRACE_MS / 1000
785
+ )
786
+ if pending:
787
+ drain.cancel()
788
+ await asyncio.wait({drain})
789
+ if not drain.cancelled():
790
+ drain.exception()
791
+ return classify_exit(status, self._tail.lines())
792
+
793
+ @staticmethod
794
+ async def spawn(
795
+ muse_bin: str,
796
+ *,
797
+ args: Sequence[str] = (),
798
+ cwd: str | None = None,
799
+ env: Mapping[str, str] | None = None,
800
+ on_stderr: OnStderr | None = None,
801
+ shutdown_timeout_ms: int = DEFAULT_SHUTDOWN_TIMEOUT_MS,
802
+ ) -> "MuseServeChild":
803
+ """Spawns one owned host (POSIX: as a process-group leader, FR-017b).
804
+
805
+ Args:
806
+ muse_bin: The host binary (see ``discovery.discover_muse_bin``).
807
+ args: Passed through verbatim.
808
+ cwd: Working directory for the host.
809
+ env: The host's environment (inherited when ``None``).
810
+ on_stderr: Raw evidence callback for consumers keeping a full
811
+ diagnostic log; the bounded tail is kept regardless.
812
+ shutdown_timeout_ms: The FR-017a drain budget; validated BEFORE
813
+ the child exists so a refusal cannot orphan one.
814
+
815
+ Raises:
816
+ MuseValidationError: An option is malformed (the C-638-2 spawn
817
+ seam, owner ruling #31276) — raised before any process is
818
+ spawned. A ``ValueError`` by inheritance, preserving the
819
+ boundary's pre-existing refusal contract.
820
+ """
821
+ options = parse_spawn_options(
822
+ command=muse_bin,
823
+ args=args,
824
+ cwd=cwd,
825
+ env=env,
826
+ shutdown_timeout_ms=shutdown_timeout_ms,
827
+ )
828
+ owns_process_group = sys.platform != "win32"
829
+ child = await asyncio.create_subprocess_exec(
830
+ options.command,
831
+ *options.args,
832
+ stdin=asyncio.subprocess.PIPE,
833
+ stdout=asyncio.subprocess.PIPE,
834
+ stderr=asyncio.subprocess.PIPE,
835
+ cwd=options.cwd,
836
+ env=options.env,
837
+ start_new_session=owns_process_group,
838
+ )
839
+ return MuseServeChild(
840
+ child,
841
+ on_stderr,
842
+ options.shutdown_timeout_ms,
843
+ owns_process_group,
844
+ )
845
+
846
+ @property
847
+ def stderr_tail(self) -> tuple[str, ...]:
848
+ """The bounded evidence captured so far (INV-010: never parsed)."""
849
+ return self._tail.lines()
850
+
851
+ async def close(self) -> ExitClassification:
852
+ """Bounded shutdown (FR-017a), resolving on the observed exit's row."""
853
+ await self._transport.close()
854
+ return await asyncio.shield(self._exit)
855
+
856
+
857
+ class SpawnedMspConnection:
858
+ """A fully initialized connection plus its owned process boundary.
859
+
860
+ Attributes:
861
+ connection: The ready :class:`Connection`.
862
+ child: The owned host process.
863
+ initialize_result: The typed handshake result.
864
+ """
865
+
866
+ def __init__(
867
+ self,
868
+ connection: "Connection",
869
+ transport: ChildStdioTransport,
870
+ child: MuseServeChild,
871
+ initialize_result: "InitializeResult",
872
+ ) -> None:
873
+ """Binds the initialized surfaces; built only by ``MspHandshake``."""
874
+ self.connection = connection
875
+ self._transport = transport
876
+ self.child = child
877
+ self.initialize_result = initialize_result
878
+
879
+ @property
880
+ def exited(self) -> Awaitable[ProcessExit]:
881
+ """Settles with the host's observed exit (shielded per access)."""
882
+ return self._transport.exited
883
+
884
+ async def close(self) -> ProcessExit:
885
+ """Orderly SS2.1.2 shutdown through the connection, then the exit."""
886
+ await self.connection.close()
887
+ return await asyncio.shield(self._transport._exited)
888
+
889
+
890
+ class MspHandshake:
891
+ """The only public state before ``initialized``: no traffic can be sent.
892
+
893
+ Attributes:
894
+ child: The owned host process (its ``stderr_tail`` and ``exit`` are
895
+ live from birth).
896
+ """
897
+
898
+ def __init__(
899
+ self,
900
+ child: MuseServeChild,
901
+ connection: "Connection",
902
+ ) -> None:
903
+ """Internal: use :func:`spawn_msp_connection`."""
904
+ self.child = child
905
+ self._transport = child._transport
906
+ self._connection = connection
907
+ self._started = False
908
+ self._transport._adopt_flush_source(connection._submission_tail)
909
+
910
+ @property
911
+ def exited(self) -> Awaitable[ProcessExit]:
912
+ """Settles with the host's observed exit (shielded per access)."""
913
+ return self._transport.exited
914
+
915
+ async def close(self) -> ProcessExit:
916
+ """Bounded close without ever having initialized."""
917
+ await self._connection.close()
918
+ return await asyncio.shield(self._transport._exited)
919
+
920
+ async def initialize(self, params: "InitializeParams") -> SpawnedMspConnection:
921
+ """Runs the SS1.4 sequence exactly once and gates the fingerprint.
922
+
923
+ Args:
924
+ params: The typed ``initialize`` params.
925
+
926
+ Returns:
927
+ The ready connection.
928
+
929
+ Raises:
930
+ ProtocolError: A second ``initialize``.
931
+ MuseValidationError: The result frame is malformed (the C-638-2
932
+ frame seam, owner ruling #31276); pydantic's
933
+ ``ValidationError`` rides as the cause.
934
+ MuseHostMismatchError: The served fingerprint differs from this
935
+ SDK's pin (spec 638 C-638-4 — exact failure, no bypass).
936
+
937
+ Every failure PAST the send-once check ends the ``muse serve`` child
938
+ this handshake spawned — it never orphans the host (spec 638
939
+ FM-638-2). (The send-once ``ProtocolError`` raises before that and
940
+ does NOT close: after a successful first ``initialize`` the host is
941
+ live and owned by the returned connection.) A post-request failure —
942
+ a mismatch, a malformed-frame rejection, or a CALLER cancel/
943
+ ``KeyboardInterrupt``/``SystemExit`` — awaits the bounded close
944
+ before the exception propagates, so the host is already dead and
945
+ ``child.stderr_tail`` is all that remains. A caller cancel is
946
+ therefore held for the bounded close (``shutdown_timeout_ms`` plus
947
+ the SIGTERM grace): never-orphan outweighs a prompt return, and the
948
+ close is bounded.
949
+ """
950
+ from .. import EXPECTED_SCHEMA_FINGERPRINT # circular-at-import-time otherwise
951
+
952
+ # The pin is read at CALL time through the module attribute, so the
953
+ # conformance suites drive recorded bundles by setting
954
+ # muse_code.EXPECTED_SCHEMA_FINGERPRINT for the test's scope — there
955
+ # is deliberately NO parameter: a public expected-fingerprint knob
956
+ # would let a caller pass the served value and switch the C-638-4
957
+ # strict gate off (PR #30094 review, thread 1).
958
+ expected = EXPECTED_SCHEMA_FINGERPRINT
959
+ if self._started:
960
+ raise ProtocolError("initialize may be sent only once per connection")
961
+ self._started = True
962
+ # Every post-latch failure below ends the host this handshake owns:
963
+ # the natural `await spawn_msp_connection(...).initialize(p)` drops
964
+ # the only handle, so a raise (malformed frame, mismatch) would orphan
965
+ # the `muse serve` child — the same never-orphan rule the bad-option
966
+ # path follows. close() is memoized, so an outer `finally: close()`
967
+ # stays a no-op (PR #30094 review).
968
+ try:
969
+ raw: Any = await self._connection.request("initialize", dict(params))
970
+ # The C-638-2 frame seam (owner ruling #31276): the members this
971
+ # handshake reads validate through a pydantic model, so a
972
+ # malformed frame is the typed MuseValidationError — the seam
973
+ # rejects BEFORE the fingerprint gate. Connection._response
974
+ # already rejected a non-object result with its own
975
+ # ProtocolError (PR #30094 review, thread 6).
976
+ frame = parse_initialize_result(raw)
977
+ fingerprint = frame.schema_info.fingerprint
978
+ if fingerprint != expected:
979
+ # Hard import: the generated package is this SDK's runtime
980
+ # dependency-by-construction (connection.py imports it at
981
+ # module load), so a guarded import here was a dead arm that
982
+ # could only DEGRADE the message below the C-638-4 contract
983
+ # (PR #30094 review, thread 5).
984
+ from muse_code_msp import REQUIRED_HOST_VERSION
985
+
986
+ raise MuseHostMismatchError(
987
+ served=fingerprint,
988
+ pinned=expected,
989
+ host_version=frame.server_info.version,
990
+ required_host_version=REQUIRED_HOST_VERSION,
991
+ )
992
+ self._connection.notify("initialized")
993
+ await self._connection.flush()
994
+ except GeneratorExit:
995
+ # A coroutine cannot await after GeneratorExit is thrown in (at
996
+ # GC finalization of an abandoned initialize()); awaiting close()
997
+ # here would log "coroutine ignored GeneratorExit". But never-
998
+ # orphan stays unconditional (FM-638-2): send the one legal
999
+ # SYNCHRONOUS signal — the same rung `_shutdown`'s finally uses —
1000
+ # so the host is dying before finalization proceeds (PR #30094
1001
+ # review, round 18). A real cancel/interrupt never arrives as
1002
+ # GeneratorExit — it is CancelledError/KeyboardInterrupt/
1003
+ # SystemExit, all caught below with the bounded inline close.
1004
+ self._transport._kill_now()
1005
+ raise
1006
+ except BaseException:
1007
+ # Never orphan (FM-638-2): close INLINE so the host is dead
1008
+ # before the exception propagates — for a mismatch, a
1009
+ # malformed-frame MuseValidationError, AND a caller cancel/
1010
+ # KeyboardInterrupt/SystemExit. A background fire-and-forget close
1011
+ # was tried and reverted: `asyncio.run` loop-teardown on the
1012
+ # natural wait_for-timeout path cancels an unsupervised task
1013
+ # before its SIGTERM rung, orphaning the child (PR #30094 review,
1014
+ # P0). The cost is that a caller cancel is held for the bounded
1015
+ # close (`shutdown_timeout_ms` + the SIGTERM grace) — never-orphan
1016
+ # outweighs prompt-return, and the close IS bounded. If the close
1017
+ # itself is cancelled mid-drain (Ctrl-C during teardown), the
1018
+ # ladder's finally still sends a synchronous SIGKILL.
1019
+ await self.close()
1020
+ raise
1021
+ result: "InitializeResult" = raw
1022
+ return SpawnedMspConnection(self._connection, self._transport, self.child, result)
1023
+
1024
+
1025
+ async def spawn_msp_connection(
1026
+ command: str,
1027
+ *,
1028
+ args: Sequence[str] = (),
1029
+ cwd: str | None = None,
1030
+ env: Mapping[str, str] | None = None,
1031
+ on_stderr: OnStderr | None = None,
1032
+ shutdown_timeout_ms: int = DEFAULT_SHUTDOWN_TIMEOUT_MS,
1033
+ connection_options: ConnectionOptions | None = None,
1034
+ ) -> MspHandshake:
1035
+ """Spawns one owned host and begins its SS1.4 handshake state machine.
1036
+
1037
+ Args:
1038
+ command: The host binary to run.
1039
+ args: Passed through verbatim (a bare ``muse`` is the TUI — pass
1040
+ ``["serve"]`` for the MSP host).
1041
+ cwd: Working directory for the host.
1042
+ env: The host's environment (inherited when ``None``).
1043
+ on_stderr: Raw stderr chunks, drained from birth, never parsed.
1044
+ shutdown_timeout_ms: See :meth:`MuseServeChild.spawn`.
1045
+ connection_options: Tuning for the connection machine.
1046
+
1047
+ Returns:
1048
+ The pre-``initialized`` handshake state.
1049
+ """
1050
+ child = await MuseServeChild.spawn(
1051
+ muse_bin=command,
1052
+ args=args,
1053
+ cwd=cwd,
1054
+ env=env,
1055
+ on_stderr=on_stderr,
1056
+ shutdown_timeout_ms=shutdown_timeout_ms,
1057
+ )
1058
+ try:
1059
+ connection = Connection(child._transport, **(connection_options or {}))
1060
+ except BaseException:
1061
+ # A bad option (a misspelled key, a wrong type) raises AFTER the host
1062
+ # exists; without this the child outlives the error with no handle to
1063
+ # end it — the same never-orphan rule the pre-spawn budget validation
1064
+ # follows (PR #30094 review, thread 8).
1065
+ await child.close()
1066
+ raise
1067
+ return MspHandshake(child, connection)