fix(mcp): fix cold-start connection drop by using lexically-intact owner exit stack (#2307)

The previous _TransferredStack approach broke anyio lexical context:
websocket_client/ClientSession use anyio task groups whose cancel scope is
bound to the frame that entered them. Deferring their aclose via a transferred
exit stack left the underlying memory streams closed once initialize() returned,
so the very next request (refresh -> list_tools) failed with Connection closed.

New design:
- Attach on the owner exit stack (same task as the serve loop, lexically intact)
- A cold-starting process makes initialize() fail; signal _ColdStartRetry up to
  the outer retry loop, which reuses the live process without consuming retry budget
- _lifecycle_loop_with_retry handles _ColdStartRetry like _TransportReconnect:
  preserves process, no fatal budget, backs off 2s and retries
- Two new unit tests: cold-start raises _ColdStartRetry (not fatal) when process
  is alive; raises fatal error when process has actually exited

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-07-03 14:58:40 +08:00
committed by GitHub
parent ed9343c686
commit bf3c96026b
3 changed files with 137 additions and 95 deletions
+19 -1
View File
@@ -25,7 +25,7 @@ from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401 from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
# Synthesized LLM tools for MCP resources (not from server tools/list). # Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used. # Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -490,6 +490,24 @@ class RuntimeMCPSession:
self.error_phase = None self.error_phase = None
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
except _ColdStartRetry as e:
# The managed process is alive but still cold-starting (e.g.
# `npx -y <pkg>` is still installing) and cannot yet answer the
# handshake. Reuse the live process and retry the attach WITHOUT
# consuming the fatal retry budget or stopping the process, so a
# slow cold start is waited out instead of failing. Preserve the
# process across the finally-block cleanup.
if self._shutdown_event.is_set():
return
self._preserve_managed_process = True
self.ap.logger.debug(
f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})'
)
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(2)
continue
except Exception as e: except Exception as e:
self.retry_count = attempt + 1 self.retry_count = attempt + 1
if self._shutdown_event.is_set(): if self._shutdown_event.is_set():
@@ -94,6 +94,15 @@ class _TransferredStack:
return False return False
class _ColdStartRetry(Exception):
"""Signal: the managed process is alive but not yet answering the MCP
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
installing). The outer lifecycle retry treats this like a transient
reconnect: it reuses the live process and does not count toward the fatal
retry budget, so a slow cold start is waited out rather than failing.
"""
class BoxStdioSessionRuntime: class BoxStdioSessionRuntime:
"""Encapsulate Box-backed stdio MCP session orchestration.""" """Encapsulate Box-backed stdio MCP session orchestration."""
@@ -226,63 +235,43 @@ class BoxStdioSessionRuntime:
websocket_url = workspace.get_managed_process_websocket_url(self.process_id) websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
# A freshly started stdio process may be slow to become ready — an # Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# `npx -y <pkg>` cold start downloads+installs the package before the # in the same task as the serve loop that follows. websocket_client and
# server can answer the MCP handshake (measured ~27s for a simple # ClientSession use anyio task groups whose cancel scope is bound to the
# server; more for heavier ones). Attaching the WS and calling # frame/stack that entered them, so they must live on the owner exit
# initialize() immediately therefore fails with "Connection closed" # stack (not a deferred/transferred one) or the streams close the moment
# even though the process is fine. The WS relay tolerates the silent # initialize() returns and the next request fails with "Connection
# cold-start window, so we retry the handshake (attach -> ClientSession # closed".
# -> initialize) within the startup budget, tearing down the failed #
# transport between attempts, until the process is ready or we time out. # A slow (`npx -y <pkg>`) cold start makes this single attempt fail
deadline = asyncio.get_running_loop().time() + max(float(self.config.startup_timeout_sec or 120), 1.0) # while the process is still alive — the package is still installing and
attempt = 0 # cannot answer the handshake. We surface that to the outer retry loop
while True: # as a _ColdStartRetry: it must NOT stop the process (it is healthy and
attempt += 1 # will be reused) and must NOT consume the fatal retry budget. The next
handshake_stack = AsyncExitStack() # attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try: try:
transport = await handshake_stack.enter_async_context(websocket_client(websocket_url)) transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport read_stream, write_stream = transport
session = await handshake_stack.enter_async_context(ClientSession(read_stream, write_stream)) self.owner.session = await self.owner.exit_stack.enter_async_context(
# Bound each handshake attempt: a process still in cold start ClientSession(read_stream, write_stream)
# will not answer, so time out and retry rather than hang until
# the transport eventually drops with 'Connection closed'.
await asyncio.wait_for(session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
# Success: hand the live transport/session over to the owner's
# exit stack so it lives for the session's lifetime.
self.owner.session = session
await self.owner.exit_stack.enter_async_context(_TransferredStack(handshake_stack))
if attempt > 1:
self.ap.logger.info(
f'MCP server {self.server_name}: handshake succeeded on attempt {attempt} '
f'(process finished cold start)'
) )
break
except Exception as exc:
# Tear down this attempt's half-open transport before retrying.
try:
await handshake_stack.aclose()
except Exception: except Exception:
pass self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
# Stop retrying ONLY if the process has definitively exited — if not await self._managed_process_has_exited():
# a just-spawned process that is not yet RUNNING, or a transient # Process is alive but not yet serving (cold start) — reconnect.
# box query error, must NOT abort the cold-start wait. raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
if await self._managed_process_has_exited():
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
raise raise
if asyncio.get_running_loop().time() >= deadline:
try:
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
except Exception as exc:
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
raise Exception( if not await self._managed_process_has_exited():
f'MCP handshake did not complete within ' raise _ColdStartRetry(
f'{int(self.config.startup_timeout_sec or 120)}s ' f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
f'({attempt} attempts): {type(exc).__name__}: {exc}'
) )
# Back off briefly and retry — the process is still coming up. raise
self.ap.logger.debug(
f'MCP server {self.server_name}: handshake attempt {attempt} not ready yet '
f'({type(exc).__name__}); retrying while process starts'
)
await asyncio.sleep(2)
async def monitor_process_health(self) -> None: async def monitor_process_health(self) -> None:
from langbot_plugin.box.models import BoxManagedProcessStatus from langbot_plugin.box.models import BoxManagedProcessStatus
@@ -832,19 +832,16 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stdio_handshake_retries_during_cold_start(mcp_module, tmp_path, monkeypatch): async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
"""A slow (npx-style) cold start: the first handshake attempts fail while """During a slow (npx) cold start the handshake fails while the managed
the process is still starting, but the managed process is NOT exited, so process is still alive. initialize() must raise _ColdStartRetry (so the
the handshake retry loop keeps trying and succeeds once the process is outer lifecycle loop reuses the live process and retries without stopping it
ready rather than raising and letting the outer loop churn the process.""" or consuming the fatal budget), NOT a fatal error."""
import asyncio as _asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
calls = {'init': 0} class ColdClientSession:
class FlakyClientSession:
def __init__(self, *_args): def __init__(self, *_args):
pass pass
@@ -855,20 +852,15 @@ async def test_stdio_handshake_retries_during_cold_start(mcp_module, tmp_path, m
return False return False
async def initialize(self): async def initialize(self):
calls['init'] += 1 # Process still cold-starting: handshake fails.
# Fail the first two attempts (process still cold-starting),
# succeed on the third.
if calls['init'] < 3:
raise Exception('Connection closed') raise Exception('Connection closed')
return None
@asynccontextmanager @asynccontextmanager
async def fake_websocket_client(_url: str): async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream') yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', FlakyClientSession) monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client) monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
# Make the retry loop fast.
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False) monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap() ap = _make_ap()
@@ -889,34 +881,77 @@ async def test_stdio_handshake_retries_during_cold_start(mcp_module, tmp_path, m
ap=ap, ap=ap,
) )
# The process never reports 'exited' during the cold start. # Process is NOT exited (still cold-starting) and not yet running for reuse.
async def _not_exited(): async def _not_exited():
return False return False
session._box_stdio_runtime._managed_process_has_exited = _not_exited session._box_stdio_runtime._managed_process_has_exited = _not_exited
# First check (is it already running for reuse?) -> False so we start it.
running_flags = iter([False])
async def _is_running(): async def _not_running():
try: return False
return next(running_flags)
except StopIteration:
return True
session._box_stdio_runtime._managed_process_is_running = _is_running session._box_stdio_runtime._managed_process_is_running = _not_running
# Speed up the inter-attempt sleep.
orig_sleep = _asyncio.sleep
async def _fast_sleep(_s):
await orig_sleep(0)
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', _fast_sleep)
with pytest.raises(mcp_stdio_module._ColdStartRetry):
await session._init_box_stdio_server() await session._init_box_stdio_server()
# Process was started exactly once (the retry will reuse it, not rebuild).
assert ap.box_service.start_managed_process.await_count == 1
await session.exit_stack.aclose() await session.exit_stack.aclose()
# Handshake was retried until success (3 initialize calls) and the process
# was started exactly once (never rebuilt). @pytest.mark.asyncio
assert calls['init'] == 3 async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
assert ap.box_service.start_managed_process.await_count == 1 """If the handshake fails AND the process has definitively exited, that is a
real failure initialize() must NOT swallow it as a cold-start retry."""
from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
class DeadClientSession:
def __init__(self, *_args):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def initialize(self):
raise Exception('Connection closed')
@asynccontextmanager
async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap()
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
session = _make_session(
mcp_module,
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
ap=ap,
)
async def _exited():
return True
session._box_stdio_runtime._managed_process_has_exited = _exited
async def _not_running():
return False
session._box_stdio_runtime._managed_process_is_running = _not_running
with pytest.raises(Exception) as ei:
await session._init_box_stdio_server()
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
await session.exit_stack.aclose()