From 0adecf15df61d49d1a6d7bc86f62647638c82f43 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:29:10 -0500 Subject: [PATCH 1/2] Fix event loop closed warning during stdio cleanup (#2792) Add GC call and yield after stdio_client context exits to clean up asyncio subprocess transport references while the loop is still running. --- src/fastmcp/client/transports.py | 11 ++- tests/client/test_stdio_cleanup.py | 114 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/client/test_stdio_cleanup.py diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index be01e7a64..0771fb620 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -2,6 +2,7 @@ import abc import asyncio import contextlib import datetime +import gc import os import shutil import sys @@ -472,7 +473,6 @@ async def _stdio_transport_connect_task( ): """A standalone connection task for a stdio transport. It is not a part of the StdioTransport class to ensure that the connection task does not hold a reference to the Transport object.""" - try: async with contextlib.AsyncExitStack() as stack: try: @@ -509,6 +509,15 @@ async def _stdio_transport_connect_task( finally: # Clean up client on exit logger.debug("Stdio transport disconnected") + + # After stdio_client context exits, force garbage collection while the + # event loop is still running. This helps prevent "Event loop is closed" + # warnings that can occur when asyncio's BaseSubprocessTransport.__del__ + # is triggered by GC after pytest-asyncio closes the event loop. + # See: https://github.com/jlowin/fastmcp/issues/2792 + gc.collect() + await asyncio.sleep(0) + except Exception: # Ensure ready event is set even if connection fails ready_event.set() diff --git a/tests/client/test_stdio_cleanup.py b/tests/client/test_stdio_cleanup.py new file mode 100644 index 000000000..7041543c5 --- /dev/null +++ b/tests/client/test_stdio_cleanup.py @@ -0,0 +1,114 @@ +""" +Test for issue #2792: Event loop is closed warning during subprocess transport cleanup. + +This test attempts to reproduce the warning that appears on Linux/CI when using +StdioTransport with pytest. The issue is that asyncio's BaseSubprocessTransport.__del__ +tries to use the event loop after it's been closed by pytest-asyncio. + +The warning looks like: + PytestUnraisableExceptionWarning: Exception ignored in: + + RuntimeError: Event loop is closed + +See: https://github.com/jlowin/fastmcp/issues/2792 +""" + +import asyncio +import gc +import inspect + +import pytest + +from fastmcp import Client +from fastmcp.client.transports import PythonStdioTransport + + +@pytest.fixture +def simple_server_script(tmp_path): + """Create a minimal MCP server script.""" + script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def echo(message: str) -> str: + return message + + if __name__ == "__main__": + mcp.run() + """) + script_file = tmp_path / "simple_server.py" + script_file.write_text(script) + return script_file + + +class TestStdioCleanup: + """Test suite for stdio transport cleanup issues.""" + + @pytest.mark.timeout(30) + @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") + async def test_stdio_transport_cleanup_no_warning(self, simple_server_script): + """Test that stdio transport doesn't produce event loop warnings on cleanup. + + This test fails if a PytestUnraisableExceptionWarning is raised during + cleanup, which happens when BaseSubprocessTransport.__del__ tries to + use a closed event loop. + """ + transport = PythonStdioTransport( + script_path=simple_server_script, keep_alive=False + ) + client = Client(transport=transport) + + async with client: + result = await client.call_tool("echo", {"message": "test"}) + assert result.data == "test" + + # The warning typically appears after the test completes and the event + # loop is closed, then GC runs. We can try to force it by: + # 1. Explicitly deleting references + del client + del transport + + # 2. Running GC while the loop is still open + gc.collect() + + # 3. Yielding to let any pending callbacks run + await asyncio.sleep(0.1) + + @pytest.mark.timeout(30) + @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") + async def test_stdio_transport_with_keep_alive_cleanup(self, simple_server_script): + """Test that keep_alive=True also cleans up properly when explicitly closed.""" + transport = PythonStdioTransport( + script_path=simple_server_script, keep_alive=True + ) + client = Client(transport=transport) + + async with client: + result = await client.call_tool("echo", {"message": "test"}) + assert result.data == "test" + + # Explicitly close even though keep_alive=True + await client.close() + + del client + del transport + gc.collect() + await asyncio.sleep(0.1) + + @pytest.mark.timeout(30) + @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") + async def test_direct_disconnect_then_gc(self, simple_server_script): + """Test explicit disconnect followed by GC.""" + transport = PythonStdioTransport( + script_path=simple_server_script, keep_alive=True + ) + + await transport.connect() + await transport.disconnect() + + # Try to force any lingering references to be collected + del transport + gc.collect() + await asyncio.sleep(0.1) From fe31e5252a078624b7c42105a823050f51a2d76f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:00:32 -0500 Subject: [PATCH 2/2] Treat unawaited coroutine warnings as errors in tests Add pytest filterwarnings to catch unawaited coroutine warnings as errors. Fix the underlying issue in _await_with_session_monitoring where a coroutine was not closed before raising when session task was already done. --- pyproject.toml | 3 +++ src/fastmcp/client/client.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6b41c1ae3..22fdd6b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,9 @@ filterwarnings = [ # Suppress OAuth in-memory token storage warnings in tests # Tests intentionally use ephemeral storage; this warning is for end users "ignore:Using in-memory token storage:UserWarning", + # Treat unawaited coroutine warnings as errors - these are almost always bugs + "error:coroutine .* was never awaited:RuntimeWarning", + "error:Exception ignored in.*coroutine:pytest.PytestUnraisableExceptionWarning", ] timeout = 5 env = [ diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4b8488280..910b6d998 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -686,6 +686,8 @@ class Client(Generic[ClientTransportT]): # If session task already failed, raise immediately if session_task.done(): + # Close the coroutine to avoid "was never awaited" warning + coro.close() exc = session_task.exception() if exc: raise exc