Compare commits

...

2 commits

Author SHA1 Message Date
Jeremiah Lowin
fe31e5252a 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.
2026-01-07 13:00:32 -05:00
Jeremiah Lowin
0adecf15df 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.
2026-01-07 11:29:10 -05:00
4 changed files with 129 additions and 1 deletions

View file

@ -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 = [

View file

@ -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

View file

@ -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()

View file

@ -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:
<function BaseSubprocessTransport.__del__ at ...>
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)