Skip to content

Commit a922cdf

Browse files
committed
Merge remote-tracking branch 'origin/main' into restore-trio-support
2 parents a1b2b89 + 6affe5c commit a922cdf

5 files changed

Lines changed: 99 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ dev = [
7777
"strict-no-cover",
7878
"logfire>=3.0.0",
7979
"opentelemetry-sdk>=1.39.1",
80+
"blockbuster>=1.5.27",
8081
]
8182
docs = [
8283
# Zensical is the Material team's successor to MkDocs; it natively

src/mcp/client/stdio.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import anyio
2020
import anyio.lowlevel
21+
import anyio.to_thread
2122
import mcp_types as types
2223
from anyio.abc import AsyncResource, Process
2324
from anyio.streams.text import TextReceiveStream
@@ -120,7 +121,7 @@ async def stdio_client(
120121
OSError: If the server process cannot be spawned.
121122
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
122123
"""
123-
command = _get_executable_command(server.command)
124+
command = await _get_executable_command(server.command)
124125

125126
process = await _create_platform_compatible_process(
126127
command=command,
@@ -317,10 +318,10 @@ def _close_subprocess_transport(process: ServerProcess) -> None:
317318
close()
318319

319320

320-
def _get_executable_command(command: str) -> str:
321+
async def _get_executable_command(command: str) -> str:
321322
"""Normalizes the command for the current platform."""
322-
if sys.platform == "win32": # pragma: no cover
323-
return get_windows_executable_command(command)
323+
if sys.platform == "win32":
324+
return await anyio.to_thread.run_sync(get_windows_executable_command, command, abandon_on_cancel=True)
324325
else: # pragma: lax no cover
325326
return command
326327

tests/client/test_stdio.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
import os
1515
import signal
1616
import sys
17+
import threading
1718
from collections.abc import Callable
1819
from contextlib import AsyncExitStack, suppress
1920
from pathlib import Path
21+
from types import SimpleNamespace
2022
from typing import TextIO, cast
2123

2224
import anyio
2325
import anyio.abc
26+
import anyio.from_thread
2427
import anyio.lowlevel
28+
import anyio.to_thread
2529
import pytest
2630
import trio
2731
import trio.testing
@@ -199,6 +203,9 @@ def install_fake_process(
199203
"""
200204
terminated: list[FakeProcess] = []
201205

206+
async def fake_get_executable_command(command: str) -> str:
207+
return command
208+
202209
async def fake_spawn(
203210
command: str,
204211
args: list[str],
@@ -212,6 +219,7 @@ async def fake_terminate_tree(proc: FakeProcess) -> None:
212219
terminated.append(proc)
213220
proc.exit(-15)
214221

222+
monkeypatch.setattr(stdio, "_get_executable_command", fake_get_executable_command)
215223
monkeypatch.setattr(stdio, "_create_platform_compatible_process", fake_spawn)
216224
monkeypatch.setattr(stdio, "_terminate_process_tree", fake_terminate_tree)
217225
if grace_period is not None:
@@ -568,6 +576,45 @@ async def test_a_command_that_cannot_be_execed_raises_enoent() -> None:
568576
assert exc_info.value.errno == errno.ENOENT
569577

570578

579+
@pytest.mark.anyio
580+
async def test_cancellation_during_windows_command_resolution_returns_before_resolution_finishes(
581+
monkeypatch: pytest.MonkeyPatch,
582+
) -> None:
583+
"""Cancelling `stdio_client` does not wait for blocked Windows command resolution."""
584+
resolution_started = anyio.Event()
585+
resolution_release = threading.Event()
586+
resolution_finished = threading.Event()
587+
588+
def blocking_resolver(command: str) -> str:
589+
anyio.from_thread.run_sync(resolution_started.set)
590+
resolution_release.wait()
591+
resolution_finished.set()
592+
return command
593+
594+
monkeypatch.setattr(stdio, "sys", SimpleNamespace(platform="win32"))
595+
monkeypatch.setattr(stdio, "get_windows_executable_command", blocking_resolver)
596+
597+
cancel_scope = anyio.CancelScope()
598+
client_stopped = anyio.Event()
599+
600+
async def run_client() -> None:
601+
with cancel_scope:
602+
async with AsyncExitStack() as stack:
603+
await stack.enter_async_context(stdio_client(FAKE_PARAMS))
604+
client_stopped.set()
605+
606+
with anyio.fail_after(5):
607+
async with anyio.create_task_group() as tg:
608+
tg.start_soon(run_client)
609+
await resolution_started.wait()
610+
cancel_scope.cancel()
611+
try:
612+
await client_stopped.wait()
613+
finally:
614+
resolution_release.set()
615+
await anyio.to_thread.run_sync(resolution_finished.wait)
616+
617+
571618
@pytest.mark.anyio
572619
async def test_cancellation_during_spawn_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None:
573620
"""Cancellation while the spawn is still in flight must not leak the internal streams.

tests/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import os
22
from collections.abc import AsyncIterator, Iterator
33

4+
import httpcore2 as _httpcore2
45
import pytest
6+
from blockbuster import BlockBuster
57

68
# OpenTelemetry's `set_tracer_provider` is set-once per process, so the suite
79
# uses a single span-capture mechanism: logfire's `capfire` fixture (its
@@ -17,12 +19,36 @@
1719

1820
import mcp.shared._otel # noqa: E402
1921

22+
# Load httpx2's lazy default transport before BlockBuster starts.
23+
del _httpcore2
24+
2025

2126
@pytest.fixture(scope="session", params=["asyncio", "trio"])
2227
def anyio_backend(request: pytest.FixtureRequest) -> str:
2328
return request.param
2429

2530

31+
@pytest.fixture(autouse=True)
32+
def blockbuster() -> Iterator[None]:
33+
bb = BlockBuster(["mcp", "mcp_types"])
34+
# Coverage reads source files while collecting data.
35+
bb.functions["os.stat"].can_block_in("coverage/python.py", "get_python_source")
36+
bb.functions["io.BufferedReader.read"].can_block_in("coverage/python.py", "read_python_source")
37+
# jsonschema discovers its bundled schemas during its first import.
38+
bb.functions["os.listdir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
39+
bb.functions["os.scandir"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
40+
bb.functions["io.TextIOWrapper.read"].can_block_in("/jsonschema_specifications/_core.py", "_schemas")
41+
# These public synchronous conversions read the media file by design.
42+
bb.functions["io.BufferedReader.read"].can_block_in(
43+
"mcp/server/mcpserver/utilities/types.py", ("to_image_content", "to_audio_content")
44+
)
45+
bb.activate()
46+
try:
47+
yield
48+
finally:
49+
bb.deactivate()
50+
51+
2652
@pytest.fixture(scope="module", autouse=True)
2753
async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
2854
"""Share one event loop per module and backend instead of one per test.

uv.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)