Files
VoiceStudio/tests/test_mcp_startup_timeout.py
d8b059813a fix(startup): timeout-bound MCP session-manager start to stop M1 startup hang (#632) (#645)
* fix(startup): timeout-bound MCP session-manager start to stop M1 hang (#632)

A reporter's faulthandler thread dump showed the asyncio loop alive but the
lifespan suspended at an await with an idle pool worker + a leaked semaphore —
the MCP Streamable-HTTP session manager hanging on its anyio task group during
startup (Apple-Silicon M1). Because `enter_async_context(_sm.run())` is awaited
before yield, the hang meant 'Application startup complete' never fired and the
backend was unreachable with no error — a P0 (default feature dead on a platform).

The MCP layer is explicitly best-effort, but the old guard only caught
exceptions, not hangs. Bound the start with asyncio.wait_for
(OMNIVOICE_MCP_START_TIMEOUT_S, default 30s): a hang → logged warning + backend
serves without MCP. Extracted _enter_mcp_session_manager + _mcp_start_timeout_s;
4 regression tests (hang→False fast, healthy→True, None→noop, env override). No
version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(startup): run MCP in its own task (anyio task-affinity) — fix CI cancel-scope error

The first attempt wrapped enter_async_context in wait_for, which entered the MCP
anyio task group in a throwaway sub-task while the AsyncExitStack exited it on the
lifespan task → 'Attempted to exit cancel scope in a different task' (caught by
test_coverage_critic's real backend boot). Correct fix: _serve_mcp owns the full
enter→exit in ONE task; _start_mcp_session_manager only waits (with timeout) on a
ready Event. A hang still can't block startup, and enter/exit share a task.
Shutdown signals stop + bounded-awaits the task. Tests updated (5; incl broken-
manager case). test_coverage_critic now boots+shuts down clean.

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:49:39 +05:30

84 lines
2.7 KiB
Python

"""MCP session-manager start must never wedge backend startup (#632).
On Apple-Silicon M1 the FastMCP Streamable-HTTP session manager could *hang* on
its anyio task group during lifespan startup. Because that enter was awaited
before `yield`, the hang meant "Application startup complete" never fired and the
whole backend was unreachable with no error. MCP now runs in its own task (it
owns the anyio enter→exit itself — task-affinity) and startup only *optionally*
waits, with a timeout, on a ready signal: a hang → a logged warning + a backend
that still serves without MCP.
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
from main import _start_mcp_session_manager, _mcp_start_timeout_s # noqa: E402
class _CM:
def __init__(self, hang, raise_on_enter=False):
self.hang = hang
self.raise_on_enter = raise_on_enter
async def __aenter__(self):
if self.raise_on_enter:
raise RuntimeError("boom")
if self.hang:
await asyncio.sleep(60) # never completes within the test timeout
return self
async def __aexit__(self, *a):
return False
class _SM:
def __init__(self, hang=False, raise_on_enter=False):
self._cm = _CM(hang, raise_on_enter)
def run(self):
return self._cm
def _drive(sm, timeout):
"""Run _start_mcp_session_manager, then clean up the task; return `mounted`."""
async def go():
task, stop, mounted = await _start_mcp_session_manager(sm, timeout=timeout)
stop.set()
if task is not None:
task.cancel()
try:
await task
except BaseException:
pass
return mounted
return asyncio.run(go())
def test_hang_does_not_block_startup():
# The crux: a hanging manager returns fast with mounted=False (no raise).
assert _drive(_SM(hang=True), 0.2) is False
def test_healthy_manager_mounts():
assert _drive(_SM(hang=False), 5.0) is True
def test_broken_manager_is_not_mounted():
# An exception during enter → not mounted, startup still proceeds.
assert _drive(_SM(raise_on_enter=True), 5.0) is False
def test_none_manager_is_noop():
assert _drive(None, 5.0) is False
def test_timeout_env_override(monkeypatch):
monkeypatch.setenv("OMNIVOICE_MCP_START_TIMEOUT_S", "12.5")
assert _mcp_start_timeout_s() == 12.5
monkeypatch.delenv("OMNIVOICE_MCP_START_TIMEOUT_S", raising=False)
assert _mcp_start_timeout_s() == 30.0
monkeypatch.setenv("OMNIVOICE_MCP_START_TIMEOUT_S", "garbage")
assert _mcp_start_timeout_s() == 30.0 # invalid → safe default