From f896bf50a6320660a6a7b2c4ebaa602b5b0bf244 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:30:04 +0000 Subject: [PATCH 1/2] 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. --- AGENTS.md | 7 +++++ tests/client/test_input_required.py | 5 ++++ tests/client/test_stdio.py | 8 ++++++ tests/conftest.py | 30 ++++++++++++++++++--- tests/interaction/lowlevel/test_timeouts.py | 5 ++++ tests/server/test_streamable_http_modern.py | 5 ++++ tests/shared/test_jsonrpc_dispatcher.py | 5 ++++ tests/transports/stdio/test_windows.py | 5 ++++ 8 files changed, 67 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43fbb887d4..2953faafd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,13 @@ — it defines the bar for naming, abstraction level, assertions, and determinism. - Framework: `uv run --frozen pytest` - Async testing: use anyio, not asyncio +- Async tests share one event loop per module (the `_module_runner_lease` + fixture in `tests/conftest.py`; it avoids Windows socketpair churn). A module + that parametrizes `anyio_backend` or calls `trio.run` directly must shadow + that fixture with a sync no-op — see `tests/shared/test_jsonrpc_dispatcher.py`. + A forgotten shadow fails loudly with a ScopeMismatch error at setup in the + parametrize case, but only as a Windows-specific flake in the direct-trio + case, so don't rely on CI to catch the latter. - Do not use `Test` prefixed classes — write plain top-level `test_*` functions. Legacy files still contain `Test*` classes; do NOT follow that pattern for new tests even when adding to such a file. diff --git a/tests/client/test_input_required.py b/tests/client/test_input_required.py index cc58cf8dbe..fe8dc7f471 100644 --- a/tests/client/test_input_required.py +++ b/tests/client/test_input_required.py @@ -37,6 +37,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + def _elicit(message: str = "What is your name?") -> ElicitRequest: schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema)) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 0b0695378b..91f829ff98 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -44,6 +44,14 @@ from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend` + and calls `trio.run` directly (see the tests/conftest.py original for the Windows hazard). + """ + + # --------------------------------------------------------------------------- # In-process fake of the spawned server process # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 2278c9939e..9ade27e7f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import os -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator import pytest @@ -18,11 +18,35 @@ import mcp.shared._otel # noqa: E402 -@pytest.fixture -def anyio_backend(): +@pytest.fixture(scope="session") +def anyio_backend() -> str: return "asyncio" +@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. + + anyio's pytest plugin tears its runner down whenever the last lease is + released, so with only function-scoped async fixtures every async test + creates and destroys its own event loop. On Windows each loop's self-pipe + is an emulated loopback-TCP socketpair, and churning thousands of those per + run can transiently exhaust kernel socket buffers — surfacing in CI as + `OSError: [WinError 10055]` raised from `asyncio.new_event_loop()` before + an arbitrary test's body even starts. Holding a module-scoped lease caps + the churn at one loop per module per xdist worker. + + Modules that parametrize `anyio_backend` or call `trio.run(...)` directly + must shadow this fixture with a sync no-op: a module-scoped lease cannot + depend on the function-scoped parameter (pytest raises ScopeMismatch at + setup), and the lease's live asyncio loop lingers over direct trio runs, + whose signal handling collides with the loop's wakeup fd on Windows. The + lease also makes sniffio report asyncio to the module's sync tests, so a + sync test must not call `anyio.run()` itself. + """ + yield + + @pytest.fixture(name="capfire") def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]: """Override of logfire's `capfire` that scopes the MCP tracer to the test. diff --git a/tests/interaction/lowlevel/test_timeouts.py b/tests/interaction/lowlevel/test_timeouts.py index 316c69245c..0c20fd7c6e 100644 --- a/tests/interaction/lowlevel/test_timeouts.py +++ b/tests/interaction/lowlevel/test_timeouts.py @@ -27,6 +27,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + @requirement("protocol:timeout:basic") @requirement("protocol:timeout:sends-cancellation") async def test_request_timeout_fails_the_pending_call() -> None: diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 19ad33f194..b85566f8a0 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -55,6 +55,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: """The per-request dispatch context refuses server-initiated requests; without an SSE sink, notify/progress are no-ops.""" diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 82d16bc4b9..b3f1c046d2 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -52,6 +52,11 @@ DCtx = DispatchContext[TransportContext] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + class RecordingWriteStream: """Records sends without a checkpoint, so a pending cancellation cannot interrupt the write or mask it.""" diff --git a/tests/transports/stdio/test_windows.py b/tests/transports/stdio/test_windows.py index 656fc8124d..2d4eeac826 100644 --- a/tests/transports/stdio/test_windows.py +++ b/tests/transports/stdio/test_windows.py @@ -37,6 +37,11 @@ ] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_closes( # pragma: no cover tmp_path: Path, spawned_processes: list[anyio.abc.Process | FallbackProcess], From af18fe0ca2785cf42fdaa38d44637e76e48befc4 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:15:24 +0000 Subject: [PATCH 2/2] Drop the AGENTS.md note; the conftest fixture docstring covers the convention --- AGENTS.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2953faafd6..43fbb887d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,13 +53,6 @@ — it defines the bar for naming, abstraction level, assertions, and determinism. - Framework: `uv run --frozen pytest` - Async testing: use anyio, not asyncio -- Async tests share one event loop per module (the `_module_runner_lease` - fixture in `tests/conftest.py`; it avoids Windows socketpair churn). A module - that parametrizes `anyio_backend` or calls `trio.run` directly must shadow - that fixture with a sync no-op — see `tests/shared/test_jsonrpc_dispatcher.py`. - A forgotten shadow fails loudly with a ScopeMismatch error at setup in the - parametrize case, but only as a Windows-specific flake in the direct-trio - case, so don't rely on CI to catch the latter. - Do not use `Test` prefixed classes — write plain top-level `test_*` functions. Legacy files still contain `Test*` classes; do NOT follow that pattern for new tests even when adding to such a file.