Skip to content

Commit 867bba6

Browse files
authored
Share one event loop per test module to stop Windows socketpair churn (#3070)
1 parent d287c98 commit 867bba6

7 files changed

Lines changed: 60 additions & 3 deletions

File tree

tests/client/test_input_required.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
pytestmark = pytest.mark.anyio
3838

3939

40+
@pytest.fixture(autouse=True)
41+
def _module_runner_lease() -> None:
42+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
43+
44+
4045
def _elicit(message: str = "What is your name?") -> ElicitRequest:
4146
schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
4247
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema))

tests/client/test_stdio.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@
4444
from mcp.shared.exceptions import MCPError
4545
from mcp.shared.message import SessionMessage
4646

47+
48+
@pytest.fixture(autouse=True)
49+
def _module_runner_lease() -> None:
50+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`
51+
and calls `trio.run` directly (see the tests/conftest.py original for the Windows hazard).
52+
"""
53+
54+
4755
# ---------------------------------------------------------------------------
4856
# In-process fake of the spawned server process
4957
# ---------------------------------------------------------------------------

tests/conftest.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
from collections.abc import Iterator
2+
from collections.abc import AsyncIterator, Iterator
33

44
import pytest
55

@@ -18,11 +18,35 @@
1818
import mcp.shared._otel # noqa: E402
1919

2020

21-
@pytest.fixture
22-
def anyio_backend():
21+
@pytest.fixture(scope="session")
22+
def anyio_backend() -> str:
2323
return "asyncio"
2424

2525

26+
@pytest.fixture(scope="module", autouse=True)
27+
async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
28+
"""Share one event loop across each module's tests instead of one per test.
29+
30+
anyio's pytest plugin tears its runner down whenever the last lease is
31+
released, so with only function-scoped async fixtures every async test
32+
creates and destroys its own event loop. On Windows each loop's self-pipe
33+
is an emulated loopback-TCP socketpair, and churning thousands of those per
34+
run can transiently exhaust kernel socket buffers — surfacing in CI as
35+
`OSError: [WinError 10055]` raised from `asyncio.new_event_loop()` before
36+
an arbitrary test's body even starts. Holding a module-scoped lease caps
37+
the churn at one loop per module per xdist worker.
38+
39+
Modules that parametrize `anyio_backend` or call `trio.run(...)` directly
40+
must shadow this fixture with a sync no-op: a module-scoped lease cannot
41+
depend on the function-scoped parameter (pytest raises ScopeMismatch at
42+
setup), and the lease's live asyncio loop lingers over direct trio runs,
43+
whose signal handling collides with the loop's wakeup fd on Windows. The
44+
lease also makes sniffio report asyncio to the module's sync tests, so a
45+
sync test must not call `anyio.run()` itself.
46+
"""
47+
yield
48+
49+
2650
@pytest.fixture(name="capfire")
2751
def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]:
2852
"""Override of logfire's `capfire` that scopes the MCP tracer to the test.

tests/interaction/lowlevel/test_timeouts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
pytestmark = pytest.mark.anyio
2828

2929

30+
@pytest.fixture(autouse=True)
31+
def _module_runner_lease() -> None:
32+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
33+
34+
3035
@requirement("protocol:timeout:basic")
3136
@requirement("protocol:timeout:sends-cancellation")
3237
async def test_request_timeout_fails_the_pending_call() -> None:

tests/server/test_streamable_http_modern.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
pytestmark = pytest.mark.anyio
5656

5757

58+
@pytest.fixture(autouse=True)
59+
def _module_runner_lease() -> None:
60+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
61+
62+
5863
async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
5964
"""The per-request dispatch context refuses server-initiated requests; without an SSE sink,
6065
notify/progress are no-ops."""

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
DCtx = DispatchContext[TransportContext]
5252

5353

54+
@pytest.fixture(autouse=True)
55+
def _module_runner_lease() -> None:
56+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
57+
58+
5459
class RecordingWriteStream:
5560
"""Records sends without a checkpoint, so a pending cancellation cannot interrupt the write or mask it."""
5661

tests/transports/stdio/test_windows.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
]
3838

3939

40+
@pytest.fixture(autouse=True)
41+
def _module_runner_lease() -> None:
42+
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
43+
44+
4045
async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_closes( # pragma: no cover
4146
tmp_path: Path,
4247
spawned_processes: list[anyio.abc.Process | FallbackProcess],

0 commit comments

Comments
 (0)