Skip to content

Commit f896bf5

Browse files
committed
Share one event loop per test module to stop Windows socketpair churn
anyio's lease-counted runner creates a fresh event loop for every async test when only function-scoped async fixtures exist. On Windows each loop's self-pipe is an emulated loopback-TCP socketpair; churning ~2,700 of those across a ~30s run transiently exhausts kernel socket buffers and flakes CI with WinError 10055 raised from asyncio.new_event_loop() before an arbitrary test's body starts, plus a collateral "unclosed event loop" failure pinned on the next test by filterwarnings=error. Hold a module-scoped autouse runner lease so each xdist worker reuses one loop per module. Measured on windows-latest at the flaked commit: 2,737 -> ~380 socketpair calls per run, peak TIME_WAIT 4,164 -> 400, identical test outcomes on both platforms. Modules that parametrize anyio_backend or call trio.run directly shadow the lease with a sync no-op: a module-scoped lease cannot depend on the function-scoped parameter (ScopeMismatch), and a held asyncio loop's wakeup fd breaks direct trio runs on Windows.
1 parent e4d95e0 commit f896bf5

8 files changed

Lines changed: 67 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@
5353
— it defines the bar for naming, abstraction level, assertions, and determinism.
5454
- Framework: `uv run --frozen pytest`
5555
- Async testing: use anyio, not asyncio
56+
- Async tests share one event loop per module (the `_module_runner_lease`
57+
fixture in `tests/conftest.py`; it avoids Windows socketpair churn). A module
58+
that parametrizes `anyio_backend` or calls `trio.run` directly must shadow
59+
that fixture with a sync no-op — see `tests/shared/test_jsonrpc_dispatcher.py`.
60+
A forgotten shadow fails loudly with a ScopeMismatch error at setup in the
61+
parametrize case, but only as a Windows-specific flake in the direct-trio
62+
case, so don't rely on CI to catch the latter.
5663
- Do not use `Test` prefixed classes — write plain top-level `test_*` functions.
5764
Legacy files still contain `Test*` classes; do NOT follow that pattern for new
5865
tests even when adding to such a file.

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
@@ -52,6 +52,11 @@
5252
DCtx = DispatchContext[TransportContext]
5353

5454

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

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)