{"repo":"NousResearch/hermes-agent","public_id":"37011","display_label":"Gateway reconnect loop file descriptor leak patches","summary":"A bug report and multiple patch attempts addressing file descriptor leaks in the gateway's reconnect loop, where adapter resources and ResponseStore connections are not properly closed on unsuccessful reconnects, causing gateway instability after extended uptime.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":37679},"resolution":{"state":"resolved","by":"pr-37679"},"freshness":{"state":"verified","last_verified_at":"2026-06-03T00:27:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":37011,"pr_number":37679,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":37011,"title":"[Bug]: File descriptor leak in platform reconnect loop — adapter sqlite3 connections never closed on failed reconnect - gateway dead after ~12h uptime","url":null,"state":"closed"},{"kind":"pr","number":37018,"title":"fix(gateway): close ResponseStore on disconnect and clean up failed reconnect adapters","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe4f4686f89873933ac616cb6e76b298d","number":0},{"kind":"pr","value":"#37018","number":37018}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37053,"title":"fix(gateway): close adapter resources on failed reconnect to prevent fd leak","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe","number":0},{"kind":"pr","value":"#37011","number":37011},{"kind":"pr","value":"#37011","number":37011}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37260,"title":"fix(gateway): close leaked adapter fds in reconnect loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe4f4686f89873933ac616cb6e76b298d","number":0},{"kind":"pr","value":"#37260","number":37260}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37394,"title":"fix(gateway): close adapter resources on failed reconnect to prevent fd leak","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b06c98fe4f4686f89873933ac616cb6e76b298d","number":0},{"kind":"pr","value":"#37011","number":37011},{"kind":"pr","value":"#37394","number":37394}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37679,"title":"fix(gateway): close ResponseStore + dispose unowned adapter on reconnect failure","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","gateway/run.py","scripts/release.py","tests/gateway/test_platform_reconnect_fd_leak.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -4195,8 +4195,25 @@ async def connect(self) -> bool:\n             return False\n \n     async def disconnect(self) -> None:\n-        \"\"\"Stop the aiohttp web server.\"\"\"\n+        \"\"\"Stop the aiohttp web server and release all owned resources.\n+\n+        Closes the ResponseStore SQLite connection in addition to stopping\n+        the aiohttp web server. Without this, every adapter instance leaks\n+        2 file descriptors (the database file and its WAL sidecar) — the\n+        reconnect loop in ``gateway.run`` constructs a fresh adapter on\n+        every retry, so 2 fds/retry × 300s backoff cap ≈ 12 fds/hour, which\n+        exhausts the default 2560 fd limit after ~12h of failed reconnects\n+        and turns the whole gateway into a zombie\n+        (OSError: [Errno 24] Too many open files, #37011).\n+        \"\"\"\n         self._mark_disconnected()\n+        if self._response_store is not None:\n+            try:\n+                self._response_store.close()\n+            except Exception:\n+                logger.debug(\n+                    \"Failed to close response store for %s\", self.name, exc_info=True,\n+                )\n         if self._site:\n             await self._site.stop()\n             self._site = None"]},{"file":"gateway/run.py","hunks":["@@ -1755,6 +1755,60 @@ def _preserve_queued_followup_history_offset(\n     return merged\n \n \n+async def _dispose_unused_adapter(adapter: \"BasePlatformAdapter | None\") -> None:\n+    \"\"\"Best-effort dispose for an adapter that never made it onto ``self.adapters``.\n+\n+    The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``\n+    constructs a fresh adapter on every retry attempt. When the connect\n+    call fails — for any of the three reasons (non-retryable error,\n+    retryable error, exception during connect) — the adapter is dropped\n+    without ever being installed, so nothing else will call its\n+    ``disconnect()``. Any resources the adapter opened in ``__init__``\n+    (e.g. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that\n+    holds 2 fds — the db file and its WAL sidecar) stay open until\n+    garbage collection sweeps the unreachable object, which Python's\n+    cyclic GC does not do promptly for asyncio-bound objects with\n+    native handles. The cumulative leak is 2 fds × every retry at the\n+    300s backoff cap ≈ 12 fds/hour, and the default 2560-fd ulimit\n+    is exhausted in ~12h of continuous failure, after which every\n+    open() call on the gateway raises ``OSError: [Errno 24] Too many\n+    open files`` and the gateway becomes a zombie (#37011).\n+\n+    This helper centralises the dispose-with-suppression so the three\n+    failure paths in the reconnect watcher can all call it without\n+    each one having to know that ``disconnect()`` may itself raise\n+    on a half-constructed adapter.\n+\n+    ``adapter`` may be ``None``: the reconnect watcher initialises\n+    ``adapter = None`` before the ``try`` so the ``except Exception``\n+    arm can dispose a half-constructed object, and also early-returns\n+    here when ``_create_adapter()`` returned ``None``.\n+    \"\"\"\n+    if adapter is None:\n+        return\n+    try:\n+        await adapter.disconnect()\n+    except Exception:\n+        # Half-constructed adapters (e.g. APIServerAdapter that\n+        # crashed during aiohttp app setup) can raise from\n+        # disconnect() on objects that never finished initializing.\n+        # We must not let that escape and abort the watcher loop.\n+        #\n+        # On Python 3.8+, ``asyncio.CancelledError`` inherits from\n+        # ``BaseException`` (not ``Exception``), so this ``except\n+        # Exception`` does not swallow task cancellation. We don't\n+        # re-raise explicitly because the watcher loop intentionally\n+        # treats dispose failures as best-effort: a failed ``disconnect``\n+        # call should not take down the reconnect watcher that\n+        # itself is what's keeping the gateway alive during a partial\n+        # outage.\n+        logger.debug(\n+            \"Adapter dispose raised on unowned adapter %r\",\n+            getattr(adapter, \"name\", type(adapter).__name__),\n+            exc_info=True,\n+        )\n+\n+\n class GatewayRunner:\n     \"\"\"\n     Main gateway controller.","@@ -6175,6 +6229,7 @@ async def _platform_reconnect_watcher(self) -> None:\n                     platform.value, attempt,\n                 )\n \n+                adapter = None\n                 try:\n                     adapter = self._create_adapter(platform, platform_config)\n                     if not adapter:","@@ -6224,6 +6279,15 @@ async def _platform_reconnect_watcher(self) -> None:\n                             \"Reconnect %s: non-retryable error (%s), removing from retry queue\",\n                             platform.value, adapter.fatal_error_message,\n                         )\n+                        # The adapter is about to be dropped from the queue\n+                        # without ever being installed on self.adapters, so\n+                        # nothing else will call disconnect() on it. We must\n+                        # dispose it here, otherwise the resource owners it\n+                        # constructed in __init__ (ResponseStore for\n+                        # APIServerAdapter, etc.) leak 2 fds each. The\n+                        # gateway hits the 2560-fd limit after ~12h of\n+                        # failed reconnects at the 300s backoff cap (#37011).\n+                        await _dispose_unused_adapter(adapter)\n                         del self._failed_platforms[platform]\n                     else:\n                         self._update_platform_runtime_status(","@@ -6239,6 +6303,14 @@ async def _platform_reconnect_watcher(self) -> None:\n                             \"Reconnect %s failed, next retry in %ds\",\n                             platform.value, backoff,\n                         )\n+                        # Same fd-leak concern as the non-retryable branch\n+                        # above: the adapter failed to connect and is being\n+                        # thrown away. Without an explicit dispose call, the\n+                        # resources it opened in __init__ stay open until\n+                        # the next GC pass — and aiohttp/SQLite handles\n+                        # don't get GC'd promptly, so 2 fds/retry leak at\n+                        # 300s backoff cap = ~12 fds/hour (#37011).\n+                        await _dispose_unused_adapter(adapter)\n                         # Retryable failures (network/DNS blips) keep retrying\n                         # at the backoff cap indefinitely — they self-heal once\n                         # connectivity returns. We do NOT auto-pause them: a","@@ -6248,6 +6320,14 @@ async def _platform_reconnect_watcher(self) -> None:\n                         # `not fatal_error_retryable` branch above, so anything\n                         # reaching here is by definition retryable.\n                 except Exception as e:\n+                    if adapter is not None:\n+                        # An exception escaping the connect call path\n+                        # (DNS timeout, aiohttp server.start() crash, etc.)\n+                        # leaves the adapter in the same unowned state as\n+                        # the two branches above. Dispose so __init__\n+                        # resources don't accumulate while the watcher\n+                        # keeps retrying.\n+                        await _dispose_unused_adapter(adapter)\n                     self._update_platform_runtime_status(\n                         platform.value,\n                         platform_state=\"retrying\","]},{"file":"scripts/release.py","hunks":["@@ -373,6 +373,7 @@\n     \"[redacted:email]\": \"counterposition\",\n     \"[redacted:email]\": \"nidhi-singh02\",\n     \"[redacted:email]\": \"Fearvox\",\n+    \"[redacted:email]\": \"Fearvox\",\n     \"[redacted:email]\": \"Hypnus-Yuan\",\n     \"[redacted:email]\": \"xsfX20\",\n     \"[redacted:email]\": \"binhnt92\","]},{"file":"tests/gateway/test_platform_reconnect_fd_leak.py","hunks":["@@ -0,0 +1,350 @@\n+\"\"\"Regression tests for the gateway platform fd-leak fix (#37011).\n+\n+Without an explicit ``disconnect()`` on adapters that fail to connect in\n+the reconnect watcher, every retry leaks the resources the adapter\n+opened in ``__init__`` — for ``APIServerAdapter`` that means 2 file\n+descriptors per attempt (the SQLite ``response_store.db`` and its WAL\n+sidecar). At the 300s backoff cap that's ~12 fds/hour; the default\n+2560-fd ulimit is exhausted in ~12h of continuous failure, after which\n+the gateway raises ``OSError: [Errno 24] Too many open files`` on\n+every ``open()`` and becomes a zombie.\n+\n+These tests pin all three failure paths in\n+``_platform_reconnect_watcher`` (non-retryable error, retryable error,\n+exception during connect) to call ``adapter.disconnect()`` on the\n+unowned adapter, plus the path-level ``APIServerAdapter.disconnect()``\n+behavior of also closing the ``ResponseStore``. The pre-fix\n+implementation did not call ``disconnect()`` on any of these paths;\n+this file would have caught the regression and now pins the fix.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import time\n+from typing import Any\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.api_server import APIServerAdapter, ResponseStore\n+from gateway.platforms.base import BasePlatformAdapter, SendResult\n+from gateway.run import GatewayRunner, _dispose_unused_adapter\n+\n+\n+def _make_runner() -> GatewayRunner:\n+    \"\"\"Create a minimal GatewayRunner via object.__new__ to skip __init__.\n+\n+    Mirrors the helper in test_platform_reconnect.py so this file\n+    is drop-in compatible with the existing reconnect test suite.\n+    \"\"\"\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"test\")}\n+    )\n+    runner._running = True\n+    runner._shutdown_event = asyncio.Event()\n+    runner._exit_reason = None\n+    runner._exit_with_failure = False\n+    runner._exit_cleanly = False\n+    runner._failed_platforms = {}\n+    runner.adapters = {}\n+    return runner\n+\n+\n+async def _run_watcher_one_iteration(runner: GatewayRunner) -> None:\n+    \"\"\"Drive ``_platform_reconnect_watcher`` for exactly one retry pass.\n+\n+    Patches ``asyncio.sleep`` to advance the watcher's internal\n+    ``await asyncio.sleep(10)`` initial delay and the 1-second inner\n+    sleeps without actually waiting. Mirrors the pattern used in\n+    ``test_platform_reconnect.py::TestPlatformReconnectWatcher``.\n+    \"\"\"\n+    real_sleep = asyncio.sleep\n+    call_count = 0\n+\n+    async def fake_sleep(_n: float) -> None:\n+        nonlocal call_count\n+        call_count += 1\n+        if call_count > 2:\n+            # Two sleeps is enough to get past the initial 10s wait\n+            # and the first inner-tick check. After that, stop the\n+            # watcher so the test returns.\n+            runner._running = False\n+        await real_sleep(0)\n+\n+    with patch(\"asyncio.sleep\", side_effect=fake_sleep):\n+        await runner._platform_reconnect_watcher()\n+\n+\n+class _CountingAdapter(BasePlatformAdapter):\n+    \"\"\"Adapter that records every disconnect() call for fd-leak assertions.\n+\n+    The base ``BasePlatformAdapter.disconnect()`` is a no-op by default\n+    for stub adapters, which is why the pre-fix reconnect watcher\n+    silently leaked: the would-be dispose calls were happening on\n+    objects that did nothing on disconnect. This stub mimics the real\n+    ``APIServerAdapter`` shape — every constructor call opens 2 fds\n+    (the SQLite db + WAL), and every disconnect() must close them.\n+    \"\"\"\n+\n+    def __init__(self, *, succeed: bool = False, fatal_error: str | None = None,\n+                 fatal_retryable: bool = True, raise_during_connect: bool = False):\n+        super().__init__(PlatformConfig(enabled=True, token=\"t\"), Platform.TELEGRAM)\n+        # 2 fds to track: the canonical \"ResponseStore\" pair. The\n+        # reconnect watcher should call disconnect() once per\n+        # construction; otherwise these stay open and contribute to\n+        # the gateway-wide fd count.\n+        self._open_fds = 2\n+        self._disconnect_calls = 0\n+        self._succeed = succeed\n+        self._fatal_error = fatal_error\n+        self._fatal_retryable = fatal_retryable\n+        self._raise_during_connect = raise_during_connect\n+\n+    async def connect(self) -> bool:\n+        if self._raise_during_connect:\n+            raise RuntimeError(\"simulated connect exception\")\n+        if self._fatal_error:\n+            self._set_fatal_error(\n+                \"test_code\", self._fatal_error, retryable=self._fatal_retryable,\n+            )\n+            return False\n+        return self._succeed\n+\n+    async def disconnect(self) -> None:\n+        self._disconnect_calls += 1\n+        self._open_fds = 0  # fd release on dispose\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None):\n+        return SendResult(success=True, message_id=\"1\")\n+\n+    async def send_typing(self, chat_id, metadata=None):\n+        return None\n+\n+    async def get_chat_info(self, chat_id):\n+        return {\"id\": chat_id}\n+\n+\n+def _seed_runner_with_one_failure(runner: GatewayRunner) -> None:\n+    \"\"\"Queue a single platform for the reconnect watcher to pick up.\"\"\"\n+    runner._failed_platforms[Platform.TELEGRAM] = {\n+        \"config\": PlatformConfig(enabled=True, token=\"t\"),\n+        \"attempts\": 0,\n+        \"next_retry\": time.monotonic() - 1,  # eligible immediately\n+    }\n+\n+\n+class TestReconnectFDLeakRegression:\n+    \"\"\"All three reconnect failure paths must dispose the unowned adapter.\n+\n+    The pre-fix implementation constructed a fresh adapter on every\n+    retry and dropped it on the floor when connect() failed. That leaks\n+    2 fds per retry (for ``APIServerAdapter``) at the 300s backoff cap,\n+    exhausting the 2560-fd ulimit in ~12h of continuous failure (#37011).\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_nonretryable_failure_disposes_unowned_adapter(self):\n+        \"\"\"A fatal error (bad auth, etc.) must call disconnect() exactly once.\n+\n+        The adapter failed to connect and is being removed from the\n+        retry queue. Nothing else owns it, so the watcher is the only\n+        code with a chance to call disconnect() — and disconnect() is\n+        the only place the SQLite fds get closed. One retry, one\n+        dispose, no leak.\n+        \"\"\"\n+        runner = _make_runner()\n+        _seed_runner_with_one_failure(runner)\n+        adapter = _CountingAdapter(\n+            succeed=False, fatal_error=\"bad token\", fatal_retryable=False,\n+        )\n+        with patch.object(runner, \"_create_adapter\", return_value=adapter), \\\n+             patch.object(runner, \"_connect_adapter_with_timeout\",\n+                          new=AsyncMock(return_value=False)):\n+            await _run_watcher_one_iteration(runner)\n+\n+        # The intent of this test is \"the watcher calls disconnect()\n+        # exactly once on the unowned adapter\" — not \"at least once\".\n+        # An accidental double-dispose would be a new bug to catch\n+        # (e.g. the watcher's two failure paths both calling dispose\n+        # for the same adapter instance). Tighten to == 1.\n+        assert adapter._disconnect_calls == 1, (\n+            f\"non-retryable reconnect failure must call adapter.disconnect() \"\n+            f\"exactly once; got {adapter._disconnect_calls} calls. \"\n+            \"Without it, 2 fds leak per retry at the 300s backoff cap \"\n+            \"(#37011). More than one call would also be a bug — the \"\n+            \"adapter has already been disposed once, a second call is \"\n+            \"wasted work and may itself raise.\"\n+        )\n+        assert adapter._open_fds == 0, (\n+            f\"adapter fds not released after disconnect(); \"\n+            f\"{adapter._open_fds} still open. This is the fd leak #37011.\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_retryable_failure_disposes_unowned_adapter(self):\n+        \"\"\"A retryable failure (network blip) must also call disconnect().\n+\n+        This is the path that fires most often in production: a\n+        transient DNS resolution failure or upstream outage, which\n+        back-offs to 300s and retries indefinitely. The watcher\n+        tracks ``info[\"attempts\"]`` and reschedules, but the failed\n+        adapter is still dropped on the floor without dispose.\n+        \"\"\"\n+        runner = _make_runner()\n+        _seed_runner_with_one_failure(runner)\n+        adapter = _CountingAdapter(\n+            succeed=False, fatal_error=\"dns timeout\", fatal_retryable=True,\n+        )\n+        with patch.object(runner, \"_create_adapter\", return_value=adapter), \\\n+             patch.object(runner, \"_connect_adapter_with_timeout\",\n+                          new=AsyncMock(return_value=False)):\n+            await _run_watcher_one_iteration(runner)\n+\n+        assert adapter._disconnect_calls >= 1, (\n+            f\"retryable reconnect failure must call adapter.disconnect(); \"\n+            f\"got {adapter._disconnect_calls} calls. This is the hot path \"\n+            \"for the fd leak in #37011.\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_exception_during_connect_disposes_unowned_adapter(self):\n+        \"\"\"An exception escaping connect() (aiohttp start crash, etc.) disposes.\n+\n+        The ``except Exception`` arm in the watcher used to skip the\n+        dispose call entirely. Pre-fix, this leaked the same 2 fds\n+        per retry as the other two branches.\n+        \"\"\"\n+        runner = _make_runner()\n+        _seed_runner_with_one_failure(runner)\n+        adapter = _CountingAdapter(raise_during_connect=True)\n+        with patch.object(runner, \"_create_adapter\", return_value=adapter), \\\n+             patch.object(runner, \"_connect_adapter_with_timeout\",\n+                          new=AsyncMock(side_effect=RuntimeError(\"boom\"))):\n+            await _run_watcher_one_iteration(runner)\n+\n+        assert adapter._disconnect_calls >= 1, (\n+            f\"exception-during-connect must call adapter.disconnect(); \"\n+            f\"got {adapter._disconnect_calls} calls. The except-arm of the \"\n+            \"reconnect watcher is one of the three leak paths in #37011.\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_dispose_helper_handles_none(self):\n+        \"\"\"``_dispose_unused_adapter(None)`` is a no-op (defensive).\"\"\"\n+        await _dispose_unused_adapter(None)  # must not raise\n+\n+    @pytest.mark.asyncio\n+    async def test_dispose_helper_swallows_disconnect_exception(self):\n+        \"\"\"A disconnect() that itself raises must not abort the watcher loop.\n+\n+        Half-constructed adapters can raise from disconnect() because\n+        some of their __init__ state is missing. The watcher loop\n+        would then die and stop retrying, masking the original\n+        configuration error as a hard crash.\n+        \"\"\"\n+        disconnect_calls = 0\n+\n+        class _RaisingAdapter(BasePlatformAdapter):\n+            def __init__(self):\n+                super().__init__(\n+                    PlatformConfig(enabled=True, token=\"t\"),\n+                    Platform.TELEGRAM,\n+                )\n+\n+            async def connect(self) -> bool:\n+                return True\n+\n+            async def disconnect(self) -> None:\n+                nonlocal disconnect_calls\n+                disconnect_calls += 1\n+                raise RuntimeError(\"half-constructed; aiohttp app never started\")\n+\n+            async def send(self, chat_id, content, reply_to=None, metadata=None):\n+                return SendResult(success=True, message_id=\"1\")\n+\n+            async def send_typing(self, chat_id, metadata=None):\n+                return None\n+\n+            async def get_chat_info(self, chat_id):\n+                return {\"id\": chat_id}\n+\n+        await _dispose_unused_adapter(_RaisingAdapter())  # must not raise\n+        assert disconnect_calls == 1\n+\n+\n+class TestAPIServerDisconnectClosesResponseStore:\n+    \"\"\"The platform-level fix: ``APIServerAdapter.disconnect()`` must close its ResponseStore.\n+\n+    Without this, the reconnect watcher's dispose call (see the\n+    test class above) is a no-op for ``APIServerAdapter`` — the\n+    aiohttp web server stops, but the SQLite ``ResponseStore``\n+    connection stays open. The DB file plus its WAL sidecar = 2 fds,\n+    which is the headline leak in #37011.\n+    \"\"\"\n+\n+    def _build_adapter_with_store(self, store: ResponseStore) -> APIServerAdapter:\n+        \"\"\"Build an APIServerAdapter with the required internal state.\n+\n+        We bypass ``__init__`` (which would try to start aiohttp\n+        immediately) and set just the fields ``disconnect()`` reads.\n+        \"\"\"\n+        adapter = APIServerAdapter.__new__(APIServerAdapter)\n+        adapter._mark_disconnected = lambda: None  # type: ignore[method-assign]\n+        adapter._site = None\n+        adapter._runner = None\n+        adapter._app = None\n+        adapter._response_store = store\n+        adapter.platform = Platform.API_SERVER\n+        return adapter\n+\n+    @pytest.mark.asyncio\n+    async def test_disconnect_closes_response_store(self, tmp_path):\n+        \"\"\"Closing the adapter's ResponseStore releases its SQLite connection.\n+\n+        We point the ``ResponseStore`` at a tmp db so we can verify\n+        its ``close()`` is called by ``APIServerAdapter.disconnect()``.\n+        The real ``ResponseStore.__init__`` opens a SQLite connection\n+        to ``~/.hermes/response_store.db`` (or :memory: as a fallback),\n+        which is exactly the resource that was leaking pre-fix.\n+        \"\"\"\n+        import sqlite3\n+\n+        store = ResponseStore(max_size=10, db_path=str(tmp_path / \"rs.db\"))\n+        adapter = self._build_adapter_with_store(store)\n+\n+        await adapter.disconnect()\n+\n+        # Post-disconnect, the underlying sqlite3 conn should be closed.\n+        # sqlite3 raises ``ProgrammingError: Cannot operate on a closed\n+        # database`` for any further operation. We assert on the\n+        # specific exception type (not bare ``Exception``) so the test\n+        # only passes when the close actually took effect — a generic\n+        # ``Exception`` catcher would mask unrelated failures (env\n+        # issues, AttributeError, etc.).\n+        with pytest.raises(sqlite3.ProgrammingError):\n+            store._conn.execute(\"SELECT 1\").fetchone()\n+\n+    @pytest.mark.asyncio\n+    async def test_disconnect_swallows_response_store_close_exception(self, tmp_path):\n+        \"\"\"A misbehaving ResponseStore.close() must not abort adapter shutdown.\n+\n+        Real-world failure mode: the SQLite file was unlinked out\n+        from under us (operator rm'd ``response_store.db`` during a\n+        disk pressure event). ``close()`` raises. The watcher must\n+        continue with the aiohttp shutdown, not bail.\n+        \"\"\"\n+        store = ResponseStore(max_size=10, db_path=str(tmp_path / \"rs.db\"))\n+\n+        def _boom() -> None:\n+            raise RuntimeError(\"sqlite file vanished\")\n+\n+        store.close = _boom  # type: ignore[method-assign]\n+        adapter = self._build_adapter_with_store(store)\n+\n+        # Must not raise — disconnect() swallows the close error and\n+        # continues to the aiohttp teardown (no-op here since we\n+        # bypassed __init__).\n+        await adapter.disconnect()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}