mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 08:26:07 +00:00
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:
@@ -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
|
||||||
try:
|
# cold start the handshake succeeds and stays healthy.
|
||||||
transport = await handshake_stack.enter_async_context(websocket_client(websocket_url))
|
try:
|
||||||
read_stream, write_stream = transport
|
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||||
session = await handshake_stack.enter_async_context(ClientSession(read_stream, write_stream))
|
read_stream, write_stream = transport
|
||||||
# Bound each handshake attempt: a process still in cold start
|
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||||
# will not answer, so time out and retry rather than hang until
|
ClientSession(read_stream, write_stream)
|
||||||
# the transport eventually drops with 'Connection closed'.
|
)
|
||||||
await asyncio.wait_for(session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
except Exception:
|
||||||
# Success: hand the live transport/session over to the owner's
|
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
# exit stack so it lives for the session's lifetime.
|
if not await self._managed_process_has_exited():
|
||||||
self.owner.session = session
|
# Process is alive but not yet serving (cold start) — reconnect.
|
||||||
await self.owner.exit_stack.enter_async_context(_TransferredStack(handshake_stack))
|
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
|
||||||
if attempt > 1:
|
raise
|
||||||
self.ap.logger.info(
|
|
||||||
f'MCP server {self.server_name}: handshake succeeded on attempt {attempt} '
|
try:
|
||||||
f'(process finished cold start)'
|
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
||||||
)
|
except Exception as exc:
|
||||||
break
|
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
||||||
except Exception as exc:
|
if not await self._managed_process_has_exited():
|
||||||
# Tear down this attempt's half-open transport before retrying.
|
raise _ColdStartRetry(
|
||||||
try:
|
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
|
||||||
await handshake_stack.aclose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Stop retrying ONLY if the process has definitively exited —
|
|
||||||
# a just-spawned process that is not yet RUNNING, or a transient
|
|
||||||
# box query error, must NOT abort the cold-start wait.
|
|
||||||
if await self._managed_process_has_exited():
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
|
||||||
raise
|
|
||||||
if asyncio.get_running_loop().time() >= deadline:
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
|
||||||
raise Exception(
|
|
||||||
f'MCP handshake did not complete within '
|
|
||||||
f'{int(self.config.startup_timeout_sec or 120)}s '
|
|
||||||
f'({attempt} attempts): {type(exc).__name__}: {exc}'
|
|
||||||
)
|
|
||||||
# Back off briefly and retry — the process is still coming up.
|
|
||||||
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)
|
raise
|
||||||
|
|
||||||
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),
|
raise Exception('Connection closed')
|
||||||
# succeed on the third.
|
|
||||||
if calls['init'] < 3:
|
|
||||||
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.
|
with pytest.raises(mcp_stdio_module._ColdStartRetry):
|
||||||
orig_sleep = _asyncio.sleep
|
await session._init_box_stdio_server()
|
||||||
|
|
||||||
async def _fast_sleep(_s):
|
# Process was started exactly once (the retry will reuse it, not rebuild).
|
||||||
await orig_sleep(0)
|
assert ap.box_service.start_managed_process.await_count == 1
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', _fast_sleep)
|
|
||||||
|
|
||||||
await session._init_box_stdio_server()
|
|
||||||
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user