mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
333 lines
14 KiB
Python
333 lines
14 KiB
Python
"""Capacity pressure must not interrupt already-admitted MCP calls."""
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import CONNECTION_CLOSED, CallToolResult, ErrorData, TextContent
|
|
|
|
from deerflow.mcp.session_pool import MCPSessionPool, call_pooled_session_tool
|
|
|
|
|
|
class ControlledSession:
|
|
def __init__(self, connection):
|
|
self.connection = connection
|
|
self.started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.closed = connection.get("closed", asyncio.Event())
|
|
|
|
async def initialize(self):
|
|
if "initialize_started" in self.connection:
|
|
self.connection["initialize_started"].set()
|
|
if "initialize_gate" in self.connection:
|
|
await self.connection["initialize_gate"].wait()
|
|
|
|
async def call_tool(self, name, arguments, **kwargs):
|
|
self.started.set()
|
|
arguments.get("started", self.started).set()
|
|
await arguments.get("release", self.release).wait()
|
|
if self.closed.is_set():
|
|
raise McpError(ErrorData(code=CONNECTION_CLOSED, message="Connection closed"))
|
|
if "error" in arguments:
|
|
raise arguments["error"]
|
|
return CallToolResult(content=[TextContent(type="text", text="finished")], isError=arguments.get("is_error", False))
|
|
|
|
|
|
@pytest.fixture
|
|
def controlled_pool(monkeypatch):
|
|
pool = MCPSessionPool()
|
|
|
|
@asynccontextmanager
|
|
async def create_session(connection):
|
|
owner = asyncio.current_task()
|
|
session = ControlledSession(connection)
|
|
try:
|
|
yield session
|
|
finally:
|
|
assert asyncio.current_task() is owner
|
|
session.closed.set()
|
|
|
|
monkeypatch.setattr("langchain_mcp_adapters.sessions.create_session", create_session)
|
|
return pool
|
|
|
|
|
|
async def call(session, pool, scope, *, domain="deployment", arguments=None):
|
|
return await call_pooled_session_tool(session, pool, server_name="server", scope_key=scope, tool_name="slow", arguments=arguments or {}, call_kwargs={}, domain=domain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_evicts_idle_session_instead_of_inflight_call(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 2
|
|
active = await pool.get_session("server", "active", {})
|
|
idle = await pool.get_session("server", "idle", {})
|
|
running = asyncio.create_task(call(active, pool, "active"))
|
|
try:
|
|
await asyncio.wait_for(active.started.wait(), 1)
|
|
replacement = await pool.get_session("server", "replacement", {})
|
|
assert not active.closed.is_set(), "LRU capacity eviction closed an in-flight call"
|
|
assert idle.closed.is_set()
|
|
active.release.set()
|
|
result = await asyncio.wait_for(running, 1)
|
|
assert result.content[0].text == "finished"
|
|
assert len(pool._entries) == 2
|
|
assert replacement is not idle
|
|
finally:
|
|
active.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_busy_pool_rejects_new_scope_without_closing_active_call(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 1
|
|
active = await pool.get_session("server", "active", {})
|
|
running = asyncio.create_task(call(active, pool, "active"))
|
|
try:
|
|
await asyncio.wait_for(active.started.wait(), 1)
|
|
with pytest.raises(RuntimeError, match="capacity"):
|
|
await pool.get_session("server", "replacement", {})
|
|
assert not pool._inflight
|
|
assert len(pool._entries) == 1
|
|
# Reusing an existing busy session does not require a new slot.
|
|
assert await pool.get_session("server", "active", {}) is active
|
|
assert not active.closed.is_set()
|
|
active.release.set()
|
|
assert (await asyncio.wait_for(running, 1)).content[0].text == "finished"
|
|
replacement = await pool.get_session("server", "replacement", {})
|
|
assert active.closed.is_set()
|
|
assert replacement is not active
|
|
finally:
|
|
active.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["scope", "server", "session", "current", "all"])
|
|
async def test_explicit_close_still_retires_an_active_call(controlled_pool, operation):
|
|
pool = controlled_pool
|
|
active = await pool.get_session("server", "active", {})
|
|
running = asyncio.create_task(call(active, pool, "active"))
|
|
try:
|
|
await asyncio.wait_for(active.started.wait(), 1)
|
|
close = {
|
|
"scope": lambda: pool.close_scope("active"),
|
|
"server": lambda: pool.close_server("server"),
|
|
"session": lambda: pool.close_session("server", "active"),
|
|
"current": lambda: pool.close_session_if_current("server", "active", active),
|
|
"all": pool.close_all,
|
|
}[operation]
|
|
await close()
|
|
assert active.closed.is_set()
|
|
active.release.set()
|
|
with pytest.raises(McpError, match="Connection closed"):
|
|
await asyncio.wait_for(running, 1)
|
|
finally:
|
|
active.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("outcome", ["cancel", "timeout", "protocol_error", "tool_error", "success"])
|
|
async def test_call_exit_releases_capacity_protection(controlled_pool, outcome):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 1
|
|
session = await pool.get_session("server", "active", {})
|
|
arguments = {}
|
|
if outcome == "timeout":
|
|
arguments["error"] = TimeoutError("tool timeout")
|
|
elif outcome == "protocol_error":
|
|
arguments["error"] = McpError(ErrorData(code=408, message="request timed out"))
|
|
elif outcome == "tool_error":
|
|
arguments["is_error"] = True
|
|
running = asyncio.create_task(call(session, pool, "active", arguments=arguments))
|
|
try:
|
|
await asyncio.wait_for(session.started.wait(), 1)
|
|
if outcome == "cancel":
|
|
running.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await running
|
|
else:
|
|
session.release.set()
|
|
if "error" in arguments:
|
|
with pytest.raises(type(arguments["error"])):
|
|
await running
|
|
else:
|
|
assert (await running).isError is (outcome == "tool_error")
|
|
assert not session.closed.is_set(), "nonfatal call exits must retain the session"
|
|
assert not pool._active_calls
|
|
await pool.get_session("server", "replacement", {})
|
|
assert session.closed.is_set()
|
|
finally:
|
|
session.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shared_session_stays_busy_until_last_call_exits(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 1
|
|
session = await pool.get_session("server", "active", {})
|
|
starts = [asyncio.Event(), asyncio.Event()]
|
|
releases = [asyncio.Event(), asyncio.Event()]
|
|
calls = [asyncio.create_task(call(session, pool, "active", arguments={"started": starts[i], "release": releases[i]})) for i in range(2)]
|
|
try:
|
|
await asyncio.wait_for(asyncio.gather(*(event.wait() for event in starts)), 1)
|
|
calls[0].cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await calls[0]
|
|
with pytest.raises(RuntimeError, match="capacity"):
|
|
await pool.get_session("server", "replacement", {})
|
|
assert not session.closed.is_set()
|
|
releases[1].set()
|
|
assert (await asyncio.wait_for(calls[1], 1)).content[0].text == "finished"
|
|
await pool.get_session("server", "replacement", {})
|
|
assert session.closed.is_set()
|
|
assert not pool._active_calls
|
|
finally:
|
|
for event in releases:
|
|
event.set()
|
|
await asyncio.gather(*calls, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("with_idle", [False, True])
|
|
async def test_promotion_rechecks_busy_sessions_after_initialization(controlled_pool, with_idle):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 2 if with_idle else 1
|
|
initialized = asyncio.Event()
|
|
initialize_gate = asyncio.Event()
|
|
rejected_closed = asyncio.Event()
|
|
pending = asyncio.create_task(pool.get_session("server", "pending", {"initialize_started": initialized, "initialize_gate": initialize_gate, "closed": rejected_closed}))
|
|
running = None
|
|
joiner = None
|
|
try:
|
|
await asyncio.wait_for(initialized.wait(), 1)
|
|
active = await pool.get_session("server", "active", {})
|
|
running = asyncio.create_task(call(active, pool, "active"))
|
|
await asyncio.wait_for(active.started.wait(), 1)
|
|
idle = await pool.get_session("server", "idle", {}) if with_idle else None
|
|
# Join the already-admitted creation even though the registry is now full.
|
|
joined = asyncio.Event()
|
|
|
|
async def join():
|
|
joined.set()
|
|
return await pool.get_session("server", "pending", {})
|
|
|
|
joiner = asyncio.create_task(join())
|
|
await asyncio.wait_for(joined.wait(), 1)
|
|
initialize_gate.set()
|
|
if with_idle:
|
|
replacement, shared = await asyncio.wait_for(asyncio.gather(pending, joiner), 1)
|
|
assert replacement is shared
|
|
await asyncio.wait_for(idle.closed.wait(), 1)
|
|
else:
|
|
outcomes = await asyncio.wait_for(asyncio.gather(pending, joiner, return_exceptions=True), 1)
|
|
assert all(isinstance(error, RuntimeError) and "capacity" in str(error) for error in outcomes)
|
|
await asyncio.wait_for(rejected_closed.wait(), 1)
|
|
assert not active.closed.is_set()
|
|
assert not pool._inflight
|
|
assert len(pool._entries) == pool.MAX_SESSIONS
|
|
active.release.set()
|
|
assert (await asyncio.wait_for(running, 1)).content[0].text == "finished"
|
|
finally:
|
|
initialize_gate.set()
|
|
if running is not None:
|
|
active.release.set()
|
|
await asyncio.gather(pending, *([joiner] if joiner is not None else []), *([running] if running is not None else []), return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_old_call_exit_cannot_unpin_replacement_owner(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 1
|
|
old = await pool.get_session("server", "active", {})
|
|
old_call = asyncio.create_task(call(old, pool, "active"))
|
|
new_call = None
|
|
try:
|
|
await asyncio.wait_for(old.started.wait(), 1)
|
|
await pool.close_scope("active")
|
|
new = await pool.get_session("server", "active", {})
|
|
new_call = asyncio.create_task(call(new, pool, "active"))
|
|
await asyncio.wait_for(new.started.wait(), 1)
|
|
old.release.set()
|
|
with pytest.raises(McpError, match="Connection closed"):
|
|
await asyncio.wait_for(old_call, 1)
|
|
with pytest.raises(RuntimeError, match="capacity"):
|
|
await pool.get_session("server", "replacement", {})
|
|
assert not new.closed.is_set()
|
|
new.release.set()
|
|
assert (await asyncio.wait_for(new_call, 1)).content[0].text == "finished"
|
|
assert not pool._active_calls
|
|
await pool.get_session("server", "replacement", {})
|
|
assert new.closed.is_set()
|
|
finally:
|
|
old.release.set()
|
|
if new_call is not None:
|
|
new.release.set()
|
|
await asyncio.gather(old_call, *([new_call] if new_call is not None else []), return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_personal_session_survives_deployment_capacity_pressure(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 2
|
|
personal = await pool.get_session("server", "shared", {}, domain="personal")
|
|
deployment = await pool.get_session("server", "shared", {})
|
|
running = asyncio.create_task(call(personal, pool, "shared", domain="personal"))
|
|
try:
|
|
await asyncio.wait_for(personal.started.wait(), 1)
|
|
await pool.get_session("server", "replacement", {})
|
|
assert not personal.closed.is_set()
|
|
assert deployment.closed.is_set()
|
|
personal.release.set()
|
|
assert (await asyncio.wait_for(running, 1)).content[0].text == "finished"
|
|
finally:
|
|
personal.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebinding_still_retires_active_calls(controlled_pool):
|
|
pool = controlled_pool
|
|
old_binding = pool.ensure_binding("server", "original-fingerprint")
|
|
session = await pool.get_session("server", "active", {}, binding=old_binding)
|
|
running = asyncio.create_task(call(session, pool, "active"))
|
|
try:
|
|
await asyncio.wait_for(session.started.wait(), 1)
|
|
pool.reconcile_bindings({"server": "new-fingerprint"}, ())
|
|
await asyncio.wait_for(session.closed.wait(), 1)
|
|
session.release.set()
|
|
with pytest.raises(McpError, match="Connection closed"):
|
|
await asyncio.wait_for(running, 1)
|
|
assert not pool._active_calls
|
|
assert not pool._entries
|
|
finally:
|
|
session.release.set()
|
|
await asyncio.gather(running, return_exceptions=True)
|
|
await pool.close_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detached_session_cannot_start_an_unprotected_call(controlled_pool):
|
|
pool = controlled_pool
|
|
pool.MAX_SESSIONS = 1
|
|
old = await pool.get_session("server", "old", {})
|
|
try:
|
|
await pool.get_session("server", "replacement", {})
|
|
with pytest.raises(RuntimeError, match="no longer pooled"):
|
|
await call(old, pool, "old")
|
|
assert not old.started.is_set()
|
|
assert not pool._active_calls
|
|
finally:
|
|
await pool.close_all()
|