Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions src/fastmcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,8 @@ async def _await_with_session_monitoring(

# 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
Expand Down
11 changes: 10 additions & 1 deletion src/fastmcp/client/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import contextlib
import datetime
import gc
import os
import shutil
import sys
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
114 changes: 114 additions & 0 deletions tests/client/test_stdio_cleanup.py
Original file line number Diff line number Diff line change
@@ -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)