fix(agent): await stdio mcp reader shutdown

This commit is contained in:
jxxghp
2026-08-23 15:21:32 +08:00
parent 7afabeda02
commit af5a330d0f
3 changed files with 38 additions and 3 deletions
+5 -2
View File
@@ -217,8 +217,11 @@ class _StdioMcpSession:
async def __aexit__(self, exc_type, exc, tb) -> None: async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。""" """结束 stdio MCP 子进程。"""
if self.stderr_task: stderr_task = self.stderr_task
self.stderr_task.cancel() self.stderr_task = None
if stderr_task:
stderr_task.cancel()
await asyncio.gather(stderr_task, return_exceptions=True)
if not self.process: if not self.process:
return return
if self.process.returncode is None: if self.process.returncode is None:
@@ -79,6 +79,10 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
- 已登记的周期 Agent task:E1,重启时通过任务定义重建;单次执行要有 execution 记录。 - 已登记的周期 Agent task:E1,重启时通过任务定义重建;单次执行要有 execution 记录。
- Agent 创建/修改订阅、删除数据等工具:业务事务按 E2/E3;聊天输出不能替代业务完成证据。 - Agent 创建/修改订阅、删除数据等工具:业务事务按 E2/E3;聊天输出不能替代业务完成证据。
- 会话 stop/cancel:E0 控制信号;被取消工具的底层阻塞 I/O 可能继续,资源所有者必须最终回收。 - 会话 stop/cancel:E0 控制信号;被取消工具的底层阻塞 I/O 可能继续,资源所有者必须最终回收。
- OpenAI/Anthropic 协议流的请求级 Agent worker 由 `api.openai.stream` /
`api.anthropic.stream` 登记并在 lifespan shutdown 时取消;它们仍是 E0 请求交付,不提供跨重启恢复。
- stdio MCP 的 stderr reader 属于会话资源内部任务;会话退出时先取消并等待 reader 收口,再终止子进程,避免
资源已释放而 reader 仍悬挂。
### Transfer pending / 文件整理 ### Transfer pending / 文件整理
+29 -1
View File
@@ -1,10 +1,11 @@
import asyncio
import sys import sys
import textwrap import textwrap
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.agent.mcp import AgentMcpManager, AgentMcpToolSpec from app.agent.mcp import AgentMcpManager, AgentMcpToolSpec, _StdioMcpSession
from app.agent.tools.catalog import ToolCatalogSnapshot from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.mcp import ( from app.agent.tools.impl.mcp import (
McpExternalTool, McpExternalTool,
@@ -86,6 +87,33 @@ async def test_stdio_mcp_server_lists_tools(tmp_path):
assert tools[0].input_schema["properties"]["text"]["type"] == "string" assert tools[0].input_schema["properties"]["text"]["type"] == "string"
@pytest.mark.anyio
async def test_stdio_mcp_session_waits_for_stderr_reader_shutdown() -> None:
"""stdio MCP 会话退出时必须等待 stderr reader 真正完成。"""
session = _StdioMcpSession(
AgentMcpServerConfig(id="fake", name="Fake MCP", transport="stdio")
)
started = asyncio.Event()
cancelled = asyncio.Event()
async def reader() -> None:
"""模拟持续读取 stderr 的会话子任务。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
session.stderr_task = asyncio.create_task(reader())
await started.wait()
await session.__aexit__(None, None, None)
assert session.stderr_task is None
assert cancelled.is_set()
@pytest.mark.anyio @pytest.mark.anyio
async def test_enabled_specs_preserve_cross_server_name_collisions() -> None: async def test_enabled_specs_preserve_cross_server_name_collisions() -> None:
"""跨 MCP 服务器生成相同 Agent 名时必须把全部身份交给目录判断。""" """跨 MCP 服务器生成相同 Agent 名时必须把全部身份交给目录判断。"""