fix: clean shutdown — silence MCP async-generator teardown noise

Swallow anyio cancel-scope RuntimeError and BaseExceptionGroup
from MCP stdio_client generators during GC on voice loop exit.
Custom unraisablehook + loop exception handler + warnings filter.
This commit is contained in:
2026-03-14 14:12:05 -04:00
parent 782218aa2c
commit fa838b0063

View File

@@ -374,9 +374,9 @@ class VoiceLoop:
self._ensure_piper()
# Suppress MCP / Agno stderr noise during voice mode.
# The "Secure MCP Filesystem Server running on stdio" messages
# are distracting in a voice session.
_suppress_mcp_noise()
# Suppress MCP async-generator teardown tracebacks on exit.
_install_quiet_asyncgen_hooks()
tts_label = (
"macOS say"
@@ -450,14 +450,35 @@ class VoiceLoop:
self._cleanup_loop()
def _cleanup_loop(self) -> None:
"""Shut down the persistent event loop cleanly."""
if self._loop is not None and not self._loop.is_closed():
"""Shut down the persistent event loop cleanly.
Agno's MCP stdio sessions leave async generators (stdio_client)
that complain loudly when torn down from a different task.
We swallow those errors — they're harmless, the subprocesses
die with the loop anyway.
"""
if self._loop is None or self._loop.is_closed():
return
# Silence "error during closing of asynchronous generator" warnings
# from MCP's anyio/asyncio cancel-scope teardown.
import warnings
self._loop.set_exception_handler(lambda loop, ctx: None)
try:
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
self._loop.close()
except Exception:
pass
self._loop.close()
self._loop = None
self._loop = None
def stop(self) -> None:
"""Stop the voice loop (from another thread)."""
@@ -480,3 +501,32 @@ def _suppress_mcp_noise() -> None:
"httpcore",
):
logging.getLogger(name).setLevel(logging.WARNING)
def _install_quiet_asyncgen_hooks() -> None:
"""Silence MCP stdio_client async-generator teardown noise.
When the voice loop exits, Python GC finalizes Agno's MCP
stdio_client async generators. anyio's cancel-scope teardown
prints ugly tracebacks to stderr. These are harmless — the
MCP subprocesses die with the loop. We intercept them here.
"""
_orig_hook = getattr(sys, "unraisablehook", None)
def _quiet_hook(args):
# Swallow RuntimeError from anyio cancel-scope teardown
# and BaseExceptionGroup from MCP stdio_client generators
if args.exc_type in (RuntimeError, BaseExceptionGroup):
msg = str(args.exc_value) if args.exc_value else ""
if "cancel scope" in msg or "unhandled errors" in msg:
return
# Also swallow GeneratorExit from stdio_client
if args.exc_type is GeneratorExit:
return
# Everything else: forward to original hook
if _orig_hook:
_orig_hook(args)
else:
sys.__unraisablehook__(args)
sys.unraisablehook = _quiet_hook