49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
1409 lines
56 KiB
Python
1409 lines
56 KiB
Python
"""Tests for the stdio client transport.
|
|
|
|
Transport logic (framing, parse errors, shutdown escalation decisions) is tested in
|
|
process against a fake process injected through the spawn seam; only real OS behaviour
|
|
(process-group kill semantics, SIGKILL after an ignored SIGTERM, exec failure) uses
|
|
real subprocesses, synchronized only by kernel-level liveness sockets. The full
|
|
client<->server round trip is pinned by tests/interaction/transports/test_stdio.py.
|
|
"""
|
|
|
|
import errno
|
|
import gc
|
|
import logging
|
|
import math
|
|
import os
|
|
import signal
|
|
import sys
|
|
from collections.abc import Callable
|
|
from contextlib import AsyncExitStack, suppress
|
|
from pathlib import Path
|
|
from typing import TextIO, cast
|
|
|
|
import anyio
|
|
import anyio.abc
|
|
import anyio.lowlevel
|
|
import pytest
|
|
import trio
|
|
import trio.testing
|
|
from anyio.streams.memory import MemoryObjectReceiveStream
|
|
from mcp_types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
|
|
|
|
from mcp.client import stdio
|
|
from mcp.client._transport import ReadStream
|
|
from mcp.client.session import ClientSession
|
|
from mcp.client.stdio import (
|
|
_EXIT_POLL_INTERVAL,
|
|
StdioServerParameters,
|
|
_create_platform_compatible_process,
|
|
_terminate_process_tree,
|
|
stdio_client,
|
|
)
|
|
from mcp.os.posix import utilities as posix_utilities
|
|
from mcp.os.posix.utilities import terminate_posix_process_tree
|
|
from mcp.os.win32.utilities import FallbackProcess
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.shared.message import SessionMessage
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _module_runner_lease() -> None:
|
|
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`
|
|
and calls `trio.run` directly (see the tests/conftest.py original for the Windows hazard).
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-process fake of the spawned server process
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Everything between the spawn and the OS kill is pure SDK logic, so it is tested
|
|
# against this fake by monkeypatching the spawn and terminate seams. The OS half
|
|
# is tested separately below with real processes.
|
|
|
|
|
|
class _FakeStdin:
|
|
"""The fake process's stdin: records what the client writes, signals closure."""
|
|
|
|
def __init__(self, process: "FakeProcess") -> None:
|
|
self._process = process
|
|
|
|
async def send(self, data: bytes) -> None:
|
|
if self._process.stdin_send_gate is not None:
|
|
# A full pipe whose reader is busy elsewhere: the write completes
|
|
# only once the test's gate opens.
|
|
await self._process.stdin_send_gate.wait()
|
|
if self._process.stdin_send_blocks:
|
|
# A pipe whose reader stopped reading: the write never completes.
|
|
await anyio.sleep_forever()
|
|
if self._process.stdin_send_error is not None:
|
|
raise self._process.stdin_send_error
|
|
if self._process.returncode is not None:
|
|
# What the asyncio backend surfaces when writing to a dead child's pipe.
|
|
raise ConnectionResetError("Connection lost")
|
|
self._process.written.append(data)
|
|
|
|
async def aclose(self) -> None:
|
|
self._process.stdin_closed.set()
|
|
if self._process.on_stdin_close is not None:
|
|
self._process.on_stdin_close()
|
|
if self._process.stdin_aclose_error is not None:
|
|
raise self._process.stdin_aclose_error
|
|
|
|
|
|
class _FakeStdout:
|
|
"""The fake process's stdout: delegates to the in-memory stream.
|
|
|
|
Optionally surfaces the abrupt-death or close-time errors a real pipe can.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
inner: MemoryObjectReceiveStream[bytes],
|
|
*,
|
|
eof_error: Exception | None = None,
|
|
aclose_error: Exception | None = None,
|
|
on_receive: Callable[[], None],
|
|
) -> None:
|
|
self._inner = inner
|
|
self._eof_error = eof_error
|
|
self._aclose_error = aclose_error
|
|
self._on_receive = on_receive
|
|
|
|
async def receive(self) -> bytes:
|
|
try:
|
|
chunk = await self._inner.receive()
|
|
except anyio.EndOfStream:
|
|
if self._eof_error is not None:
|
|
# A hard-killed pipe surfaces a reset, not EOF, on the proactor loop.
|
|
raise self._eof_error from None
|
|
raise
|
|
self._on_receive()
|
|
return chunk
|
|
|
|
async def aclose(self) -> None:
|
|
await self._inner.aclose()
|
|
if self._aclose_error is not None:
|
|
raise self._aclose_error
|
|
# Real async closes yield; keeps the fake honest and shutdown scheduling realistic.
|
|
await anyio.lowlevel.checkpoint()
|
|
|
|
|
|
class FakeProcess:
|
|
"""In-memory stand-in for the spawned server process.
|
|
|
|
`feed`/`close_stdout` drive its stdout, `written` records client writes, `exit`
|
|
and the error knobs replay death and pipe failure modes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
on_stdin_close: Callable[[], None] | None = None,
|
|
stdin_aclose_error: Exception | None = None,
|
|
stdin_send_error: Exception | None = None,
|
|
stdin_send_blocks: bool = False,
|
|
stdin_send_gate: anyio.Event | None = None,
|
|
stdout_eof_error: Exception | None = None,
|
|
stdout_aclose_error: Exception | None = None,
|
|
on_stdout_receive: Callable[[], None] | None = None,
|
|
) -> None:
|
|
self._stdout_send, stdout_receive = anyio.create_memory_object_stream[bytes](math.inf)
|
|
self.stdout = _FakeStdout(
|
|
stdout_receive,
|
|
eof_error=stdout_eof_error,
|
|
aclose_error=stdout_aclose_error,
|
|
on_receive=self._dispatch_stdout_receive,
|
|
)
|
|
self.pid = 424242
|
|
self.written: list[bytes] = []
|
|
self.stdin_closed = anyio.Event()
|
|
self.returncode: int | None = None
|
|
self.on_stdin_close = on_stdin_close
|
|
self.stdin_aclose_error = stdin_aclose_error
|
|
self.stdin_send_error = stdin_send_error
|
|
self.stdin_send_blocks = stdin_send_blocks
|
|
self.stdin_send_gate = stdin_send_gate
|
|
self.on_stdout_receive = on_stdout_receive
|
|
self.stdin = _FakeStdin(self)
|
|
|
|
def _dispatch_stdout_receive(self) -> None:
|
|
# Late-bound so a test can assign `on_stdout_receive` after construction.
|
|
if self.on_stdout_receive is not None:
|
|
self.on_stdout_receive()
|
|
|
|
async def feed(self, data: bytes) -> None:
|
|
"""Make `data` readable on the fake process's stdout."""
|
|
await self._stdout_send.send(data)
|
|
|
|
def close_stdout(self) -> None:
|
|
"""End the fake process's stdout, as the kernel does when it dies."""
|
|
self._stdout_send.close()
|
|
|
|
def exit(self, code: int = 0) -> None:
|
|
"""Die: set the exit code and EOF stdout, as the kernel does."""
|
|
self.returncode = code
|
|
self.close_stdout()
|
|
|
|
def pending_stdout_chunks(self) -> int:
|
|
"""How many fed chunks the client has not yet pulled off the fake stdout."""
|
|
return self._stdout_send.statistics().current_buffer_used
|
|
|
|
|
|
def install_fake_process(
|
|
monkeypatch: pytest.MonkeyPatch, process: FakeProcess, *, grace_period: float | None = 0.2
|
|
) -> list[FakeProcess]:
|
|
"""Route stdio_client's spawn and terminate seams to `process`.
|
|
|
|
Returns the list of processes the (fake) tree termination was invoked on.
|
|
`grace_period=None` keeps the production stdin-close grace (affordable only on a
|
|
virtual clock).
|
|
"""
|
|
terminated: list[FakeProcess] = []
|
|
|
|
async def fake_spawn(
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
errlog: TextIO = sys.stderr,
|
|
cwd: Path | str | None = None,
|
|
) -> FakeProcess:
|
|
return process
|
|
|
|
async def fake_terminate_tree(proc: FakeProcess) -> None:
|
|
terminated.append(proc)
|
|
proc.exit(-15)
|
|
|
|
monkeypatch.setattr(stdio, "_create_platform_compatible_process", fake_spawn)
|
|
monkeypatch.setattr(stdio, "_terminate_process_tree", fake_terminate_tree)
|
|
if grace_period is not None:
|
|
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", grace_period)
|
|
return terminated
|
|
|
|
|
|
FAKE_PARAMS = StdioServerParameters(command="fake-server")
|
|
|
|
|
|
def _line(message: JSONRPCMessage) -> bytes:
|
|
"""The wire form of `message`: one JSON document on its own line."""
|
|
return (message.model_dump_json(by_alias=True, exclude_unset=True) + "\n").encode()
|
|
|
|
|
|
async def _next_message(read_stream: ReadStream[SessionMessage | Exception]) -> JSONRPCMessage:
|
|
received = await read_stream.receive()
|
|
assert isinstance(received, SessionMessage)
|
|
return received.message
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_messages_split_and_packed_across_chunks_are_reframed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Framing survives arbitrary chunk boundaries.
|
|
|
|
Split, packed, and CRLF-terminated messages are each delivered exactly once, and a
|
|
trailing line without a newline is not delivered.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
ping2 = JSONRPCRequest(jsonrpc="2.0", id=2, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
|
|
# First message split mid-bytes; its tail packed with the second, a
|
|
# CRLF-framed third (the SDK's own server emits \r\n on Windows; jiter
|
|
# treats the \r as JSON whitespace), and a partial fourth.
|
|
wire = _line(ping)
|
|
crlf_wire = ping2.model_dump_json(by_alias=True, exclude_unset=True).encode() + b"\r\n"
|
|
await process.feed(wire[:7])
|
|
await process.feed(wire[7:] + _line(pong) + crlf_wire + b'{"jsonrpc": "2.0", "id": 99')
|
|
|
|
assert await _next_message(read_stream) == ping
|
|
assert await _next_message(read_stream) == pong
|
|
assert await _next_message(read_stream) == ping2
|
|
|
|
# The partial trailing message is dropped at EOF, not delivered broken.
|
|
# (no branch: coverage mis-traces the exit arc of a `with` whose body
|
|
# raises inside a nested async context.)
|
|
with pytest.raises(anyio.EndOfStream): # pragma: no branch
|
|
process.close_stdout()
|
|
await read_stream.receive()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_each_outgoing_message_is_written_as_exactly_one_line(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Client -> server framing writes one line per message.
|
|
|
|
Every sent message reaches the server's stdin as exactly one newline-terminated
|
|
JSON document.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (_, write_stream):
|
|
await write_stream.send(SessionMessage(ping))
|
|
await write_stream.send(SessionMessage(pong))
|
|
# The zero-buffer handoff resumes this task before the writer has
|
|
# necessarily written; once all tasks block again, both writes have landed.
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert process.written == [_line(ping), _line(pong)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invalid_json_from_the_server_surfaces_as_an_in_stream_exception(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A line failing JSON-RPC validation is delivered as an Exception on the read stream.
|
|
|
|
The messages after it still come through.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
|
|
await process.feed(b"not json\n" + _line(ping))
|
|
|
|
error = await read_stream.receive()
|
|
# The transport surfaces parse failures as the underlying validation error.
|
|
assert isinstance(error, ValueError)
|
|
assert await _next_message(read_stream) == ping
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_server_that_dies_before_responding_fails_initialize_with_connection_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Server death (stdout EOF) is reported to the session as a closed connection.
|
|
|
|
The in-flight initialize fails instead of hanging.
|
|
"""
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
process.exit(1)
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
stdio_client(FAKE_PARAMS) as (read_stream, write_stream),
|
|
ClientSession(read_stream, write_stream) as session,
|
|
):
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await session.initialize()
|
|
|
|
assert exc_info.value.error.code == CONNECTION_CLOSED
|
|
assert exc_info.value.error.message == "Connection closed"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_server_that_exits_on_stdin_close_is_never_terminated(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Closing stdin (shutdown's first step) suffices for a well-behaved server.
|
|
|
|
The escalation is never invoked. The fake's stdin also raises on close, which the
|
|
shutdown must tolerate.
|
|
"""
|
|
|
|
process = FakeProcess(
|
|
on_stdin_close=lambda: process.exit(0),
|
|
stdin_aclose_error=anyio.ClosedResourceError(),
|
|
)
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass
|
|
|
|
assert terminated == []
|
|
assert process.stdin_closed.is_set()
|
|
|
|
|
|
def test_escalation_fires_once_and_only_after_the_grace_period(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A server that ignores stdin closure is terminated at the grace deadline exactly.
|
|
|
|
The kill lands no earlier than the production `PROCESS_TERMINATION_TIMEOUT` on the
|
|
runtime clock, and by the first `returncode` poll after it.
|
|
|
|
The suite's only direct trio use: anyio's pytest plugin cannot hand the backend a
|
|
clock, so the test calls `trio.run` itself with an autojumping `MockClock`. Every
|
|
time primitive rides that one virtual clock, so the production grace elapses
|
|
instantly and the bound can be two-sided (a wall-clock upper bound flakes under
|
|
load). That virtual seconds match wall seconds is the runtime clock's contract,
|
|
deliberately not re-tested here.
|
|
"""
|
|
|
|
class ClockedFakeProcess(FakeProcess):
|
|
"""Records the virtual time of each death.
|
|
|
|
Only the (fake) tree termination calls `exit` here, so these are the
|
|
escalation timestamps.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.exit_times: list[float] = []
|
|
|
|
def exit(self, code: int = 0) -> None:
|
|
self.exit_times.append(trio.current_time())
|
|
super().exit(code)
|
|
|
|
process = ClockedFakeProcess()
|
|
terminated = install_fake_process(monkeypatch, process, grace_period=None)
|
|
|
|
async def run_client() -> float:
|
|
with anyio.fail_after(stdio.PROCESS_TERMINATION_TIMEOUT + 5): # virtual seconds
|
|
async with stdio_client(FAKE_PARAMS):
|
|
# Evaluated just before the context exits: the moment cleanup begins.
|
|
return trio.current_time()
|
|
|
|
cleanup_started = trio.run(run_client, clock=trio.testing.MockClock(autojump_threshold=0))
|
|
|
|
assert terminated == [process]
|
|
virtual_elapsed = process.exit_times[0] - cleanup_started
|
|
# Two-sided: never before the grace deadline, and within one poll interval past it
|
|
# (shutdown's writer-flush poll); the epsilon absorbs virtual-sleep float drift.
|
|
assert (
|
|
stdio.PROCESS_TERMINATION_TIMEOUT
|
|
<= virtual_elapsed
|
|
<= stdio.PROCESS_TERMINATION_TIMEOUT + _EXIT_POLL_INTERVAL + 1e-9
|
|
), virtual_elapsed
|
|
|
|
|
|
def test_a_server_dying_in_the_final_poll_interval_is_not_escalated(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A server exiting in the poll interval the grace deadline cuts short is not escalated.
|
|
|
|
Such a server is dead, not hung: the timed-out grace wait must re-check `returncode`
|
|
before deciding to escalate, so this server is never terminated.
|
|
|
|
Runs on trio's MockClock (see the escalation-bound test above). The grace is
|
|
set to end mid-interval (0.105 with 0.01 polls) and the fake dies at 0.102
|
|
after its stdin closes, strictly between the last in-window poll (0.10) and
|
|
the deadline (0.105), so no two timers collide.
|
|
"""
|
|
process = FakeProcess()
|
|
terminated = install_fake_process(monkeypatch, process, grace_period=0.105)
|
|
|
|
async def run_client() -> None:
|
|
with anyio.fail_after(5): # virtual seconds
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def die_late() -> None:
|
|
await anyio.sleep(0.102)
|
|
process.exit(0)
|
|
|
|
# The grace wait starts when stdin closes; anchor the death there.
|
|
process.on_stdin_close = lambda: tg.start_soon(die_late)
|
|
# no branch: the tracer drops this nested async-with's arcs under
|
|
# trio's MockClock even though the body runs.
|
|
async with stdio_client(FAKE_PARAMS): # pragma: no branch
|
|
pass
|
|
|
|
trio.run(run_client, clock=trio.testing.MockClock(autojump_threshold=0))
|
|
|
|
assert terminated == []
|
|
assert process.returncode == 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancelling_the_client_still_runs_the_full_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Cancellation (a client timeout, app shutdown) must not skip the shutdown sequence.
|
|
|
|
Stdin is still closed and a server ignoring it is still terminated. Without the
|
|
shielded shutdown this leaks the process and can deadlock.
|
|
"""
|
|
process = FakeProcess()
|
|
terminated = install_fake_process(monkeypatch, process, grace_period=0.05)
|
|
entered = anyio.Event()
|
|
# Cancel a scope owned by the client's task, not the test's task group: a host
|
|
# self-cancel is delivered by throwing through this test function's suspended
|
|
# frames, and Python 3.11's tracer loses coverage events after such a throw()
|
|
# traversal (python/cpython#106749).
|
|
cancel_scope = anyio.CancelScope()
|
|
|
|
async def run_client_until_cancelled() -> None:
|
|
with cancel_scope:
|
|
async with stdio_client(FAKE_PARAMS):
|
|
entered.set()
|
|
await anyio.sleep_forever()
|
|
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(run_client_until_cancelled)
|
|
await entered.wait()
|
|
cancel_scope.cancel()
|
|
|
|
assert process.stdin_closed.is_set()
|
|
assert terminated == [process]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_writing_after_the_server_dies_reports_clean_closure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A send racing the server's death must not surface a raw backend exception.
|
|
|
|
The exception (ConnectionResetError in an exception group) must not escape the
|
|
context manager; the transport still shuts down cleanly.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (_, write_stream):
|
|
process.exit(1)
|
|
# The fake's stdin now raises ConnectionResetError, as a dead child's pipe does.
|
|
await write_stream.send(SessionMessage(ping))
|
|
|
|
assert process.written == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_exiting_with_an_unconsumed_server_message_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Exiting while a server message is still undelivered must be a clean exit.
|
|
|
|
Shutdown closes the read stream under the blocked reader task, and that closure
|
|
must not escape the caller as a BrokenResourceError in an exception group.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
# Feed a message and never receive it: the reader parses it and blocks
|
|
# delivering into the zero-buffer read stream until shutdown breaks the send.
|
|
await process.feed(_line(ping))
|
|
# Wait until the reader task is genuinely parked on its blocked send
|
|
# before shutdown closes the stream out from under it.
|
|
await anyio.wait_all_tasks_blocked()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_spawn_failure_propagates_the_error_and_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""When the spawn itself fails, the OSError reaches the caller and no streams leak.
|
|
|
|
The transport's internal streams are all closed; an unclosed stream would fail the
|
|
test through its GC-time ResourceWarning under filterwarnings=error.
|
|
"""
|
|
|
|
async def failing_spawn(
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
errlog: TextIO = sys.stderr,
|
|
cwd: Path | str | None = None,
|
|
) -> FakeProcess:
|
|
raise OSError(errno.EACCES, "Permission denied")
|
|
|
|
monkeypatch.setattr(stdio, "_create_platform_compatible_process", failing_spawn)
|
|
|
|
with pytest.raises(OSError) as exc_info:
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass # pragma: no cover
|
|
|
|
assert exc_info.value.errno == errno.EACCES
|
|
# Drop the ExceptionInfo before collecting: its traceback references the suspended
|
|
# stdio_client frame, which would keep leaked streams alive across the collect.
|
|
del exc_info
|
|
gc.collect()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_command_that_cannot_be_execed_raises_enoent() -> None:
|
|
"""A command that cannot be exec'd raises OSError(ENOENT) out of stdio_client."""
|
|
server_params = StdioServerParameters(
|
|
command="/path/to/nonexistent/command",
|
|
args=["--help"],
|
|
)
|
|
|
|
with pytest.raises(OSError) as exc_info:
|
|
async with stdio_client(server_params):
|
|
pass # pragma: no cover
|
|
|
|
assert exc_info.value.errno == errno.ENOENT
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancellation_during_spawn_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Cancellation while the spawn is still in flight must not leak the internal streams.
|
|
|
|
A caller timeout can fire mid-spawn (interpreter cold start); an unclosed stream
|
|
would fail the test through its GC-time ResourceWarning under filterwarnings=error.
|
|
"""
|
|
spawn_started = anyio.Event()
|
|
|
|
async def hanging_spawn(
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
errlog: TextIO = sys.stderr,
|
|
cwd: Path | str | None = None,
|
|
) -> FakeProcess:
|
|
spawn_started.set()
|
|
await anyio.sleep_forever()
|
|
raise NotImplementedError("unreachable: the spawn is cancelled while parked")
|
|
|
|
monkeypatch.setattr(stdio, "_create_platform_compatible_process", hanging_spawn)
|
|
|
|
# Cancel a scope owned by the client's task, not the test's task group: a host
|
|
# self-cancel is delivered by throwing through this test function's suspended
|
|
# frames, and Python 3.11's tracer loses coverage events after such a throw()
|
|
# traversal (python/cpython#106749).
|
|
cancel_scope = anyio.CancelScope()
|
|
|
|
async def run_client() -> None:
|
|
with cancel_scope:
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass # pragma: no cover
|
|
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(run_client)
|
|
await spawn_started.wait()
|
|
cancel_scope.cancel()
|
|
|
|
gc.collect()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_non_oserror_spawn_failure_propagates_and_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A non-OSError spawn failure also propagates and leaks no streams.
|
|
|
|
Spawning can fail with more than OSError (e.g. ValueError for a NUL byte in the
|
|
command); the error reaches the caller and the transport's internal streams are
|
|
still all closed (checked through GC-time ResourceWarnings, as above).
|
|
"""
|
|
|
|
async def failing_spawn(
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
errlog: TextIO = sys.stderr,
|
|
cwd: Path | str | None = None,
|
|
) -> FakeProcess:
|
|
raise ValueError("embedded null byte")
|
|
|
|
monkeypatch.setattr(stdio, "_create_platform_compatible_process", failing_spawn)
|
|
|
|
with pytest.raises(ValueError, match="embedded null byte"):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass # pragma: no cover
|
|
|
|
gc.collect()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_message_sent_just_before_exit_is_flushed_to_the_server(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A message the transport accepted must reach the server even on immediate exit.
|
|
|
|
The caller exits right after sending. Once the writer is parked waiting, a send is
|
|
a pure handoff that returns before the write lands, so the second message here is
|
|
the one shutdown must let the writer flush before closing the server's stdin.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (_, write_stream):
|
|
await write_stream.send(SessionMessage(ping))
|
|
await write_stream.send(SessionMessage(pong))
|
|
|
|
assert process.written == [_line(ping), _line(pong)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_failed_write_to_a_live_server_closes_the_read_stream_instead_of_hanging(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A failed write to a live server ends the read stream instead of hanging the session.
|
|
|
|
When a write fails but the server is still alive (stdout never EOFs), the transport
|
|
must end the read stream so a session maps the loss to CONNECTION_CLOSED instead of
|
|
waiting forever. EIO pins that plain OSError, not just ConnectionError, is handled.
|
|
|
|
Steps:
|
|
1. A send fails with EIO while the server is alive; the read stream ends.
|
|
2. Output the server produces afterwards is still drained, so it cannot wedge
|
|
on a full pipe.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
process = FakeProcess(
|
|
on_stdin_close=lambda: process.exit(0),
|
|
stdin_send_error=OSError(errno.EIO, "I/O error"),
|
|
)
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (read_stream, write_stream):
|
|
await write_stream.send(SessionMessage(ping))
|
|
|
|
with pytest.raises(anyio.EndOfStream):
|
|
await read_stream.receive()
|
|
|
|
await process.feed(_line(pong))
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert process.pending_stdout_chunks() == 0
|
|
|
|
assert process.written == []
|
|
assert terminated == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_exit_completes_when_a_write_is_wedged_in_a_pipe_no_one_reads(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Exiting stays bounded even when the writer is parked in a write that cannot complete.
|
|
|
|
A kill-surviving descendant can hold the read end without reading; the flush window
|
|
expires and the post-shutdown cancellation unparks the writer.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0), stdin_send_blocks=True)
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
monkeypatch.setattr(stdio, "_WRITER_FLUSH_TIMEOUT", 0.05)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (_, write_stream):
|
|
await write_stream.send(SessionMessage(ping))
|
|
# Wait until the writer task is genuinely parked inside the wedged send.
|
|
await anyio.wait_all_tasks_blocked()
|
|
|
|
assert process.written == []
|
|
assert terminated == []
|
|
assert process.stdin_closed.is_set()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_undelivered_server_output_is_drained_at_shutdown_so_the_server_can_exit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Output the caller never received is consumed during the stdin-close grace period.
|
|
|
|
A real server flushing its remaining output on the way out would otherwise block on
|
|
a full pipe, never reach its stdin read, and be killed despite being well-behaved.
|
|
The fake ignores stdin closure (so it is ultimately terminated); the pin is that its
|
|
backlog was drained during the grace window.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
process = FakeProcess()
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
# Three separate chunks: the reader parks delivering the first; the other
|
|
# two sit unconsumed in the pipe when shutdown begins.
|
|
await process.feed(_line(ping))
|
|
await process.feed(_line(pong))
|
|
await process.feed(_line(ping))
|
|
await anyio.wait_all_tasks_blocked()
|
|
assert process.pending_stdout_chunks() == 2
|
|
|
|
assert terminated == [process]
|
|
assert process.pending_stdout_chunks() == 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_shutdown_drains_stdout_first_so_a_wedged_writers_flush_can_complete(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Shutdown unblocks the reader's drain before waiting out the writer flush.
|
|
|
|
A server wedged writing its stdout cannot get to reading its stdin, so a client
|
|
write can sit in a full pipe; the drain is what unwedges the server and lets the
|
|
flush complete.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
|
|
received = 0
|
|
stdin_gate = anyio.Event()
|
|
|
|
def unwedge_once_drained() -> None:
|
|
# Accept the client's write only once all three output chunks are consumed,
|
|
# like a real server whose blocked stdout write gates its stdin read.
|
|
nonlocal received
|
|
received += 1
|
|
if received == 3:
|
|
stdin_gate.set()
|
|
|
|
process = FakeProcess(
|
|
on_stdin_close=lambda: process.exit(0),
|
|
stdin_send_gate=stdin_gate,
|
|
on_stdout_receive=unwedge_once_drained,
|
|
)
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
# A flush wait that never gets unwedged would outlast the whole test budget.
|
|
monkeypatch.setattr(stdio, "_WRITER_FLUSH_TIMEOUT", 30.0)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (_read_stream, write_stream):
|
|
# The reader parks delivering a message nobody receives, with more
|
|
# chunks backed up behind it; the writer parks in the gated send.
|
|
await process.feed(_line(ping))
|
|
await process.feed(_line(pong))
|
|
await process.feed(_line(ping))
|
|
await write_stream.send(SessionMessage(ping))
|
|
await anyio.wait_all_tasks_blocked()
|
|
|
|
assert terminated == []
|
|
assert len(process.written) == 1
|
|
assert process.pending_stdout_chunks() == 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancellation_with_undelivered_backlog_still_drains_and_spares_the_server(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Cancellation must not skip the shutdown drain.
|
|
|
|
A well-behaved server that can only exit once its remaining output is consumed (a
|
|
real one blocks on a full stdout pipe) still exits within the grace period and is
|
|
never terminated.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
pong = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
|
|
process = FakeProcess()
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
def exit_when_flushed() -> None:
|
|
# The fake exits only once its stdin has closed AND its output backlog
|
|
# has been consumed, like a real server wedged writing its stdout.
|
|
if process.stdin_closed.is_set() and process.pending_stdout_chunks() == 0:
|
|
process.exit(0)
|
|
|
|
process.on_stdin_close = exit_when_flushed
|
|
process.on_stdout_receive = exit_when_flushed
|
|
|
|
entered = anyio.Event()
|
|
# Cancel a scope owned by the client's task, not the test's task group (see
|
|
# test_cancelling_the_client_still_runs_the_full_shutdown).
|
|
cancel_scope = anyio.CancelScope()
|
|
|
|
async def run_client_until_cancelled() -> None:
|
|
with cancel_scope:
|
|
async with stdio_client(FAKE_PARAMS):
|
|
await process.feed(_line(ping))
|
|
await process.feed(_line(pong))
|
|
await process.feed(_line(ping))
|
|
entered.set()
|
|
await anyio.sleep_forever()
|
|
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(run_client_until_cancelled)
|
|
await entered.wait()
|
|
cancel_scope.cancel()
|
|
|
|
assert process.pending_stdout_chunks() == 0
|
|
assert terminated == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_invalid_utf8_flushed_by_a_dying_server_does_not_break_shutdown(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The shutdown drain consumes raw bytes.
|
|
|
|
A server flushing non-UTF-8 output (a crash dump, say) on its way out must not
|
|
abort the drain or surface a UnicodeDecodeError out of the context manager.
|
|
"""
|
|
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
|
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
# Park the reader delivering a message nobody receives, then queue
|
|
# bytes that are not valid UTF-8 behind it.
|
|
await process.feed(_line(ping))
|
|
await anyio.wait_all_tasks_blocked()
|
|
await process.feed(b"\xff\xfe not utf-8\n")
|
|
|
|
assert terminated == []
|
|
assert process.pending_stdout_chunks() == 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_kill_racing_a_pending_stdout_read_is_swallowed_during_shutdown(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A hard kill during a pending stdout read must not escape the context manager.
|
|
|
|
The read surfaces ConnectionResetError on the proactor backend; being expected
|
|
teardown noise, it is not logged as an error either.
|
|
"""
|
|
process = FakeProcess(stdout_eof_error=ConnectionResetError("read torn down by kill"))
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass # the fake ignores stdin closure, so shutdown must escalate
|
|
|
|
assert terminated == [process]
|
|
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_mid_session_stdout_failure_is_logged_and_surfaces_as_clean_closure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A mid-session stdout read failure ends the read stream cleanly and is logged.
|
|
|
|
A failure outside shutdown surfaces no raw exception out of the context manager and
|
|
leaves an error log identifying the failure, unlike the silent shutdown case.
|
|
"""
|
|
process = FakeProcess(
|
|
on_stdin_close=lambda: process.exit(0),
|
|
stdout_eof_error=ConnectionResetError("pipe failed mid-session"),
|
|
)
|
|
install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
|
|
process.exit(1)
|
|
# (no branch: coverage mis-traces the exit arc of a `with` whose body
|
|
# raises inside a nested async context.)
|
|
with pytest.raises(anyio.EndOfStream): # pragma: no branch
|
|
await read_stream.receive()
|
|
|
|
assert "stdout failed mid-session" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_failing_stdout_close_still_closes_the_transport_streams(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A close-time error on the process's stdout must not abort the rest of the shutdown.
|
|
|
|
Such an error (a contended pipe handle on the Windows fallback) still leaves the
|
|
context exiting cleanly and the internal streams all closed (checked via GC-time
|
|
ResourceWarnings).
|
|
"""
|
|
process = FakeProcess(
|
|
on_stdin_close=lambda: process.exit(0),
|
|
stdout_aclose_error=OSError(errno.EBADF, "Bad file descriptor"),
|
|
)
|
|
terminated = install_fake_process(monkeypatch, process)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass
|
|
|
|
assert terminated == []
|
|
gc.collect()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_process_surviving_the_kill_escalation_is_logged_and_abandoned(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A process surviving the whole kill escalation is logged and abandoned.
|
|
|
|
If the process is still alive after the escalation (D-state, an unsignalable
|
|
survivor), shutdown still completes, bounded, and leaves a warning instead of
|
|
silently leaking a live process.
|
|
"""
|
|
process = FakeProcess() # ignores stdin closure and survives "termination"
|
|
install_fake_process(monkeypatch, process, grace_period=0.05)
|
|
|
|
stubborn: list[FakeProcess] = []
|
|
|
|
async def stubborn_terminate(proc: FakeProcess) -> None:
|
|
stubborn.append(proc) # the kill has no effect
|
|
|
|
monkeypatch.setattr(stdio, "_terminate_process_tree", stubborn_terminate)
|
|
monkeypatch.setattr(stdio, "_KILL_REAP_TIMEOUT", 0.05)
|
|
|
|
with anyio.fail_after(5):
|
|
async with stdio_client(FAKE_PARAMS):
|
|
pass
|
|
|
|
assert stubborn == [process]
|
|
assert process.returncode is None
|
|
assert "still alive after the kill escalation" in caplog.text
|
|
# The fake "survived", so nothing ever EOF'd its stdout pipe; release it here
|
|
# or its GC-time ResourceWarning would fail a later test.
|
|
process.close_stdout()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POSIX tree-termination policy, tested through the sanctioned killpg seam
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# `mcp.os.posix.utilities` is coverage-omitted and the sanctioned place to monkeypatch
|
|
# OS calls. These pin the EPERM policy without a foreign-euid process: macOS killpg
|
|
# raises EPERM when *any* group member cannot be signalled, even if others were.
|
|
|
|
|
|
class _StubPosixProcess:
|
|
"""The two attributes `terminate_posix_process_tree` touches.
|
|
|
|
They are the pgid source and the reap-progress probe.
|
|
"""
|
|
|
|
pid = 54321
|
|
returncode: int | None = None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX killpg semantics")
|
|
# lax no cover: Windows CI jobs enforce 100% coverage per job and skip this test.
|
|
async def test_an_eperm_group_that_dies_during_the_grace_period_is_not_sigkilled( # pragma: lax no cover
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""EPERM from the SIGTERM killpg no longer short-circuits termination.
|
|
|
|
The grace wait still runs, and a group observed to be gone during it is never
|
|
SIGKILLed.
|
|
"""
|
|
calls: list[tuple[int, int]] = []
|
|
probes = 0
|
|
|
|
def fake_killpg(pgid: int, sig: int) -> None:
|
|
nonlocal probes
|
|
calls.append((pgid, sig))
|
|
if sig == signal.SIGTERM:
|
|
raise PermissionError("one group member has a foreign euid")
|
|
if sig == 0:
|
|
probes += 1
|
|
if probes == 1:
|
|
raise PermissionError("survivors we may not signal")
|
|
raise ProcessLookupError("group is gone")
|
|
raise NotImplementedError("no other signal should be sent")
|
|
|
|
monkeypatch.setattr(posix_utilities.os, "killpg", fake_killpg)
|
|
stub = _StubPosixProcess()
|
|
|
|
with anyio.fail_after(5):
|
|
await terminate_posix_process_tree(cast(anyio.abc.Process, stub))
|
|
|
|
assert calls == [(stub.pid, signal.SIGTERM), (stub.pid, 0), (stub.pid, 0)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX killpg semantics")
|
|
# lax no cover: same Windows-runner coverage reason as above.
|
|
async def test_an_eperm_group_that_outlives_the_grace_period_is_still_sigkilled( # pragma: lax no cover
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Even when every probe reports EPERM, the SIGKILL escalation still fires.
|
|
|
|
It fires after the grace period, and its own EPERM is tolerated. Pre-fix, EPERM at
|
|
SIGTERM abandoned the group escalation for a leader-only kill, leaking every other
|
|
group member. The tiny timeout is the time-based grace period under test.
|
|
"""
|
|
calls: list[tuple[int, int]] = []
|
|
|
|
def fake_killpg(pgid: int, sig: int) -> None:
|
|
calls.append((pgid, sig))
|
|
if sig in (signal.SIGTERM, 0, signal.SIGKILL):
|
|
raise PermissionError("a foreign-euid member never goes away")
|
|
raise NotImplementedError("no other signal should be sent")
|
|
|
|
monkeypatch.setattr(posix_utilities.os, "killpg", fake_killpg)
|
|
stub = _StubPosixProcess()
|
|
|
|
with anyio.fail_after(5):
|
|
await terminate_posix_process_tree(cast(anyio.abc.Process, stub), timeout_seconds=0.05)
|
|
|
|
assert calls[0] == (stub.pid, signal.SIGTERM)
|
|
assert calls[-1] == (stub.pid, signal.SIGKILL)
|
|
assert set(calls[1:-1]) == {(stub.pid, 0)}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"])
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX killpg semantics")
|
|
# lax no cover: same Windows-runner coverage reason as above.
|
|
async def test_the_grace_wait_reads_returncode_so_trio_can_reap_the_leaders_zombie( # pragma: lax no cover
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The wait between SIGTERM and SIGKILL reads `process.returncode` while it polls.
|
|
|
|
On trio that property calls `Popen.poll()`, whose reap stops the leader's zombie
|
|
from keeping the group alive for the full timeout (see terminate_posix_process_tree).
|
|
Regression pin for the read itself, on both backends; the reaping side effect is
|
|
trio's documented behaviour, deliberately not re-tested here.
|
|
"""
|
|
|
|
calls: list[tuple[int, int]] = []
|
|
|
|
def fake_killpg(pgid: int, sig: int) -> None:
|
|
# SIGTERM is accepted and every liveness probe reports survivors, so the
|
|
# grace wait runs to its (tiny) timeout and the SIGKILL escalation fires.
|
|
calls.append((pgid, sig))
|
|
|
|
class _ReadCountingProcess:
|
|
"""A live-forever leader whose `returncode` property counts its reads."""
|
|
|
|
pid = 54321
|
|
|
|
def __init__(self) -> None:
|
|
self.returncode_reads = 0
|
|
|
|
@property
|
|
def returncode(self) -> int | None:
|
|
self.returncode_reads += 1
|
|
return None
|
|
|
|
monkeypatch.setattr(posix_utilities.os, "killpg", fake_killpg)
|
|
stub = _ReadCountingProcess()
|
|
|
|
with anyio.fail_after(5):
|
|
await terminate_posix_process_tree(cast(anyio.abc.Process, stub), timeout_seconds=0.05)
|
|
|
|
# The wait ran to its deadline (the escalation fired)...
|
|
assert calls[0] == (stub.pid, signal.SIGTERM)
|
|
assert calls[-1] == (stub.pid, signal.SIGKILL)
|
|
# ...and `returncode` was read while it polled, the read that reaps on trio.
|
|
assert stub.returncode_reads >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real-process tests: the OS facts no fake can certify
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# These pin kernel behaviour (process-group kill semantics, SIGKILL delivery) via a
|
|
# socket liveness probe, no sleeps or polls: `accept()` blocks until the subprocess
|
|
# connects, proving it runs (and its pre-connect setup ran); after cleanup, `receive(1)`
|
|
# raises EndOfStream (FIN) or BrokenResourceError (RST, typical of SIGKILL and Windows
|
|
# job termination) because the kernel closes a dead process's file descriptors.
|
|
|
|
|
|
def _connect_back_script(port: int) -> str:
|
|
"""Return a ``python -c`` liveness-probe body: connect to `port`, send `b'alive'`, block forever."""
|
|
return (
|
|
f"import socket, time\n"
|
|
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
|
|
f"s.sendall(b'alive')\n"
|
|
f"time.sleep(3600)\n"
|
|
)
|
|
|
|
|
|
async def _open_liveness_listener() -> tuple[anyio.abc.SocketListener, int]:
|
|
"""Open a TCP listener on localhost and return it along with its port."""
|
|
multi = await anyio.create_tcp_listener(local_host="127.0.0.1")
|
|
sock = multi.listeners[0]
|
|
assert isinstance(sock, anyio.abc.SocketListener)
|
|
addr = sock.extra(anyio.abc.SocketAttribute.local_address)
|
|
# IPv4 local_address is (host: str, port: int)
|
|
assert isinstance(addr, tuple) and len(addr) >= 2 and isinstance(addr[1], int)
|
|
return sock, addr[1]
|
|
|
|
|
|
async def _accept_alive(sock: anyio.abc.SocketListener) -> anyio.abc.SocketStream:
|
|
"""Accept one connection and assert the peer sent ``b'alive'``.
|
|
|
|
Blocks until a subprocess connects (the outer test bounds this with
|
|
``anyio.fail_after``).
|
|
"""
|
|
stream = await sock.accept()
|
|
msg = await stream.receive(5)
|
|
assert msg == b"alive", f"expected b'alive', got {msg!r}"
|
|
return stream
|
|
|
|
|
|
async def _assert_stream_closed(stream: anyio.abc.SocketStream) -> None:
|
|
"""Assert the peer holding the other end of `stream` has terminated."""
|
|
with anyio.fail_after(5.0), pytest.raises((anyio.EndOfStream, anyio.BrokenResourceError)):
|
|
await stream.receive(1)
|
|
|
|
|
|
# lax no cover: only called by win32-skipped tests; Windows CI jobs enforce 100%
|
|
# coverage per job, where these helpers never execute.
|
|
async def _wait_until_exited(proc: anyio.abc.Process) -> None: # pragma: lax no cover
|
|
"""Poll `returncode` until the process itself dies.
|
|
|
|
Not `proc.wait()`: on asyncio that also waits for the pipes to close, conflating
|
|
process death with pipe state.
|
|
"""
|
|
while proc.returncode is None:
|
|
await anyio.sleep(0.01)
|
|
|
|
|
|
async def _reap(proc: anyio.abc.Process) -> None: # pragma: lax no cover
|
|
"""Reap an already-killed process and release its pipe transports.
|
|
|
|
Draining stdout to EOF lets the asyncio pipe transport observe the closure instead
|
|
of warning at GC. The bound swallows a hung cleanup on purpose; reaping is just a
|
|
safety net.
|
|
"""
|
|
with anyio.move_on_after(5.0):
|
|
await proc.wait()
|
|
assert proc.stdin is not None
|
|
assert proc.stdout is not None
|
|
await proc.stdin.aclose()
|
|
with suppress(anyio.EndOfStream, anyio.BrokenResourceError, anyio.ClosedResourceError):
|
|
await proc.stdout.receive(65536)
|
|
await proc.stdout.aclose()
|
|
|
|
|
|
def _record_spawned_processes(monkeypatch: pytest.MonkeyPatch) -> list[anyio.abc.Process | FallbackProcess]:
|
|
"""Record every process `stdio_client` spawns (the real spawn still runs).
|
|
|
|
A test can inspect each process afterwards and tear its process group down on
|
|
failure.
|
|
"""
|
|
spawned: list[anyio.abc.Process | FallbackProcess] = []
|
|
|
|
async def recording_spawn(
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
errlog: TextIO = sys.stderr,
|
|
cwd: Path | str | None = None,
|
|
) -> anyio.abc.Process | FallbackProcess:
|
|
process = await _create_platform_compatible_process(command, args, env, errlog, cwd)
|
|
spawned.append(process)
|
|
return process
|
|
|
|
monkeypatch.setattr(stdio, "_create_platform_compatible_process", recording_spawn)
|
|
return spawned
|
|
|
|
|
|
# lax no cover: registered on every platform but a no-op on Windows, whose runners
|
|
# enforce 100% coverage per job.
|
|
def _kill_spawn_groups(spawned: list[anyio.abc.Process | FallbackProcess]) -> None: # pragma: lax no cover
|
|
"""Failure-path safety net: SIGKILL each spawn-time process group.
|
|
|
|
This stops a test failing mid-body from orphaning its sleep-forever descendants.
|
|
A no-op when the test passed, and on Windows (no process group to signal; the Job
|
|
Object covers strays).
|
|
"""
|
|
if sys.platform == "win32":
|
|
return
|
|
for process in spawned:
|
|
# macOS killpg raises EPERM for a group holding only unreaped zombies.
|
|
with suppress(ProcessLookupError, PermissionError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_exiting_the_context_terminates_the_entire_process_tree(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Exiting `stdio_client` kills the server's whole process tree.
|
|
|
|
The tree is a parent that exits instantly on SIGTERM (so the group must outlive its
|
|
leader), a child, and a grandchild, each death observed through its liveness socket
|
|
closing. The escalation timing is pinned in process by
|
|
test_escalation_fires_once_and_only_after_the_grace_period; the production grace
|
|
constant's value is deliberately unpinned.
|
|
"""
|
|
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 0.2)
|
|
spawned = _record_spawned_processes(monkeypatch)
|
|
|
|
async with AsyncExitStack() as stack:
|
|
stack.callback(_kill_spawn_groups, spawned)
|
|
sock, port = await _open_liveness_listener()
|
|
stack.push_async_callback(sock.aclose)
|
|
|
|
grandchild = _connect_back_script(port)
|
|
child = (
|
|
f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {grandchild!r}])\n"
|
|
+ _connect_back_script(port)
|
|
)
|
|
# The parent exits immediately on SIGTERM and never reads stdin, so cleanup
|
|
# must escalate, and the group kill must work even as its leader dies first.
|
|
parent = (
|
|
f"import signal, subprocess, sys, time\n"
|
|
f"signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n"
|
|
f"subprocess.Popen([sys.executable, '-c', {child!r}])\n" + _connect_back_script(port)
|
|
)
|
|
server_params = StdioServerParameters(command=sys.executable, args=["-c", parent])
|
|
|
|
# The bound covers three Python interpreter cold starts on a loaded runner;
|
|
# a healthy run takes well under a second.
|
|
with anyio.fail_after(15.0):
|
|
async with stdio_client(server_params):
|
|
streams = [await _accept_alive(sock) for _ in range(3)]
|
|
for stream in streams:
|
|
stack.push_async_callback(stream.aclose)
|
|
|
|
for stream in streams:
|
|
await _assert_stream_closed(stream)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process-group semantics")
|
|
# lax no cover: Windows CI jobs enforce 100% coverage per job and skip this test.
|
|
async def test_tree_kill_reaches_children_after_the_leader_has_already_exited() -> None: # pragma: lax no cover
|
|
"""Killing the tree of an already-exited process still reaches its surviving children.
|
|
|
|
The process group outlives its leader, and the group ID is the leader's pid by
|
|
construction (start_new_session), not something to look up from the (reaped)
|
|
leader.
|
|
"""
|
|
async with AsyncExitStack() as stack:
|
|
sock, port = await _open_liveness_listener()
|
|
stack.push_async_callback(sock.aclose)
|
|
|
|
child = _connect_back_script(port)
|
|
# The parent spawns the child and exits immediately: the group leader is dead
|
|
# (and reaped) by the time the tree is terminated.
|
|
parent = f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {child!r}])\n"
|
|
proc = await _create_platform_compatible_process(sys.executable, ["-c", parent])
|
|
assert isinstance(proc, anyio.abc.Process)
|
|
stack.callback(_kill_spawn_groups, [proc])
|
|
stack.push_async_callback(_reap, proc)
|
|
|
|
# Two interpreter cold starts on a loaded runner; healthy runs take ~0.2s.
|
|
with anyio.fail_after(10.0):
|
|
stream = await _accept_alive(sock)
|
|
stack.push_async_callback(stream.aclose)
|
|
# The child connecting proves the parent ran; wait for the leader itself
|
|
# to be gone so the kill exercises the dead-leader path.
|
|
await _wait_until_exited(proc)
|
|
|
|
await _terminate_process_tree(proc)
|
|
|
|
await _assert_stream_closed(stream)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process-group semantics")
|
|
# lax no cover: same Windows-runner coverage reason as above.
|
|
async def test_terminating_an_already_exited_process_is_a_no_op() -> None: # pragma: lax no cover
|
|
"""Once the whole group is gone, tree termination returns without error.
|
|
|
|
It does not fall back to signalling a reaped pid.
|
|
"""
|
|
proc = await _create_platform_compatible_process(sys.executable, ["-c", "pass"])
|
|
assert isinstance(proc, anyio.abc.Process)
|
|
|
|
# The bound covers one interpreter cold start on a loaded runner; a healthy run
|
|
# takes well under a second.
|
|
with anyio.fail_after(10.0):
|
|
await _wait_until_exited(proc)
|
|
await _terminate_process_tree(proc)
|
|
await _reap(proc)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different")
|
|
# lax no cover: Windows CI jobs enforce 100% coverage per job and skip this test.
|
|
async def test_escalation_kills_a_process_that_ignores_sigterm( # pragma: lax no cover
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Cleanup escalates past SIGTERM and kills a process that ignores it.
|
|
|
|
The child installs SIG_IGN *before* connecting to the liveness socket, so the
|
|
ignore is guaranteed in place; SIGKILL delivery is proven by the kernel closing
|
|
the socket. The only test of the SIGTERM-then-SIGKILL escalation itself; the
|
|
production constants' values are deliberately unpinned.
|
|
"""
|
|
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 0.2)
|
|
monkeypatch.setattr(stdio, "FORCE_KILL_TIMEOUT", 0.2)
|
|
spawned = _record_spawned_processes(monkeypatch)
|
|
|
|
async with AsyncExitStack() as stack:
|
|
stack.callback(_kill_spawn_groups, spawned)
|
|
sock, port = await _open_liveness_listener()
|
|
stack.push_async_callback(sock.aclose)
|
|
|
|
script = "import signal\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + _connect_back_script(port)
|
|
server_params = StdioServerParameters(command=sys.executable, args=["-c", script])
|
|
|
|
# The bound covers an interpreter cold start on a loaded runner plus the two
|
|
# shortened escalation waits; a healthy run takes well under a second.
|
|
with anyio.fail_after(15.0):
|
|
async with stdio_client(server_params):
|
|
stream = await _accept_alive(sock)
|
|
stack.push_async_callback(stream.aclose)
|
|
|
|
await _assert_stream_closed(stream)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs procfs to enumerate open file descriptors")
|
|
# lax no cover: Windows CI jobs enforce 100% coverage per job, have no procfs, and skip this.
|
|
async def test_a_graceful_exit_with_a_surviving_child_leaks_no_pipe_fds( # pragma: lax no cover
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A graceful exit with a surviving child must not leak the client's pipe fds.
|
|
|
|
A server may exit cleanly on stdin closure while leaving a child holding the
|
|
inherited pipe ends (the POSIX policy: survivors are the server's business). The
|
|
client must still release its own pipe fds and subprocess transport at shutdown
|
|
(on asyncio nothing else ever closes them while the orphan holds the pipe) instead
|
|
of leaking them for the orphan's lifetime.
|
|
"""
|
|
spawned = _record_spawned_processes(monkeypatch)
|
|
|
|
async with AsyncExitStack() as stack:
|
|
stack.callback(_kill_spawn_groups, spawned)
|
|
sock, port = await _open_liveness_listener()
|
|
stack.push_async_callback(sock.aclose)
|
|
|
|
child = _connect_back_script(port)
|
|
# The server hands its inherited pipes to a child, then exits as soon as its
|
|
# stdin closes: the well-behaved graceful path, so no kill ever happens.
|
|
server = f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {child!r}])\nsys.stdin.read()\n"
|
|
server_params = StdioServerParameters(command=sys.executable, args=["-c", server])
|
|
|
|
gc.collect() # settle earlier garbage so its collection cannot close fds mid-test
|
|
baseline = set(os.listdir("/proc/self/fd"))
|
|
|
|
# Two interpreter cold starts on a loaded runner; healthy runs take ~0.3s.
|
|
with anyio.fail_after(15.0):
|
|
async with stdio_client(server_params):
|
|
stream = await _accept_alive(sock)
|
|
await stream.aclose()
|
|
|
|
leader = spawned[0]
|
|
assert isinstance(leader, anyio.abc.Process)
|
|
# The graceful path: exited on stdin closure, no termination involved.
|
|
assert leader.returncode == 0
|
|
# Subset, not equality: other machinery may close fds, but never open new
|
|
# ones; a leaked pipe fd would show up as an extra entry.
|
|
assert set(os.listdir("/proc/self/fd")) <= baseline
|