mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
2 commits
main
...
fix-stdio-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe31e5252a | ||
|
|
0adecf15df |
4 changed files with 129 additions and 1 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
114
tests/client/test_stdio_cleanup.py
Normal file
114
tests/client/test_stdio_cleanup.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue