Skip to content

Commit fb960d9

Browse files
committed
Add real-subprocess stdio lifecycle tests for POSIX and Windows
New tests/transports/stdio/ suite pinning the client transport's process-lifecycle contracts against real subprocesses, synchronized through TCP liveness sockets (kernel FIN/RST and connect-back) with no sleeps and no timing assertions: - Both platforms: a well-behaved server exits on stdin close and is reaped without escalation; cancellation mid-session still terminates the whole server tree; a server that exits mid-session keeps its own exit code; server stderr reaches the errlog file; FallbackProcess reports death through returncode without wait() and its wait() is cancellable (the SelectorEventLoop fallback wraps a plain Popen, so these run on every platform with waitid where available). - POSIX: a gracefully-exited server's background child survives client shutdown (the documented policy), proven by an echo round trip plus a never-escalated seam; the surviving child's next write to its inherited stdout fails with EPIPE once the client has released its pipe ends. - Windows: the same scenario's documented opposite outcome - the child is reaped when the Job Object handle closes at shutdown; a SelectorEventLoop session engages the FallbackProcess wrapper and completes a clean lifecycle; a print()-based server emitting CRLF line endings round-trips messages. Shared helpers live in _liveness.py (extracted from the real-process section of tests/client/test_stdio.py; consolidating that file onto this module is follow-up work), with conftest fixtures recording the spawn and terminate seams and reaping spawn-time process groups on teardown.
1 parent 3035984 commit fb960d9

7 files changed

Lines changed: 769 additions & 0 deletions

File tree

tests/transports/__init__.py

Whitespace-only changes.

tests/transports/stdio/__init__.py

Whitespace-only changes.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Kernel-synchronized liveness probes for the real-subprocess stdio lifecycle suite.
2+
3+
A spawned (grand)child connects back to a test-owned TCP listener and sends
4+
`b'alive'`. From there the kernel provides every signal a test needs, with no
5+
sleeps or polling anywhere:
6+
7+
1. `accept_alive` blocks until the subprocess connects, proving it is running (and
8+
that the script lines before the connect have executed).
9+
2. `assert_stream_closed` proves the peer terminated: the kernel closes all of a
10+
process's file descriptors on exit, surfacing EOF (clean close / FIN) or
11+
`BrokenResourceError` (abrupt close / RST, typical of SIGKILL and Windows job
12+
termination).
13+
3. `assert_peer_echoes` proves the peer is *alive*: only a running process can
14+
answer an echo, so a positive reply cannot race a kill the way a "no FIN yet"
15+
observation could.
16+
17+
These helpers are extracted from the real-process section of
18+
tests/client/test_stdio.py; the two copies on this branch are deliberate —
19+
consolidating that file onto this module is follow-up work.
20+
"""
21+
22+
import anyio
23+
import anyio.abc
24+
import pytest
25+
26+
27+
def connect_back_script(port: int, *, echo: bool = False) -> str:
28+
"""Return a `python -c` script body that connects to 127.0.0.1:`port` and
29+
sends `b'alive'`.
30+
31+
By default the process then blocks forever, serving as a pure liveness beacon
32+
for kill/termination tests. With `echo=True` it instead echoes every received
33+
chunk back (the recv parks it just as indefinitely), so a survival test can
34+
prove the process is still running after the client is gone — see
35+
`assert_peer_echoes`.
36+
"""
37+
# Excluded from coverage (lax: exempt from strict-no-cover): echo mode is
38+
# used only by POSIX-gated tests, and Windows runners enforce 100% per job.
39+
if echo: # pragma: lax no cover
40+
tail = "while True:\n data = s.recv(65536)\n if not data:\n break\n s.sendall(data)\n"
41+
else:
42+
tail = "time.sleep(3600)\n"
43+
return f"import socket, time\ns = socket.create_connection(('127.0.0.1', {port}))\ns.sendall(b'alive')\n" + tail
44+
45+
46+
async def open_liveness_listener() -> tuple[anyio.abc.SocketListener, int]:
47+
"""Open a TCP listener on localhost and return it along with its port."""
48+
multi = await anyio.create_tcp_listener(local_host="127.0.0.1")
49+
sock = multi.listeners[0]
50+
assert isinstance(sock, anyio.abc.SocketListener)
51+
addr = sock.extra(anyio.abc.SocketAttribute.local_address)
52+
# IPv4 local_address is (host: str, port: int)
53+
assert isinstance(addr, tuple) and len(addr) >= 2 and isinstance(addr[1], int)
54+
return sock, addr[1]
55+
56+
57+
async def accept_alive(sock: anyio.abc.SocketListener) -> anyio.abc.SocketStream:
58+
"""Accept one connection and assert the peer sent `b'alive'`.
59+
60+
Blocks deterministically until a subprocess connects (no polling), reading
61+
until the full 5-byte banner has arrived — TCP may legally split even a tiny
62+
send. The calling test bounds this with `anyio.fail_after` to catch the case
63+
where the subprocess chain failed to start.
64+
"""
65+
stream = await sock.accept()
66+
msg = b""
67+
while len(msg) < 5:
68+
msg += await stream.receive(5 - len(msg))
69+
assert msg == b"alive", f"expected b'alive', got {msg!r}"
70+
return stream
71+
72+
73+
async def assert_stream_closed(stream: anyio.abc.SocketStream) -> None:
74+
"""Assert the peer holding the other end of `stream` has terminated."""
75+
with anyio.fail_after(5.0), pytest.raises((anyio.EndOfStream, anyio.BrokenResourceError)):
76+
await stream.receive(1)
77+
78+
79+
async def assert_peer_echoes(stream: anyio.abc.SocketStream) -> None: # pragma: lax no cover
80+
"""Assert the peer holding the other end of `stream` is still running, by
81+
round-tripping one echo through it (the peer must use `echo=True`).
82+
83+
A dead process can never answer, so under a regression that kills the peer this
84+
raises (EOF/reset) or times out via the bound — it cannot pass spuriously; the
85+
sub-millisecond window between a kill being issued and taking effect is dwarfed
86+
by the socket round trip that must complete after it.
87+
88+
Excluded from coverage (lax: exempt from strict-no-cover) like `connect_back_script`'s
89+
echo mode: only POSIX-gated survival tests call this, and Windows runners enforce
90+
100% coverage per job.
91+
"""
92+
with anyio.fail_after(5.0):
93+
await stream.send(b"ping")
94+
# Read until the full echo has arrived: TCP may legally split even a tiny send.
95+
echoed = b""
96+
while len(echoed) < 4:
97+
echoed += await stream.receive(4 - len(echoed))
98+
assert echoed == b"ping", f"expected b'ping', got {echoed!r}"

tests/transports/stdio/conftest.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Fixtures for the stdio lifecycle suite: recording seams around the spawn and
2+
tree-termination internals of `stdio_client` (the real implementations still run),
3+
plus the failure-path safety net that keeps a crashed test from orphaning its
4+
sleep-forever subprocesses.
5+
"""
6+
7+
import os
8+
import signal
9+
import sys
10+
from collections.abc import Generator
11+
from contextlib import suppress
12+
from pathlib import Path
13+
from typing import TextIO
14+
15+
import anyio.abc
16+
import pytest
17+
18+
from mcp.client import stdio
19+
from mcp.client.stdio import _create_platform_compatible_process, _terminate_process_tree
20+
from mcp.os.win32.utilities import FallbackProcess
21+
22+
23+
@pytest.fixture
24+
def spawned_processes(
25+
monkeypatch: pytest.MonkeyPatch,
26+
) -> Generator[list[anyio.abc.Process | FallbackProcess]]:
27+
"""Record every process `stdio_client` spawns; the real spawn still runs.
28+
29+
Tests inspect the recorded processes afterwards (exit codes, concrete type on
30+
the Windows fallback path). Teardown SIGKILLs each spawn-time process group on
31+
POSIX, in both of its roles: failure-path safety net (a test that dies mid-body
32+
cannot orphan its sleep-forever descendants for an hour) and the reaper for
33+
tests that deliberately leave a survivor running, like the POSIX survival
34+
test's echo child. On Windows there is no process group to signal (the Job
35+
Object covers strays).
36+
"""
37+
spawned: list[anyio.abc.Process | FallbackProcess] = []
38+
39+
async def recording_spawn(
40+
command: str,
41+
args: list[str],
42+
env: dict[str, str] | None = None,
43+
errlog: TextIO = sys.stderr,
44+
cwd: Path | str | None = None,
45+
) -> anyio.abc.Process | FallbackProcess:
46+
process = await _create_platform_compatible_process(command, args, env, errlog, cwd)
47+
spawned.append(process)
48+
return process
49+
50+
monkeypatch.setattr(stdio, "_create_platform_compatible_process", recording_spawn)
51+
yield spawned
52+
_kill_spawn_groups(spawned)
53+
54+
55+
@pytest.fixture
56+
def terminate_calls(monkeypatch: pytest.MonkeyPatch) -> list[anyio.abc.Process | FallbackProcess]:
57+
"""Record every invocation of `stdio_client`'s tree-termination seam; the real
58+
termination still runs.
59+
60+
An empty list after the context exits proves the graceful path: the server was
61+
never escalated against, which a socket signal alone cannot establish (a FIN
62+
looks the same whether the peer exited on stdin closure or was killed).
63+
"""
64+
terminated: list[anyio.abc.Process | FallbackProcess] = []
65+
66+
async def recording_terminate(process: anyio.abc.Process | FallbackProcess) -> None:
67+
terminated.append(process)
68+
await _terminate_process_tree(process)
69+
70+
monkeypatch.setattr(stdio, "_terminate_process_tree", recording_terminate)
71+
return terminated
72+
73+
74+
# Excluded from coverage (lax: exempt from strict-no-cover): registered on every
75+
# platform but a no-op on Windows, whose runners enforce 100% coverage per job.
76+
def _kill_spawn_groups(spawned: list[anyio.abc.Process | FallbackProcess]) -> None: # pragma: lax no cover
77+
"""SIGKILL each spawn-time process group; see `spawned_processes`."""
78+
if sys.platform == "win32":
79+
return
80+
for process in spawned:
81+
with suppress(ProcessLookupError):
82+
os.killpg(process.pid, signal.SIGKILL)

0 commit comments

Comments
 (0)