"""Fixtures for the stdio lifecycle suite. Provides recording seams around `stdio_client`'s spawn and tree-termination internals (the real implementations still run), plus a teardown that keeps a crashed test from orphaning its sleep-forever subprocesses. """ import os import signal import sys from collections.abc import Generator from contextlib import suppress from pathlib import Path from typing import TextIO import anyio.abc import pytest from mcp.client import stdio from mcp.client.stdio import _create_platform_compatible_process, _terminate_process_tree from mcp.os.win32.utilities import FallbackProcess @pytest.fixture def spawned_processes( monkeypatch: pytest.MonkeyPatch, ) -> Generator[list[anyio.abc.Process | FallbackProcess]]: """Record every process `stdio_client` spawns; the real spawn still runs. Teardown SIGKILLs each spawn-time process group on POSIX: the safety net for a test that dies mid-body and the reaper for deliberate survivors. On Windows there is no group to signal (the Job Object covers strays). """ 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) yield spawned _kill_spawn_groups(spawned) @pytest.fixture def terminate_calls(monkeypatch: pytest.MonkeyPatch) -> list[anyio.abc.Process | FallbackProcess]: """Record every invocation of `stdio_client`'s tree-termination seam; the real termination still runs. An empty list after the context exits proves the graceful path: a FIN looks the same whether the peer exited on stdin closure or was killed. """ terminated: list[anyio.abc.Process | FallbackProcess] = [] async def recording_terminate(process: anyio.abc.Process | FallbackProcess) -> None: terminated.append(process) await _terminate_process_tree(process) monkeypatch.setattr(stdio, "_terminate_process_tree", recording_terminate) return terminated # lax no cover: registered on every platform but a no-op on Windows, whose runners enforce 100% per job. def _kill_spawn_groups(spawned: list[anyio.abc.Process | FallbackProcess]) -> None: # pragma: lax no cover """SIGKILL each spawn-time process group; see `spawned_processes`.""" 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)