Skip to content

Share one event loop per test module to stop Windows socketpair churn#3070

Merged
maxisbey merged 2 commits into
mainfrom
windows-loop-churn
Jul 7, 2026
Merged

Share one event loop per test module to stop Windows socketpair churn#3070
maxisbey merged 2 commits into
mainfrom
windows-loop-churn

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Share one event loop per test module instead of creating a fresh one for every async test, to stop a Windows CI flake caused by event-loop churn.

Motivation and Context

test (3.12, locked, windows-latest) flaked with two failures that are really one event:

  1. test_client_tolerates_405_on_get_and_delete failed before its body ran with OSError: [WinError 10055] ("insufficient buffer space or queue full"), raised from asyncio.new_event_loop() while anyio's pytest plugin was creating the test's runner. On Windows every new event loop builds its self-pipe via socket.socketpair(), which CPython emulates as a real loopback TCP listen/connect/accept.
  2. The next test on the same worker was then blamed for the half-built loop's ResourceWarning: unclosed event loop (GC → unraisable → promoted to error by filterwarnings = ["error"]).

The churn behind it: anyio's runner is lease-counted, so with only function-scoped async fixtures every async test creates and destroys its own event loop (anyio docs, agronholm/anyio#686). This suite has ~2,740 async tests and runs them in ~30s across 4 xdist workers on Windows CI — hundreds of loopback socketpair create/close cycles per second, each parking a connection in TIME_WAIT for ~120s. WinError 10055 is a transient kernel buffer failure under that churn; each loop creation is a lottery ticket and a Windows job currently buys ~2,740 of them (measured: exactly one socketpair() call per async test).

The fix holds a module-scoped anyio runner lease (autouse fixture in tests/conftest.py), so each xdist worker reuses one loop per module. Measured on the flaked SHA with a socket.socketpair counter: 2,737 → ~380 socketpair calls per run on windows-latest, peak TIME_WAIT 4,164 → 400, with identical test outcomes on both windows-latest and ubuntu-latest.

Six modules parametrize anyio_backend (trio + MockClock, SelectorEventLoop) and shadow the lease fixture with a sync no-op: a module-scoped lease can't depend on the function-scoped parameter (pytest fails with ScopeMismatch — loud, so a future module that forgets the shadow can't misbehave silently), the held runner wouldn't match the requested backend anyway, and tests/client/test_stdio.py's direct trio.run(...) calls collide with a lingering asyncio loop's wakeup fd on Windows. When these modules run, the previous module's runner has already been torn down, so their behavior is unchanged.

Session scope would cut churn further (~10 socketpairs/run) but was rejected: it breaks all anyio_backend-parametrized tests with ScopeMismatch and the direct-trio tests on Windows via the wakeup-fd collision (both verified empirically).

How Has This Been Tested?

  • Full suite on windows-latest and ubuntu-latest at the flaked SHA with this patch: identical pass/skip counts to baseline, zero new failures, socketpair churn measured via an injected socket.socketpair counter and a TIME_WAIT sampler.
  • Alternative scopes (session, package) were also run on both platforms to map exactly which tests conflict; the module + shadow shape is the one with zero behavior change.
  • ./scripts/test locally: 100.00% coverage, strict-no-cover clean.

Breaking Changes

None — test infrastructure only. New convention for contributors: a test module that parametrizes anyio_backend must shadow _module_runner_lease (documented in the fixture's docstring in tests/conftest.py; forgetting it fails loudly at setup).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Tests in the same module now share loop-scoped state. An audit found no bare asyncio.create_task, set_event_loop, exception-handler, or executor usage anywhere in tests/ or src/mcp/, so nothing depends on per-test loop isolation today; a future test leaking a background task would surface as order-dependence within its own module.

The residual ~380 loop creations (the six opt-out modules plus one loop per module per worker) keep a small tail of exposure. If the flake ever reappears, the next increment is moving the ~12 parametrized/direct-trio tests into small sibling modules so the two big opted-out files (test_jsonrpc_dispatcher.py, test_streamable_http_modern.py) rejoin the shared loop.

AI Disclaimer

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.
@maxisbey maxisbey marked this pull request as ready for review July 6, 2026 18:08
@maxisbey maxisbey marked this pull request as draft July 6, 2026 18:09
@maxisbey maxisbey marked this pull request as ready for review July 6, 2026 18:11

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 8 files

Re-trigger cubic

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs — the fixture and the six shadow modules line up exactly with every module that parametrizes anyio_backend or calls trio.run directly. Deferring to a human because this establishes a new suite-wide testing convention (module-shared event loops + the shadow-fixture requirement in AGENTS.md) that maintainers should sign off on.

Extended reasoning...

Overview

This PR is test-infrastructure only: a new module-scoped, autouse _module_runner_lease fixture in tests/conftest.py (plus a session-scoped anyio_backend), sync no-op shadows of that fixture in six test modules, and a documentation update in AGENTS.md describing the new convention. No files under src/ are touched.

Verification performed

I cross-checked that the six modules given shadow fixtures are exactly the set of test modules that parametrize anyio_backend or call trio.run directly — no module is missed, and no shadow is superfluous. A forgotten shadow in a future parametrizing module fails loudly with ScopeMismatch, as the PR description and AGENTS.md note. The bug-hunting pass found no issues.

Security risks

None. The change does not touch runtime code, authentication, transport, or any security-sensitive path; it only alters how the test suite allocates event loops.

Level of scrutiny

The blast radius is limited to CI/test behavior: worst case is new test flakiness or order-dependence within a module, which CI would surface rather than shipping to users. However, the PR changes the isolation semantics of every async test in the suite (loop shared per module) and introduces a new convention contributors must follow (the shadow requirement, one of whose failure modes is only a Windows-specific flake per the direct-trio case). That is a project-wide testing-convention decision that maintainers should deliberately accept, so I'm not shadow-approving despite finding no defects.

Other factors

The author reports full-suite runs on both windows-latest and ubuntu-latest at the flaked SHA with identical outcomes and measured socketpair-churn reduction, plus an audit for loop-scoped state dependence. Those claims can't be independently re-verified here, but the design and documentation are internally consistent.

@maxisbey maxisbey merged commit 867bba6 into main Jul 7, 2026
32 of 33 checks passed
@maxisbey maxisbey deleted the windows-loop-churn branch July 7, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant