From fa838b00634a18e1ccfff0919c11484d092749b4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 14 Mar 2026 14:12:05 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20clean=20shutdown=20=E2=80=94=20silence?= =?UTF-8?q?=20MCP=20async-generator=20teardown=20noise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/timmy/voice_loop.py | 64 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/timmy/voice_loop.py b/src/timmy/voice_loop.py index 644e213..c66053a 100644 --- a/src/timmy/voice_loop.py +++ b/src/timmy/voice_loop.py @@ -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