From af5a330d0f9992b63bafffeb0bd18564e875513d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 23 Aug 2026 15:21:32 +0800 Subject: [PATCH] fix(agent): await stdio mcp reader shutdown --- app/agent/mcp.py | 7 +++-- .../adr/0007-background-action-reliability.md | 4 +++ tests/test_agent_mcp.py | 30 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/app/agent/mcp.py b/app/agent/mcp.py index 99eae3060..6a71337a9 100644 --- a/app/agent/mcp.py +++ b/app/agent/mcp.py @@ -217,8 +217,11 @@ class _StdioMcpSession: async def __aexit__(self, exc_type, exc, tb) -> None: """结束 stdio MCP 子进程。""" - if self.stderr_task: - self.stderr_task.cancel() + stderr_task = self.stderr_task + self.stderr_task = None + if stderr_task: + stderr_task.cancel() + await asyncio.gather(stderr_task, return_exceptions=True) if not self.process: return if self.process.returncode is None: diff --git a/docs/adr/0007-background-action-reliability.md b/docs/adr/0007-background-action-reliability.md index db72400e9..b7ee7080e 100644 --- a/docs/adr/0007-background-action-reliability.md +++ b/docs/adr/0007-background-action-reliability.md @@ -79,6 +79,10 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同 - 已登记的周期 Agent task:E1,重启时通过任务定义重建;单次执行要有 execution 记录。 - Agent 创建/修改订阅、删除数据等工具:业务事务按 E2/E3;聊天输出不能替代业务完成证据。 - 会话 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 / 文件整理 diff --git a/tests/test_agent_mcp.py b/tests/test_agent_mcp.py index 1d8064f37..c445b75e6 100644 --- a/tests/test_agent_mcp.py +++ b/tests/test_agent_mcp.py @@ -1,10 +1,11 @@ +import asyncio import sys import textwrap from unittest.mock import AsyncMock, patch 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.impl.mcp import ( McpExternalTool, @@ -86,6 +87,33 @@ async def test_stdio_mcp_server_lists_tools(tmp_path): 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 async def test_enabled_specs_preserve_cross_server_name_collisions() -> None: """跨 MCP 服务器生成相同 Agent 名时必须把全部身份交给目录判断。"""