Detect event-loop blocking in tests (#3510)
Some checks failed
Conformance Tests / server-conformance (push) Has been cancelled
Conformance Tests / client-conformance (push) Has been cancelled
Deploy Docs / deploy-docs (push) Has been cancelled
CI / checks (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
CI / all-green (push) Has been cancelled

This commit is contained in:
Marcelo Trylesinski 2026-09-16 12:12:27 +02:00 • committed by GitHub
commit 6affe5c0d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 99 additions and 4 deletions

View file

@ -77,6 +77,7 @@ dev = [
"strict-no-cover",
"logfire>=3.0.0",
"opentelemetry-sdk>=1.39.1",
"blockbuster>=1.5.27",
]
docs = [
# Zensical is the Material team's successor to MkDocs; it natively

View file

@ -18,6 +18,7 @@ from typing import Literal, TextIO
import anyio
import anyio.lowlevel
import anyio.to_thread
import mcp_types as types
from anyio.abc import AsyncResource, Process
from anyio.streams.text import TextReceiveStream
@ -120,7 +121,7 @@ async def stdio_client(
OSError: If the server process cannot be spawned.
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
"""
command = _get_executable_command(server.command)
command = await _get_executable_command(server.command)
process = await _create_platform_compatible_process(
command=command,
@ -317,10 +318,10 @@ def _close_subprocess_transport(process: ServerProcess) -> None:
close()
def _get_executable_command(command: str) -> str:
async def _get_executable_command(command: str) -> str:
"""Normalizes the command for the current platform."""
if sys.platform == "win32": # pragma: no cover
return get_windows_executable_command(command)
if sys.platform == "win32":
return await anyio.to_thread.run_sync(get_windows_executable_command, command, abandon_on_cancel=True)
else: # pragma: lax no cover
return command

View file

@ -14,14 +14,18 @@ import math
import os
import signal
import sys
import threading
from collections.abc import Callable
from contextlib import AsyncExitStack, suppress
from pathlib import Path
from types import SimpleNamespace
from typing import TextIO, cast
import anyio
import anyio.abc
import anyio.from_thread
import anyio.lowlevel
import anyio.to_thread
import pytest
import trio
import trio.testing
@ -199,6 +203,9 @@ def install_fake_process(
"""
terminated: list[FakeProcess] = []
async def fake_get_executable_command(command: str) -> str:
return command
async def fake_spawn(
command: str,
args: list[str],
@ -212,6 +219,7 @@ def install_fake_process(
terminated.append(proc)
proc.exit(-15)
monkeypatch.setattr(stdio, "_get_executable_command", fake_get_executable_command)
monkeypatch.setattr(stdio, "_create_platform_compatible_process", fake_spawn)
monkeypatch.setattr(stdio, "_terminate_process_tree", fake_terminate_tree)
if grace_period is not None:
@ -568,6 +576,45 @@ async def test_a_command_that_cannot_be_execed_raises_enoent() -> None:
assert exc_info.value.errno == errno.ENOENT
@pytest.mark.anyio
async def test_cancellation_during_windows_command_resolution_returns_before_resolution_finishes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Cancelling `stdio_client` does not wait for blocked Windows command resolution."""
resolution_started = anyio.Event()
resolution_release = threading.Event()
resolution_finished = threading.Event()
def blocking_resolver(command: str) -> str:
anyio.from_thread.run_sync(resolution_started.set)
resolution_release.wait()
resolution_finished.set()
return command
monkeypatch.setattr(stdio, "sys", SimpleNamespace(platform="win32"))
monkeypatch.setattr(stdio, "get_windows_executable_command", blocking_resolver)
cancel_scope = anyio.CancelScope()
client_stopped = anyio.Event()
async def run_client() -> None:
with cancel_scope:
async with AsyncExitStack() as stack:
await stack.enter_async_context(stdio_client(FAKE_PARAMS))
client_stopped.set()
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
tg.start_soon(run_client)
await resolution_started.wait()
cancel_scope.cancel()
try:
await client_stopped.wait()
finally:
resolution_release.set()
await anyio.to_thread.run_sync(resolution_finished.wait)
@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.

View file

@ -1,7 +1,9 @@
import os
from collections.abc import AsyncIterator, Iterator
import httpcore2 as _httpcore2
import pytest
from blockbuster import BlockBuster
# OpenTelemetry's `set_tracer_provider` is set-once per process, so the suite
# uses a single span-capture mechanism: logfire's `capfire` fixture (its
@ -17,12 +19,36 @@ from logfire.testing import CaptureLogfire # noqa: E402
import mcp.shared._otel # noqa: E402
# Load httpx2's lazy default transport before BlockBuster starts.
del _httpcore2
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(autouse=True)
def blockbuster() -> Iterator[None]:
bb = BlockBuster(["mcp", "mcp_types"])
# Coverage reads source files while collecting data.
bb.functions["os.stat"].can_block_in("coverage/python.py", "get_python_source")
bb.functions["io.BufferedReader.read"].can_block_in("coverage/python.py", "read_python_source")
# jsonschema discovers its bundled schemas during its first import.
bb.functions["os.listdir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
bb.functions["os.scandir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
bb.functions["io.TextIOWrapper.read"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
# These public synchronous conversions read the media file by design.
bb.functions["io.BufferedReader.read"].can_block_in(
"mcp/server/mcpserver/utilities/types.py", ("to_image_content", "to_audio_content")
)
bb.activate()
try:
yield
finally:
bb.deactivate()
@pytest.fixture(scope="module", autouse=True)
async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
"""Share one event loop across each module's tests instead of one per test.

20
uv.lock generated
View file

@ -154,6 +154,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]]
name = "blockbuster"
version = "1.5.27"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "forbiddenfruit", marker = "implementation_name == 'cpython'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/c3/21678f5b979be2cbf0e68352f7330a84ea4e24674023e4de981c78a218cd/blockbuster-1.5.27.tar.gz", hash = "sha256:b8e9d988b9b91ba468c94530e219f26a00d3ff616b39ebf3da561a2a3eea9dd4", size = 96732, upload-time = "2026-08-17T23:53:13.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/c5/092e631bc1fba86f0a822be65c137c90a71b71ba0a0865e7e9a21f6ca05e/blockbuster-1.5.27-py3-none-any.whl", hash = "sha256:f0acf153d22a791bf5f142935332ef8530960ec215541b48a6037e6cea0a8645", size = 13517, upload-time = "2026-08-17T23:53:14.625Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
@ -577,6 +589,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "forbiddenfruit"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size = 43756, upload-time = "2021-01-16T21:03:35.401Z" }
[[package]]
name = "genson"
version = "1.3.0"
@ -1023,6 +1041,7 @@ codegen = [
{ name = "datamodel-code-generator" },
]
dev = [
{ name = "blockbuster" },
{ name = "coverage", extra = ["toml"] },
{ name = "dirty-equals" },
{ name = "inline-snapshot" },
@ -1081,6 +1100,7 @@ provides-extras = ["cli", "rich"]
[package.metadata.requires-dev]
codegen = [{ name = "datamodel-code-generator", specifier = "==0.57.0" }]
dev = [
{ name = "blockbuster", specifier = ">=1.5.27" },
{ name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" },
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "inline-snapshot", specifier = ">=0.23.0" },