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
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:
parent
9972c21aa4
commit
6affe5c0d3
5 changed files with 99 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
20
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue