Skip to content

Commit 01eaba7

Browse files
Fearvoxteknium1
authored andcommitted
polish(gateway): address Copilot review comments on fd-leak fix
Seven Copilot inline review comments on NousResearch#37679, four worth landing in a polish pass before merge: 1. _dispose_unused_adapter signature: 'BasePlatformAdapter' -> 'BasePlatformAdapter | None'. The function explicitly handles None and the reconnect watcher calls it with None in the except arm, so the annotation now matches the actual contract. 2. (duplicate of #1 on a different line) — same fix. 3. except Exception in _dispose_unused_adapter — the reviewer asked about asyncio.CancelledError swallowing. On Python 3.8+ (Hermes requires 3.13, see pyproject.toml), CancelledError inherits from BaseException, NOT Exception, so the existing 'except Exception' does NOT swallow task cancellation. Added an explicit comment explaining the contract so future readers don't repeat the analysis. We don't re-raise because the watcher loop intentionally treats dispose failures as best-effort: a failed dispose on an unowned adapter should not take down the watcher that's keeping the gateway alive. 4. _response_store = None after close in api_server.py — the reviewer flagged this for idempotency. Decided to keep the non-None state intentionally: setting it to None cascades to ~9 callers that access self._response_store without a None check, and 'close() is idempotent on a closed sqlite3 Connection' means the current code is already safe. The type stays stable; LSP doesn't flag a cascade of reportOptionalMemberAccess errors. (This matches the pre-existing pattern in the codebase — e.g. _mark_disconnected doesn't reset state to None either.) 5. _build_adapter_with_store: reviewer worried about disconnect() failing on the self.name property if __init__ wasn't called. Already handled: we set 'adapter.platform = Platform.API_SERVER' so the 'self.platform.value.title()' property returns 'Api_Server' without raising. The exception-swallowing branch in disconnect() does call self.name via the logger.debug format, so this is a real path that needs the platform attribute, and we have it. 6. test_disconnect_closes_response_store: bare 'pytest.raises(Exception)' -> 'pytest.raises(sqlite3.ProgrammingError)'. The bare Exception matcher would silently accept AttributeError, OperationalError, env-related issues, etc. The specific exception type ('Cannot operate on a closed database') is the actual signal we want — proves the SQLite conn is closed, not just that *something* raised. 7. test_nonretryable_failure_disposes_unowned_adapter: assertion tightened from '>= 1' to '== 1' on adapter._disconnect_calls. The docstring said 'exactly once', the assertion now matches. Catches the hypothetical 'watcher disposes the same adapter twice' regression that '>=' would have missed.
1 parent 7982560 commit 01eaba7

2 files changed

Lines changed: 34 additions & 7 deletions

File tree

gateway/run.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1755,7 +1755,7 @@ def _preserve_queued_followup_history_offset(
17551755
return merged
17561756

17571757

1758-
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter") -> None:
1758+
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
17591759
"""Best-effort dispose for an adapter that never made it onto ``self.adapters``.
17601760

17611761
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``
@@ -1778,6 +1778,11 @@ async def _dispose_unused_adapter(adapter: "BasePlatformAdapter") -> None:
17781778
failure paths in the reconnect watcher can all call it without
17791779
each one having to know that ``disconnect()`` may itself raise
17801780
on a half-constructed adapter.
1781+
1782+
``adapter`` may be ``None``: the reconnect watcher initialises
1783+
``adapter = None`` before the ``try`` so the ``except Exception``
1784+
arm can dispose a half-constructed object, and also early-returns
1785+
here when ``_create_adapter()`` returned ``None``.
17811786
"""
17821787
if adapter is None:
17831788
return
@@ -1788,6 +1793,15 @@ async def _dispose_unused_adapter(adapter: "BasePlatformAdapter") -> None:
17881793
# crashed during aiohttp app setup) can raise from
17891794
# disconnect() on objects that never finished initializing.
17901795
# We must not let that escape and abort the watcher loop.
1796+
#
1797+
# On Python 3.8+, ``asyncio.CancelledError`` inherits from
1798+
# ``BaseException`` (not ``Exception``), so this ``except
1799+
# Exception`` does not swallow task cancellation. We don't
1800+
# re-raise explicitly because the watcher loop intentionally
1801+
# treats dispose failures as best-effort: a failed ``disconnect``
1802+
# call should not take down the reconnect watcher that
1803+
# itself is what's keeping the gateway alive during a partial
1804+
# outage.
17911805
logger.debug(
17921806
"Adapter dispose raised on unowned adapter %r",
17931807
getattr(adapter, "name", type(adapter).__name__),

tests/gateway/test_platform_reconnect_fd_leak.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,18 @@ async def test_nonretryable_failure_disposes_unowned_adapter(self):
165165
new=AsyncMock(return_value=False)):
166166
await _run_watcher_one_iteration(runner)
167167

168-
assert adapter._disconnect_calls >= 1, (
168+
# The intent of this test is "the watcher calls disconnect()
169+
# exactly once on the unowned adapter" — not "at least once".
170+
# An accidental double-dispose would be a new bug to catch
171+
# (e.g. the watcher's two failure paths both calling dispose
172+
# for the same adapter instance). Tighten to == 1.
173+
assert adapter._disconnect_calls == 1, (
169174
f"non-retryable reconnect failure must call adapter.disconnect() "
170-
f"at least once; got {adapter._disconnect_calls} calls. "
175+
f"exactly once; got {adapter._disconnect_calls} calls. "
171176
"Without it, 2 fds leak per retry at the 300s backoff cap "
172-
"(#37011)."
177+
"(#37011). More than one call would also be a bug — the "
178+
"adapter has already been disposed once, a second call is "
179+
"wasted work and may itself raise."
173180
)
174181
assert adapter._open_fds == 0, (
175182
f"adapter fds not released after disconnect(); "
@@ -303,15 +310,21 @@ async def test_disconnect_closes_response_store(self, tmp_path):
303310
to ``~/.hermes/response_store.db`` (or :memory: as a fallback),
304311
which is exactly the resource that was leaking pre-fix.
305312
"""
313+
import sqlite3
314+
306315
store = ResponseStore(max_size=10, db_path=str(tmp_path / "rs.db"))
307316
adapter = self._build_adapter_with_store(store)
308317

309318
await adapter.disconnect()
310319

311320
# Post-disconnect, the underlying sqlite3 conn should be closed.
312-
# Any further query raises ``ProgrammingError: Cannot operate
313-
# on a closed database``.
314-
with pytest.raises(Exception):
321+
# sqlite3 raises ``ProgrammingError: Cannot operate on a closed
322+
# database`` for any further operation. We assert on the
323+
# specific exception type (not bare ``Exception``) so the test
324+
# only passes when the close actually took effect — a generic
325+
# ``Exception`` catcher would mask unrelated failures (env
326+
# issues, AttributeError, etc.).
327+
with pytest.raises(sqlite3.ProgrammingError):
315328
store._conn.execute("SELECT 1").fetchone()
316329

317330
@pytest.mark.asyncio

0 commit comments

Comments
 (0)