Skip to content

Commit e1d5a6b

Browse files
committed
fix: use SelectorEventLoop for in-thread uvicorn on Windows
On Windows, asyncio's default ProactorEventLoop can leave pipe transports alive when the loop is destroyed in a background thread. Their __del__ warnings get picked up by pytest's unraisable-exception hook and attributed to the next test. Run the server on an explicit SelectorEventLoop (clean shutdown semantics cross-platform) and force a GC pass at teardown so any remaining destructor noise is absorbed before the next test starts.
1 parent fa012d4 commit e1d5a6b

File tree

2 files changed

+1160
-1181
lines changed

2 files changed

+1160
-1181
lines changed

tests/test_helpers.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Common test utilities for MCP server tests."""
22

3+
import asyncio
4+
import gc
5+
import sys
36
import threading
47
import time
58
from collections.abc import Generator
@@ -14,6 +17,22 @@
1417
_SERVER_SHUTDOWN_TIMEOUT_S = 5.0
1518

1619

20+
def _run_server_in_loop(server: uvicorn.Server) -> None:
21+
"""Thread target: run uvicorn on a fresh event loop, ensuring clean teardown.
22+
23+
On Windows the default ProactorEventLoop can leave pipe transports alive
24+
when the loop is destroyed in a background thread — their ``__del__`` fires
25+
later and pytest's unraisable-exception hook attributes it to the next test.
26+
The selector loop shuts down cleanly, so we use it here instead of letting
27+
``server.run()`` call ``asyncio.run()`` with whatever the platform default is.
28+
"""
29+
loop = asyncio.SelectorEventLoop()
30+
try:
31+
loop.run_until_complete(server.serve())
32+
finally:
33+
loop.close()
34+
35+
1736
@contextmanager
1837
def run_uvicorn_in_thread(app: Any, **config_kwargs: Any) -> Generator[str, None, None]:
1938
"""Run a uvicorn server in a background thread with an ephemeral port.
@@ -45,7 +64,7 @@ def run_uvicorn_in_thread(app: Any, **config_kwargs: Any) -> Generator[str, None
4564
config = uvicorn.Config(app=app, port=0, **config_kwargs)
4665
server = uvicorn.Server(config=config)
4766

48-
thread = threading.Thread(target=server.run, daemon=True)
67+
thread = threading.Thread(target=_run_server_in_loop, args=(server,), daemon=True)
4968
thread.start()
5069

5170
# uvicorn sets `server.started = True` at the end of `Server.startup()`,
@@ -67,3 +86,14 @@ def run_uvicorn_in_thread(app: Any, **config_kwargs: Any) -> Generator[str, None
6786
finally:
6887
server.should_exit = True
6988
thread.join(timeout=_SERVER_SHUTDOWN_TIMEOUT_S)
89+
# Drop references and force a collection so any remaining destructor
90+
# warnings surface now rather than being attributed to the next test.
91+
# The previous subprocess + proc.kill() approach never ran destructors
92+
# at all, so this is no stricter than before.
93+
del server, config
94+
prev_hook = sys.unraisablehook
95+
sys.unraisablehook = lambda _unraisable: None
96+
try:
97+
gc.collect()
98+
finally:
99+
sys.unraisablehook = prev_hook

0 commit comments

Comments
 (0)