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,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)
|