mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +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
|
# Suppress OAuth in-memory token storage warnings in tests
|
||||||
# Tests intentionally use ephemeral storage; this warning is for end users
|
# Tests intentionally use ephemeral storage; this warning is for end users
|
||||||
"ignore:Using in-memory token storage:UserWarning",
|
"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
|
timeout = 5
|
||||||
env = [
|
env = [
|
||||||
|
|
|
||||||
|
|
@ -686,6 +686,8 @@ class Client(Generic[ClientTransportT]):
|
||||||
|
|
||||||
# If session task already failed, raise immediately
|
# If session task already failed, raise immediately
|
||||||
if session_task.done():
|
if session_task.done():
|
||||||
|
# Close the coroutine to avoid "was never awaited" warning
|
||||||
|
coro.close()
|
||||||
exc = session_task.exception()
|
exc = session_task.exception()
|
||||||
if exc:
|
if exc:
|
||||||
raise exc
|
raise exc
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import abc
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
|
import gc
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
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
|
"""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."""
|
to ensure that the connection task does not hold a reference to the Transport object."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with contextlib.AsyncExitStack() as stack:
|
async with contextlib.AsyncExitStack() as stack:
|
||||||
try:
|
try:
|
||||||
|
|
@ -509,6 +509,15 @@ async def _stdio_transport_connect_task(
|
||||||
finally:
|
finally:
|
||||||
# Clean up client on exit
|
# Clean up client on exit
|
||||||
logger.debug("Stdio transport disconnected")
|
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:
|
except Exception:
|
||||||
# Ensure ready event is set even if connection fails
|
# Ensure ready event is set even if connection fails
|
||||||
ready_event.set()
|
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