forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user