Files
hhzscreate c16175e4f4 修复以下问题:
(1)新增 MCP server 工具数为 0,其原因为重连块被误缩进进工具清理循环
(2)测试连接成功、但持久连接 0 tools ,mcp连接在 A 任务打开、B 任务关闭,违反 anyio 同任务约束。改为每个 server 由“持有者任务”独占 open/hold/close。
(3)中途改 MCP 配置,老对话不加载新工具,改为每轮对话前全量对齐(增/删/换)。
(4)停止后全局开关仍开,状态轮询把它又连回来。导致无法手动单独停止server。改为停止后全局开关仍开,状态轮询把它又连回来
2026-07-12 00:56:47 +08:00

305 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""McpClientManager 连接生命周期回归测试(持有者任务模型)。
背景:MCP 的 stdio/sse/http 传输由 anyio 实现,其 cancel scope 必须在
“进入它的那个任务”里退出。历史实现把连接的 open 放在临时任务(connect 的
gather 子任务)里、close 却在别的任务里做,触发
RuntimeError: Attempted to exit cancel scope in a different task
导致“测试连接成功、但持久连接 0 tools / 启动失败 / 子进程泄漏”。
修复后:每个 server 由一个“持有者任务”独占——open/hold/close 全在同一任务内,
外部只通过 stop 信号或取消来停止。本文件用替身验证该模型的逻辑正确性;
真正的“跨任务不再抛 RuntimeError”由 mcp_demo_server/run_e2e.py 端到端验证。
无第三方 async 插件:用 asyncio.run 驱动。
"""
import asyncio
import sys
import types
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import backend.modules.mcp.client as mcp_client
from backend.modules.mcp.client import McpClientManager
class FakeRegistry:
"""实现持有者任务用到的 registry 接口。"""
def __init__(self):
self._tools = {} # 健康检查会读 _tools
def register_name(self, name):
self._tools[name] = types.SimpleNamespace(name=name)
def unregister(self, name):
if name in self._tools:
del self._tools[name]
return True
raise KeyError(name)
def list_tools(self):
return sorted(self._tools)
class FakeStack:
def __init__(self):
self.closed = False
async def aclose(self):
self.closed = True
def _cfg(server_id):
return types.SimpleNamespace(
id=server_id, name=server_id, enabled=True,
connect_timeout=10, timeout=30, transport="stdio",
)
def _make_manager(server_id):
mgr = McpClientManager()
mgr.set_registry(FakeRegistry())
mgr._server_configs[server_id] = _cfg(server_id)
mgr._reconnect_backoff_base = 0.0 # 失败重试不真正 sleep
return mgr
def _patch_connect(monkeypatch, tools, stacks=None):
"""把 connect_mcp_server 换成受控替身:注册工具、返回可关闭的 FakeStack。
tools=None 表示模拟连接失败(返回 None)。
"""
calls = {"count": 0}
async def fake_connect(server_id, cfg, registry, out_wrappers=None):
calls["count"] += 1
if tools is None:
return None
for suffix in tools:
name = f"mcp_{server_id}_{suffix}"
registry.register_name(name)
if out_wrappers is not None:
out_wrappers[name] = object()
st = FakeStack()
if stacks is not None:
stacks.append(st)
return st
monkeypatch.setattr(mcp_client, "connect_mcp_server", fake_connect)
return calls
# --------------------------------------------------------------------------
# reconnect_server
# --------------------------------------------------------------------------
def test_reconnect_new_server_connects(monkeypatch):
"""0 连接的新 server:reconnect 应起持有者任务、注册工具、返回 True。"""
async def scenario():
mgr = _make_manager("srv")
calls = _patch_connect(monkeypatch, tools=["a", "b", "c"])
ok = await mgr.reconnect_server("srv")
assert ok is True
assert calls["count"] == 1
assert "srv" in mgr._conn_tasks, "应有持有者任务在持有连接"
assert set(mgr._mcp_tool_names) == {"mcp_srv_a", "mcp_srv_b", "mcp_srv_c"}
# 持有者任务应仍在运行(挂在 stop.wait 上)
assert not mgr._conn_tasks["srv"].done()
await mgr.disconnect_server("srv") # 清理
asyncio.run(scenario())
def test_reconnect_replaces_old_connection_no_leak(monkeypatch):
"""再次 reconnect:旧持有者任务被停止、旧 stack 被关闭,工具数保持一致、无泄漏。"""
async def scenario():
mgr = _make_manager("srv")
stacks = []
_patch_connect(monkeypatch, tools=["a", "b"], stacks=stacks)
assert await mgr.reconnect_server("srv") is True
first_task = mgr._conn_tasks["srv"]
assert len(stacks) == 1
assert await mgr.reconnect_server("srv") is True
# 旧任务已结束、旧 stack 已在“它自己任务里”关闭
assert first_task.done()
assert stacks[0].closed is True, "旧连接的 stack 应被关闭(无子进程泄漏)"
assert len(stacks) == 2
assert set(mgr._mcp_tool_names) == {"mcp_srv_a", "mcp_srv_b"}, "重连后工具集合一致、无重复/残留"
await mgr.disconnect_server("srv")
asyncio.run(scenario())
def test_reconnect_returns_false_when_connect_fails(monkeypatch):
"""连接持续失败:应耗尽重试并返回 False。"""
async def scenario():
mgr = _make_manager("srv")
mgr._max_reconnect_attempts = 3
calls = _patch_connect(monkeypatch, tools=None)
ok = await mgr.reconnect_server("srv")
assert ok is False
assert calls["count"] == 3
assert "srv" not in mgr._conn_tasks
asyncio.run(scenario())
def test_reconnect_unknown_server_returns_false(monkeypatch):
async def scenario():
mgr = _make_manager("known")
calls = _patch_connect(monkeypatch, tools=["x"])
ok = await mgr.reconnect_server("nope")
assert ok is False
assert calls["count"] == 0
asyncio.run(scenario())
# --------------------------------------------------------------------------
# disconnect_server
# --------------------------------------------------------------------------
def test_disconnect_stops_task_and_clears_tools(monkeypatch):
"""断开:停止持有者任务、关闭 stack、清空工具、_connected 归零、返回 True。"""
async def scenario():
mgr = _make_manager("srv")
stacks = []
_patch_connect(monkeypatch, tools=["a", "b"], stacks=stacks)
assert await mgr.start_server("srv") is True
assert mgr._connected is True
task = mgr._conn_tasks["srv"]
ok = await mgr.disconnect_server("srv")
assert ok is True
assert task.done(), "持有者任务应已停止"
assert stacks[0].closed is True, "stack 应被关闭"
assert "srv" not in mgr._conn_tasks
assert [n for n in mgr._mcp_tool_names] == []
assert mgr._connected is False
asyncio.run(scenario())
def test_disconnect_one_of_many_keeps_connected(monkeypatch):
"""还有其它 server 时,断开其中一个不应清零 _connected。"""
async def scenario():
mgr = McpClientManager()
mgr.set_registry(FakeRegistry())
mgr._server_configs["A"] = _cfg("A")
mgr._server_configs["B"] = _cfg("B")
_patch_connect(monkeypatch, tools=["t"])
assert await mgr.start_server("A") is True
assert await mgr.start_server("B") is True
assert await mgr.disconnect_server("A") is True
assert "A" not in mgr._conn_tasks
assert "B" in mgr._conn_tasks
assert mgr._connected is True
await mgr.disconnect_server("B")
asyncio.run(scenario())
def test_disconnect_all_stops_everything(monkeypatch):
async def scenario():
mgr = McpClientManager()
mgr.set_registry(FakeRegistry())
mgr._server_configs["A"] = _cfg("A")
mgr._server_configs["B"] = _cfg("B")
stacks = []
_patch_connect(monkeypatch, tools=["t"], stacks=stacks)
await mgr.start_server("A")
await mgr.start_server("B")
assert len(mgr._conn_tasks) == 2
await mgr.disconnect()
assert mgr._conn_tasks == {}
assert mgr._connected is False
assert all(s.closed for s in stacks), "所有连接的 stack 都应关闭"
assert mgr._mcp_tool_names == []
asyncio.run(scenario())
# --------------------------------------------------------------------------
# connect / start_server / _safe_aclose
# --------------------------------------------------------------------------
def test_connect_sets_connected_and_starts_health_check(monkeypatch):
"""connect() 多 server:置 _connected、登记全部工具、起健康检查任务。"""
async def scenario():
mgr = McpClientManager()
mgr.set_registry(FakeRegistry())
_patch_connect(monkeypatch, tools=["t"])
await mgr.connect([_cfg("A"), _cfg("B")])
assert mgr._connected is True
assert set(mgr._conn_tasks) == {"A", "B"}
assert set(mgr._mcp_tool_names) == {"mcp_A_t", "mcp_B_t"}
assert mgr._reconnect_task is not None and not mgr._reconnect_task.done(), "应启动健康检查任务"
await mgr.disconnect()
assert mgr._conn_tasks == {}
assert mgr._connected is False
asyncio.run(scenario())
def test_start_server_already_running_returns_true(monkeypatch):
"""已在运行的 server 再次 start:直接返回 True,不重复发起连接。"""
async def scenario():
mgr = _make_manager("srv")
calls = _patch_connect(monkeypatch, tools=["t"])
assert await mgr.start_server("srv") is True
assert calls["count"] == 1
assert await mgr.start_server("srv") is True
assert calls["count"] == 1, "已运行时不应重复连接"
await mgr.disconnect_server("srv")
asyncio.run(scenario())
def test_safe_aclose_swallows_errors_and_timeout():
"""_safe_aclose:aclose 抛错或卡死都不得外泄、不得卡死持有者任务(B 兜底鲁棒性)。"""
async def scenario():
mgr = McpClientManager()
class RaisingStack:
async def aclose(self):
raise RuntimeError("Attempted to exit cancel scope in a different task")
# 抛错被吞(若外泄则本行会抛,测试失败)
await mgr._safe_aclose(RaisingStack(), "x")
class HangingStack:
async def aclose(self):
await asyncio.Event().wait() # 永不返回
mgr._close_timeout = 0.05
# 超时被吞、不卡死
await mgr._safe_aclose(HangingStack(), "y")
asyncio.run(scenario())
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))