fix(memory): close embedded Hindsight async client cleanly

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.
This commit is contained in:
maxims-oss
2026-04-26 12:53:53 -07:00
committed by Teknium
parent bf05b8f4a2
commit 18beb69b49
2 changed files with 32 additions and 3 deletions

View File

@@ -1231,9 +1231,19 @@ class HindsightMemoryProvider(MemoryProvider):
if self._client is not None: if self._client is not None:
try: try:
if self._mode == "local_embedded": if self._mode == "local_embedded":
# Use the public close() API. The RuntimeError from # HindsightEmbedded.close() delegates to its sync client.close().
# aiohttp's "attached to a different loop" is expected # When Hermes created/used that client on the shared async loop,
# and harmless — the daemon keeps running independently. # 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: try:
self._client.close() self._client.close()
except RuntimeError: except RuntimeError:

View File

@@ -1102,3 +1102,22 @@ class TestSharedEventLoopLifecycle:
mock_client.aclose.assert_called_once() mock_client.aclose.assert_called_once()
assert provider._client is None 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