From 18beb69b4996591cfae3233131e8dc37018f3423 Mon Sep 17 00:00:00 2001 From: maxims-oss Date: Sun, 26 Apr 2026 12:53:53 -0700 Subject: [PATCH] fix(memory): close embedded Hindsight async client cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HindsightEmbedded.close() delegates to its sync client.close(). When Hermes created/used that client on the shared async loop, closing it from the main thread raises 'attached to a different loop' before aiohttp releases the session — so the ClientSession / TCPConnector leak past provider teardown. Close the embedded inner async client on the shared loop first via _run_sync(inner_client.aclose()), then let the wrapper's sync close() do its daemon/UI bookkeeping. Salvage of #14605: test placement rebased — appended TestShutdown class after TestSharedEventLoopLifecycle (which landed on main after the PR was written). Original author attribution preserved. --- plugins/memory/hindsight/__init__.py | 16 +++++++++++++--- .../plugins/memory/test_hindsight_provider.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index bc82bc40..39dfe94f 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -1231,9 +1231,19 @@ class HindsightMemoryProvider(MemoryProvider): if self._client is not None: try: if self._mode == "local_embedded": - # Use the public close() API. The RuntimeError from - # aiohttp's "attached to a different loop" is expected - # and harmless — the daemon keeps running independently. + # HindsightEmbedded.close() delegates to its sync client.close(). + # When Hermes created/used that client on the shared async loop, + # closing it from this thread can raise "attached to a different + # loop" before aiohttp releases the session. Close the embedded + # inner async client on the shared loop first, then let the + # wrapper clean up daemon/UI bookkeeping. + inner_client = getattr(self._client, "_client", None) + if inner_client is not None and hasattr(inner_client, "aclose"): + _run_sync(inner_client.aclose()) + try: + self._client._client = None + except Exception: + pass try: self._client.close() except RuntimeError: diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 5f1290b2..2f123b6f 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -1102,3 +1102,22 @@ class TestSharedEventLoopLifecycle: mock_client.aclose.assert_called_once() assert provider._client is None + + +class TestShutdown: + def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self, provider): + inner_client = _make_mock_client() + embedded = MagicMock() + embedded._client = inner_client + embedded.close = MagicMock() + + provider._mode = "local_embedded" + provider._client = embedded + + provider.shutdown() + + inner_client.aclose.assert_awaited_once() + embedded.close.assert_called_once() + assert embedded._client is None + assert provider._client is None +