Skip to content

Commit 1877cd0

Browse files
authored
fix: keep the WebSocket pool usable after an event-loop change (#2001)
* fix: detach the WebSocket pool before cleaning it up on an event-loop change Pooled clients belong to the loop they were created on. When the loop changed, get_client awaited disconnect() for every stale client from the new loop, which raises RuntimeError ("attached to a different loop"). That class was not in the best-effort catch, so it escaped before _clients.clear() and before the _current_loop update: the pool stayed stale, every later call re-entered the same branch, and WebSocket-backed tools kept failing until the process restarted. The pool and the loop reference are now detached first, and cleanup runs without awaiting anything across loops: a still-running owning loop gets each disconnect scheduled on it, a closed or stopped one leaves nothing to schedule so the connection is abandoned with the loop's resources. WebSocketManager.disconnect gets the same detach-first order for the shutdown path, and both remaining best-effort catches around client.disconnect() now include RuntimeError. * fix: report scheduled stale-disconnect outcomes and pin the detach order Review follow-ups on the loop-change fix: - A disconnect scheduled on a still-running owning loop was fire-and-forget into a discarded concurrent.futures.Future, which never warns about an exception nobody retrieved, so a failure there was invisible at every log level. A done-callback now reports cancellation and failure at debug level. - The docstring claimed an abandoned connection's socket is released with the loop's resources. Closing a loop does not close its transports; the socket goes when the orphaned transport is garbage-collected, with a ResourceWarning. Reworded, and the residual scheduling window (a loop that accepts the callback and then stops before draining it) is now named instead of implied to be closed. - The disconnect() regression test asserted only the final pool state, which the widened except alone satisfies, so the detach-before-cleanup order was untested. The stub now captures the pool from inside its disconnect call and the test asserts it is already empty. * fix: release pooled clients when the old loop is only stopped A loop that is stopped but not closed still owns its transports and can be resumed with run_forever()/run_until_complete(), so treating it like a closed loop dropped the manager's only reference to those clients without scheduling their disconnect: the WebSocket and its background task stayed rooted by the retained loop while a replacement connection was opened, and repeated loop swaps leaked one connection each. run_coroutine_threadsafe accepts a stopped, non-closed loop; the callback is queued and runs when the loop is resumed. Narrow the abandon condition to loops that are closed or unknown, which are the only ones with nothing left to schedule on. * test: pin the proxy branch in the journald window lines cases The two proxy cases in TestJournaldWindowLinesParam asserted on the HA-Core proxy path by mocking mock_client.httpx_client.request, but did not declare which branch get_addon_logs / _get_system_service_logs should take. Both branch on is_running_in_addon(), which keys off SUPERVISOR_TOKEN in the environment, so with that variable set the call went down the Supervisor-direct path instead, never touched the mocked attribute, and issued a real request to http://supervisor. CI never sets the variable, so the failure only appears when the unit suite runs inside an add-on container. Take the non_addon_install fixture, which the sibling cases in this file already use, so the branch is an explicit input on every machine (#2000). * test: cover the stale-disconnect callback and schedule-race branches Pin the previously untested paths of the fire-and-forget cleanup added for the loop-change fix: - _log_stale_disconnect on a cancelled future and on a failed one. The cancelled() guard has to precede exception(), since calling exception() on a cancelled concurrent.futures.Future raises CancelledError; without the guard the callback itself would raise. - _release_stale_clients when run_coroutine_threadsafe raises RuntimeError (the owning loop closing between the is_closed() check and the schedule call). Two clients pin the continue, and the orphaned coroutines are closed rather than left "never awaited". Also correct the disconnect() comment, which claimed parity with get_client()'s loop-change branch it does not have: the sole caller runs on the pool's own loop and awaits each disconnect, and the RuntimeError catch there is purely defensive.
1 parent 363132b commit 1877cd0

3 files changed

Lines changed: 405 additions & 20 deletions

File tree

src/ha_mcp/client/websocket_client.py

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import asyncio
11+
import concurrent.futures
1112
import hashlib
1213
import json
1314
import logging
@@ -1036,6 +1037,21 @@ def last_connect_error(self) -> str | None:
10361037
MAX_POOL_SIZE = 50
10371038

10381039

1040+
def _log_stale_disconnect(future: "concurrent.futures.Future[None]") -> None:
1041+
"""Report how a disconnect scheduled on a stale event loop ended.
1042+
1043+
Nothing awaits that future, so without this the outcome would be invisible
1044+
at every log level: a ``concurrent.futures.Future`` never warns about an
1045+
exception no one retrieved.
1046+
"""
1047+
if future.cancelled():
1048+
logger.debug("Stale WebSocket disconnect was cancelled with its loop")
1049+
return
1050+
error = future.exception()
1051+
if error is not None:
1052+
logger.debug("Stale WebSocket disconnect failed: %s", error)
1053+
1054+
10391055
class WebSocketManager:
10401056
"""Singleton manager for Home Assistant WebSocket connections.
10411057
@@ -1114,6 +1130,56 @@ def _effective_verify_ssl(verify_ssl: bool | None) -> bool:
11141130
)
11151131
return True
11161132

1133+
@staticmethod
1134+
def _release_stale_clients(
1135+
clients: list[HomeAssistantWebSocketClient],
1136+
loop: asyncio.AbstractEventLoop | None,
1137+
) -> None:
1138+
"""Best-effort cleanup of clients orphaned by an event-loop change.
1139+
1140+
Never awaits: the connections belong to ``loop``, and awaiting them
1141+
from the loop that replaced it is exactly the cross-loop failure this
1142+
avoids. Each disconnect is scheduled on the owning loop and
1143+
deliberately not waited for. A merely stopped loop is scheduled too:
1144+
it still owns live transports and still accepts callbacks, so the
1145+
disconnect runs once that loop is resumed, and otherwise stays an
1146+
unrun callback like any other the abandoned loop still holds. Only a
1147+
closed or unknown loop has nothing left to schedule on; its
1148+
connection is abandoned and the socket closes when the orphaned
1149+
transport is garbage-collected (with a ``ResourceWarning``), because
1150+
closing a loop does not close the transports it carried. Either way
1151+
the caller has already detached the pool, so a failure here cannot
1152+
make it stale again.
1153+
"""
1154+
if not clients:
1155+
return
1156+
if loop is None or loop.is_closed():
1157+
logger.debug(
1158+
"Abandoning %d stale WebSocket client(s): the owning event "
1159+
"loop is gone",
1160+
len(clients),
1161+
)
1162+
return
1163+
for client in clients:
1164+
coro = client.disconnect()
1165+
try:
1166+
future = asyncio.run_coroutine_threadsafe(coro, loop)
1167+
except RuntimeError:
1168+
# The loop closed between the check above and now. Closing the
1169+
# coroutine keeps it from surfacing as "never awaited". The
1170+
# narrower window stays open by construction: a loop that
1171+
# accepts the callback and then stops before draining it never
1172+
# runs the coroutine at all, and ``run_coroutine_threadsafe``
1173+
# needs the coroutine object up front, so there is nothing left
1174+
# to reclaim from this side.
1175+
coro.close()
1176+
logger.debug(
1177+
"Could not schedule disconnect of a stale WebSocket client",
1178+
exc_info=True,
1179+
)
1180+
continue
1181+
future.add_done_callback(_log_stale_disconnect)
1182+
11171183
async def get_client(
11181184
self,
11191185
url: str | None = None,
@@ -1143,22 +1209,21 @@ async def get_client(
11431209
if not self._lock:
11441210
raise Exception("Lock not initialized")
11451211
async with self._lock:
1146-
if self._current_loop is not None and self._current_loop != current_loop:
1147-
# Event loop changed — disconnect all clients
1148-
for client in self._clients.values():
1149-
try:
1150-
await client.disconnect()
1151-
except (OSError, asyncio.CancelledError):
1152-
# Best-effort cleanup — failure is expected when the
1153-
# event loop changed and connections are stale.
1154-
logger.debug(
1155-
"Ignoring error disconnecting stale WebSocket client",
1156-
exc_info=True,
1157-
)
1212+
previous_loop = self._current_loop
1213+
stale_clients: list[HomeAssistantWebSocketClient] = []
1214+
if previous_loop is not None and previous_loop is not current_loop:
1215+
# Event loop changed: detach the pool BEFORE cleaning it up.
1216+
# The pooled clients' futures belong to ``previous_loop``, so
1217+
# awaiting their ``disconnect()`` here raises ``RuntimeError:
1218+
# ... attached to a different loop``, which used to escape the
1219+
# best-effort catch and leave both the pool and the loop
1220+
# reference stale for every later call (issue #1994).
1221+
stale_clients = list(self._clients.values())
11581222
self._clients.clear()
11591223
self._last_used.clear()
11601224

11611225
self._current_loop = current_loop
1226+
self._release_stale_clients(stale_clients, previous_loop)
11621227

11631228
# Determine credentials to use
11641229
if url and token:
@@ -1226,7 +1291,7 @@ async def _evict_lru_if_needed(self) -> None:
12261291
if stale:
12271292
try:
12281293
await stale.disconnect()
1229-
except (OSError, asyncio.CancelledError):
1294+
except (OSError, RuntimeError, asyncio.CancelledError):
12301295
logger.warning(
12311296
"Error disconnecting evicted WebSocket client",
12321297
exc_info=True,
@@ -1239,16 +1304,27 @@ async def disconnect(self) -> None:
12391304
if not self._lock:
12401305
raise Exception("Lock not initialized")
12411306
async with self._lock:
1242-
for client in self._clients.values():
1307+
# Detach the pool before disconnecting so a raising disconnect
1308+
# cannot leave it populated (the bookkeeping half of the
1309+
# loop-change branch in ``get_client``). The only caller,
1310+
# ``__main__``'s shutdown, runs on the pool's own loop, so each
1311+
# ``disconnect()`` is awaited to completion here. ``RuntimeError``
1312+
# is caught purely defensively: were this ever driven from a
1313+
# different loop, the cross-loop future would raise instead of
1314+
# hanging the shutdown. Unlike ``get_client`` it does not
1315+
# reschedule onto the owning loop, because the process is going
1316+
# away and there is nothing left to resume it.
1317+
clients = list(self._clients.values())
1318+
self._clients.clear()
1319+
self._last_used.clear()
1320+
self._current_loop = None
1321+
for client in clients:
12431322
try:
12441323
await client.disconnect()
1245-
except (OSError, asyncio.CancelledError):
1324+
except (OSError, RuntimeError, asyncio.CancelledError):
12461325
logger.warning(
12471326
"Error disconnecting WebSocket client", exc_info=True
12481327
)
1249-
self._clients.clear()
1250-
self._last_used.clear()
1251-
self._current_loop = None
12521328

12531329

12541330
# Global WebSocket manager instance

tests/src/unit/test_tools_utility_supervisor_logs.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,7 +1502,9 @@ async def test_addon_direct_branch_omits_query_without_lines(
15021502
assert kwargs["params"] is None
15031503

15041504
@pytest.mark.asyncio
1505-
async def test_addon_proxy_branch_sends_lines_query(self, mock_client):
1505+
async def test_addon_proxy_branch_sends_lines_query(
1506+
self, mock_client, non_addon_install
1507+
):
15061508
mock_response = MagicMock()
15071509
mock_response.status_code = 200
15081510
mock_response.text = "x\n"
@@ -1514,7 +1516,9 @@ async def test_addon_proxy_branch_sends_lines_query(self, mock_client):
15141516
assert kwargs["params"] == {"lines": 2000}
15151517

15161518
@pytest.mark.asyncio
1517-
async def test_system_service_proxy_branch_sends_lines_query(self, mock_client):
1519+
async def test_system_service_proxy_branch_sends_lines_query(
1520+
self, mock_client, non_addon_install
1521+
):
15181522
mock_response = MagicMock()
15191523
mock_response.status_code = 200
15201524
mock_response.text = "x\n"

0 commit comments

Comments
 (0)