Plugin Runtime Stability#
LangBot manages plugin execution in a dedicated plugin runtime subprocess that communicates with the main process over two transports: stdio (default on Linux/macOS) or WebSocket (Docker deployments and Windows). The same stability machinery also governs MCP (Model Context Protocol) tool-server sessions, which run as Box-sandboxed subprocesses connected over a WebSocket relay. Both layers share the same core patterns: heartbeat monitoring, reconnection callbacks, process-vs-transport distinction, and bounded retry budgets.
Primary source files:
src/langbot/pkg/plugin/connector.pyβPluginRuntimeConnectorsrc/langbot/pkg/provider/tools/loaders/mcp.pyβRuntimeMCPSession,MCPLoadersrc/langbot/pkg/provider/tools/loaders/mcp_stdio.pyβBoxStdioSessionRuntime,MCPSessionErrorPhase,_ColdStartRetrysrc/langbot/pkg/utils/managed_runtime.pyβManagedRuntimeConnector(base class)
Transport Selection and Connection Setup#
PluginRuntimeConnector.initialize() selects a transport at startup based on runtime environment :
| Environment | Transport | Notes |
|---|---|---|
Docker / LANGBOT_USE_WS_PLUGIN | WebSocket | Connects to ws://langbot_plugin_runtime:5400/control/ws (configurable) |
| Windows | WebSocket | Runtime launched via cmd, then connected over ws://localhost:5400/control/ws |
| Linux/macOS | stdio | Runtime spawned via python -m langbot_plugin.cli.__init__ rt -s |
Auto-reconnect on WebSocket disconnects is possible; stdio mode requires a manual restart .
For MCP sessions, RuntimeMCPSession._lifecycle_loop() dispatches to a transport-specific initializer based on server_config['mode'] :
| Mode | Transport | Notes |
|---|---|---|
stdio | Box WebSocket relay | Managed subprocess in Box sandbox |
remote | Streamable HTTP β SSE fallback | Tries HTTP first; falls back to SSE on 400/404/405 |
sse / http | SSE or Streamable HTTP | Explicit override |
Heartbeat Monitoring#
Plugin runtime: heartbeat_loop() pings the plugin runtime every 20 seconds . Failures are logged at debug level but do not trigger reconnection β only transport-level disconnects invoke the runtime_disconnect_callback. The heartbeat task is cancelled in dispose() . This was introduced in PR #1698 to provide baseline visibility into runtime health .
MCP sessions (Box stdio): monitor_process_health() polls the managed Box process status every 5 seconds. It tolerates up to 3 consecutive polling errors before declaring the process gone and signaling the lifecycle loop. Concurrent with _shutdown_event.wait() via asyncio.wait(return_when=FIRST_COMPLETED), the lifecycle loop inspects whether the process is still alive when the monitor returns .
Distinguishing Process Crashes from Transient Issues#
The central design challenge is separating a dead process (requires full rebuild) from a live process with a broken transport (requires only reconnect). LangBot uses three special exception types that route around the fatal retry budget entirely :
Failure detected
β
βββ Is the managed process still running?
β β
β βββ YES + transport/WS dropped β raise _TransportReconnect
β β βββ Re-attach transport with 1s sleep, no retry budget consumed
β β
β βββ YES + handshake not answered (cold start) β raise _ColdStartRetry
β β βββ Wait 2s, retry handshake, reuse same process
β β
β βββ NO β fatal error β exponential backoff retry (max 3, delays 2/4/8s)
β
βββ Server session expired β raise _CallerReconnect
βββ Rebuild MCP session, wake all waiting callers via shared _reconnected_event
-
_TransportReconnect: Raised whenmonitor_process_health()signals exit but the Box process is still running β i.e., only the WebSocket relay dropped._managed_process_is_running()confirms liveness. The_preserve_managed_processflag prevents cleanup from killing the healthy process. -
_ColdStartRetry: Raised duringBoxStdioSessionRuntime.initialize()when the handshake fails but the process is alive. Designed for slow npm/npx cold starts where package installation can take 20β30 seconds._managed_process_has_exited()is the authoritative check β transient API errors are treated as "still coming up", not exited. -
_CallerReconnect: Raised wheninvoke_mcp_tool()orread_resource_envelope()encounters a server-side session expiry ("session terminated"/"session expired"). Multiple concurrent callers share a single_reconnected_eventto avoid thundering-herd reconnects.
BOX_UNAVAILABLE short-circuits all retries immediately since Box is disabled at the config level β retrying is pointless .
Retry Budget and Reconnection Logic#
MCP session retries are managed by _lifecycle_loop_with_retry() :
- Fatal retry budget:
_MAX_RETRIES = 3(4 total attempts), delays[2, 4, 8]seconds - Out-of-budget exceptions:
_TransportReconnect,_ColdStartRetry,_CallerReconnectβ these reset status toCONNECTINGand loop without incrementing the attempt counter - Startup timeout:
_ready_eventis awaited with a 30s timeout for remote/SSE/HTTP sessions; Box stdio usesbox_config.startup_timeout_sec + 30
Plugin runtime reconnection follows a simpler model :
- On Docker/WebSocket: transport-level disconnect invokes
runtime_disconnect_callback, which retries the full connection - On stdio: disconnect logs an error and requires a manual restart (no auto-reconnect, since restarting a stdio subprocess safely requires human intervention)
Caller-initiated reconnect for session expiry :
_trigger_reconnect()sets_reconnect_eventand creates_reconnected_event- Lifecycle loop detects
_reconnect_event, rebuilds the session, then sets_reconnected_event - Callers wait up to 30 seconds (
_RECONNECT_WAIT_TIMEOUT) for reconnection
Timeout and Hang Prevention#
A hung MCP server with no per-call timeout can lock the entire session. With concurrency.session: 1, a single blocking tool call drops all subsequent messages β this caused a 9-hour production outage .
PR #2344 addressed this by wrapping session.call_tool() in asyncio.timeout(30) :
TimeoutErroris caught before the generic exception handler so it is not retried (a hung server won't recover in 30s)- Raises:
"MCP tool '<name>' on server '<server>' timed out after 30 seconds"
Session-expiry timeout: _trigger_reconnect() waits up to 30s (_RECONNECT_WAIT_TIMEOUT) for the lifecycle loop to rebuild the session .
Startup timeout: start() blocks on _ready_event with bounded wait β callers don't hang indefinitely waiting for a never-connecting server .
Plugin install readiness: _wait_for_installed_plugin_ready() polls for status == 'initialized' for up to 30 seconds before declaring the install failed, preventing silent failures when dependency installation crashes the plugin process .
Error Phase Tracking and Diagnostics#
MCPSessionErrorPhase tags which lifecycle stage failed, surfaced via get_runtime_info_dict() in the admin UI:
| Phase | Trigger |
|---|---|
session_create | Box session creation failed |
dep_install | Dependency installation failed |
process_start | Managed process failed to start |
relay_connect | WebSocket relay connection failed |
mcp_init | MCP protocol handshake failed |
runtime | Process exited after successful init |
tool_call | Error during a tool call |
box_unavailable | Box disabled/unreachable (no retry) |
For the plugin runtime itself, PluginRuntimeNotConnectedError is raised when operations are requested before connection is established . Use get_debug_info() to retrieve the runtime's debug key and WebSocket URL for troubleshooting .
Key PRs and Change History#
| PR | Summary |
|---|---|
| #1698 | Added 20s heartbeat loop to PluginRuntimeConnector; improved WS failure logging |
| #2303 | _TransportReconnect: survive transient WS drops on live Box stdio processes without consuming retry budget |
| #2306 | _ColdStartRetry: in-place handshake retry for slow npx cold starts |
| #2307 | Fixed anyio lexical exit-stack context loss introduced by #2306; _ColdStartRetry moved to outer lifecycle loop |
| #2340 | _CallerReconnect: rebuild session on server-side expiry; shared _reconnected_event prevents thundering herd |
| #2344 | Wrapped call_tool() in asyncio.timeout(30) to prevent indefinite hangs; TimeoutError is not retried |