mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 08:26:07 +00:00
fix(mcp): survive transient WS transport drops for Box stdio MCP servers (#2303)
* fix(mcp): survive transient WS transport drops for Box stdio MCP servers A Box-backed stdio MCP server (e.g. pab1it0/prometheus) would periodically error on the frontend with Box managed process exited unexpectedly / Failed after 4 attempts once the session had been alive for a while. Root cause: the managed MCP process lives in the Box runtime and SURVIVES a WebSocket transport drop, but _lifecycle_loop treated any monitor completion as a fatal process death. It then ran the finally-block cleanup — which STOPS the still-healthy managed process — and did a full 4-attempt exponential backoff rebuild. Under an occasionally-stalled single-worker event loop the mcp websocket client misses a ping/pong, the transport drops, and this self-inflicted teardown loop is what the user sees. Fixes: - _lifecycle_loop: when the health monitor completes, re-check the real managed-process state. If the process is still running, the transport merely dropped: raise an internal _TransportReconnect signal instead of Box managed process exited unexpectedly. - _lifecycle_loop_with_retry: handle _TransportReconnect as a free, uncounted reconnect (does not consume the fatal retry budget), so a long-lived session survives arbitrarily many transient drops. - finally-block: gate managed-process teardown on a _preserve_managed_process flag so a transport-only reconnect closes just the WS, not the process. - BoxStdioSessionRuntime.initialize: reuse an already-running managed process instead of stopping+rebuilding it (which also re-ran the slow dependency bootstrap); only (re)start when none is running. Adds _managed_process_is_running() helper. Pairs with langbot-plugin-sdk fix adding a server-driven WS heartbeat to the managed-process relay, which prevents most drops in the first place. * style(mcp): ruff format * chore(deps): pin langbot-plugin 0.4.8 for the managed-process WS heartbeat fix --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.7",
|
"langbot-plugin==0.4.8",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ class MCPSessionStatus(enum.Enum):
|
|||||||
ERROR = 'error'
|
ERROR = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class _TransportReconnect(Exception):
|
||||||
|
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||||
|
process is still alive. Triggers a lightweight transport reconnect that
|
||||||
|
reuses the live process, instead of a full process rebuild.
|
||||||
|
|
||||||
|
Reconnect attempts are NOT counted toward the fatal retry budget, so a
|
||||||
|
long-lived session can survive arbitrarily many transient drops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RuntimeMCPSession:
|
class RuntimeMCPSession:
|
||||||
"""运行时 MCP 会话"""
|
"""运行时 MCP 会话"""
|
||||||
|
|
||||||
@@ -254,6 +264,9 @@ class RuntimeMCPSession:
|
|||||||
self._lifecycle_task = None
|
self._lifecycle_task = None
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
|
# Set transiently when a WS transport drop should NOT stop the managed
|
||||||
|
# process (it will be re-attached on the next initialize()).
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
|
||||||
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
||||||
self.box_config = self._box_stdio_runtime.config
|
self.box_config = self._box_stdio_runtime.config
|
||||||
@@ -399,6 +412,28 @@ class RuntimeMCPSession:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
for task in done:
|
for task in done:
|
||||||
if task is monitor_task and not self._shutdown_event.is_set():
|
if task is monitor_task and not self._shutdown_event.is_set():
|
||||||
|
# The monitor completed. This is EITHER the managed
|
||||||
|
# process actually exiting OR just the WS transport
|
||||||
|
# dropping while the process stays alive in the Box
|
||||||
|
# runtime. Re-check the real process state so a
|
||||||
|
# transient transport drop reconnects (reusing the live
|
||||||
|
# process) instead of tearing the process down and
|
||||||
|
# running a full rebuild+backoff cycle.
|
||||||
|
process_still_running = False
|
||||||
|
try:
|
||||||
|
process_still_running = await self._box_stdio_runtime._managed_process_is_running()
|
||||||
|
except Exception:
|
||||||
|
process_still_running = False
|
||||||
|
if process_still_running:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: transport dropped but '
|
||||||
|
f'managed process is still running; reconnecting transport'
|
||||||
|
)
|
||||||
|
self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
|
# Preserve the live process across the finally-block
|
||||||
|
# cleanup: only the WS transport should be torn down.
|
||||||
|
self._preserve_managed_process = True
|
||||||
|
raise _TransportReconnect('Box managed process transport dropped; reconnecting')
|
||||||
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
||||||
raise Exception('Box managed process exited unexpectedly')
|
raise Exception('Box managed process exited unexpectedly')
|
||||||
else:
|
else:
|
||||||
@@ -424,14 +459,37 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||||
finally:
|
finally:
|
||||||
await self._cleanup_box_stdio_session()
|
# On a transport-only reconnect the managed process is healthy
|
||||||
|
# and will be re-attached on the next initialize(); do NOT stop
|
||||||
|
# it. Any other exit path fully tears the session down.
|
||||||
|
if getattr(self, '_preserve_managed_process', False):
|
||||||
|
self._preserve_managed_process = False
|
||||||
|
else:
|
||||||
|
await self._cleanup_box_stdio_session()
|
||||||
|
|
||||||
async def _lifecycle_loop_with_retry(self):
|
async def _lifecycle_loop_with_retry(self):
|
||||||
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
"""Wrap _lifecycle_loop with retry and exponential backoff."""
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
attempt = 0
|
||||||
|
while attempt <= self._MAX_RETRIES:
|
||||||
try:
|
try:
|
||||||
await self._lifecycle_loop()
|
await self._lifecycle_loop()
|
||||||
return # Normal shutdown, don't retry
|
return # Normal shutdown, don't retry
|
||||||
|
except _TransportReconnect as e:
|
||||||
|
# Transient WS transport drop while the managed process is still
|
||||||
|
# alive. Reconnect promptly WITHOUT consuming the fatal retry
|
||||||
|
# budget and WITHOUT stopping the process — initialize() will
|
||||||
|
# re-attach to the live process. This is what lets a long-lived
|
||||||
|
# stdio MCP survive repeated brief event-loop stalls / pings.
|
||||||
|
if self._shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})'
|
||||||
|
)
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
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():
|
||||||
@@ -460,6 +518,7 @@ class RuntimeMCPSession:
|
|||||||
self.error_message = None
|
self.error_message = None
|
||||||
self.error_phase = None
|
self.error_phase = None
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _describe_exception(exc: BaseException) -> str:
|
def _describe_exception(exc: BaseException) -> str:
|
||||||
|
|||||||
@@ -173,25 +173,36 @@ class BoxStdioSessionRuntime:
|
|||||||
stderr_preview = (result.stderr or '')[:500]
|
stderr_preview = (result.stderr or '')[:500]
|
||||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||||
|
|
||||||
try:
|
# Reuse an already-running managed process instead of rebuilding it.
|
||||||
process_workspace = (
|
# The Box runtime keeps the managed process alive across a transient
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
# WebSocket transport drop, so on a reconnect we only need to re-attach
|
||||||
if host_path
|
# the WS below. Rebuilding here would needlessly stop a healthy process
|
||||||
else workspace
|
# and re-run the (slow, network-touching) dependency bootstrap.
|
||||||
|
if not await self._managed_process_is_running():
|
||||||
|
try:
|
||||||
|
process_workspace = (
|
||||||
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
|
if host_path
|
||||||
|
else workspace
|
||||||
|
)
|
||||||
|
payload = process_workspace.build_process_payload(
|
||||||
|
self.server_config['command'],
|
||||||
|
self.server_config.get('args', []),
|
||||||
|
env=self.server_config.get('env', {}),
|
||||||
|
cwd=process_cwd,
|
||||||
|
)
|
||||||
|
if install_cmd:
|
||||||
|
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||||
|
payload['process_id'] = self.process_id
|
||||||
|
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||||
|
except Exception:
|
||||||
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: reusing live managed process '
|
||||||
|
f'process_id={self.process_id} (transport reconnect)'
|
||||||
)
|
)
|
||||||
payload = process_workspace.build_process_payload(
|
|
||||||
self.server_config['command'],
|
|
||||||
self.server_config.get('args', []),
|
|
||||||
env=self.server_config.get('env', {}),
|
|
||||||
cwd=process_cwd,
|
|
||||||
)
|
|
||||||
if install_cmd:
|
|
||||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
|
||||||
payload['process_id'] = self.process_id
|
|
||||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
|
||||||
except Exception:
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||||
@@ -236,6 +247,23 @@ class BoxStdioSessionRuntime:
|
|||||||
return
|
return
|
||||||
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def _managed_process_is_running(self) -> bool:
|
||||||
|
"""Return True if this server's managed process exists and is running.
|
||||||
|
|
||||||
|
Used to decide whether initialize() must (re)start the process or can
|
||||||
|
simply re-attach the WebSocket transport to a process the Box runtime
|
||||||
|
kept alive across a transient transport drop.
|
||||||
|
"""
|
||||||
|
from langbot_plugin.box.models import BoxManagedProcessStatus
|
||||||
|
|
||||||
|
workspace = self._build_workspace()
|
||||||
|
try:
|
||||||
|
info = await workspace.get_managed_process(self.process_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
||||||
|
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
|
||||||
|
|
||||||
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
||||||
source_path = normalize_host_path(host_path)
|
source_path = normalize_host_path(host_path)
|
||||||
if not source_path:
|
if not source_path:
|
||||||
|
|||||||
@@ -2123,7 +2123,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.7" },
|
{ name = "langbot-plugin", specifier = "==0.4.8" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2187,7 +2187,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.7"
|
version = "0.4.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2208,9 +2208,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/00/6c/850a92cc05583ab71e6ac527ff50fa5f3db43cc16e86f4f6d5ac684da9a8/langbot_plugin-0.4.7.tar.gz", hash = "sha256:16b24d79fc55c0a6b15d901b1134de9e298e2cadab9a9b25c9231f731333ebe9", size = 333867, upload-time = "2026-07-02T16:49:46.258Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/1b417a48d329f959a261eaf9a37b26197f18081d2b6f0ef3e4bbab687b81/langbot_plugin-0.4.8.tar.gz", hash = "sha256:e9614be248acf352acb9430d9e154e0abae981575de16812f68282c92a4e42d8", size = 334076, upload-time = "2026-07-03T04:15:28.233Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/f5/a00a929ffb4dcbd958e5f5249516f19ecc720f25f072c9bfda8bbb56851c/langbot_plugin-0.4.7-py3-none-any.whl", hash = "sha256:1364f80fcf448f4503e87d1367af60438abd2cf16e78be6807032cbdb4710a56", size = 220999, upload-time = "2026-07-02T16:49:44.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/21/46bfa911ab27d6856a3b4363805c4bb8a657500cbc12e1e719d0b3b349dc/langbot_plugin-0.4.8-py3-none-any.whl", hash = "sha256:87c12d9d5579b0ab4dc43c9e559b4d15d7387db8c8b52d476cf27a3de74aedf5", size = 221244, upload-time = "2026-07-03T04:15:27.087Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user