fix(feishu): send WebSocket CLOSE frame on disconnect (#10202)

Feishu adapter's disconnect() cancelled WSS-thread tasks but never
called the lark_oapi client's _disconnect() coroutine, so no
WebSocket CLOSE frame was sent. Feishu's server kept routing
messages to the stale endpoint for minutes (CLOSE-WAIT timeout),
silencing the channel across every shutdown path — systemd restart,
hermes update, hermes gateway restart, and the --replace takeover
during 'hermes dashboard' invocations.

Schedule ws_client._disconnect() on the WSS thread loop via
run_coroutine_threadsafe with a 5s timeout before the existing
task-cancel + loop-stop sequence. Defensive hasattr guard + broad
except keeps disconnect() resilient if lark_oapi's internals shift.

Fixes #10202
This commit is contained in:
Teknium 2026-04-26 21:18:59 -07:00 committed by Teknium
parent a6079dd350
commit 77700a0ec0
2 changed files with 111 additions and 1 deletions

View file

@ -1756,10 +1756,49 @@ class FeishuAdapter(BasePlatformAdapter):
await self._cancel_pending_tasks(self._pending_text_batch_tasks)
await self._cancel_pending_tasks(self._pending_media_batch_tasks)
self._reset_batch_buffers()
# Send a WebSocket CLOSE frame to Feishu BEFORE tearing down the
# thread loop. Without this, Feishu's server never learns the
# connection is dead and continues routing messages to the stale
# endpoint — the channel goes silent until the server-side
# CLOSE-WAIT expires (minutes to hours). See issue #10202.
#
# ``_disable_websocket_auto_reconnect()`` nils ``self._ws_client``,
# so capture the client reference first.
ws_client = self._ws_client
ws_thread_loop = self._ws_thread_loop
self._disable_websocket_auto_reconnect()
await self._stop_webhook_server()
ws_thread_loop = self._ws_thread_loop
if (
ws_client is not None
and ws_thread_loop is not None
and not ws_thread_loop.is_closed()
and hasattr(ws_client, "_disconnect")
):
try:
future = asyncio.run_coroutine_threadsafe(
ws_client._disconnect(), ws_thread_loop
)
# 5s is generous — the CLOSE frame is a single WebSocket
# control frame. If it takes longer than that the
# connection is already wedged and we gain nothing by
# waiting further.
await asyncio.wait_for(asyncio.wrap_future(future), timeout=5.0)
logger.debug("[Feishu] Sent WebSocket CLOSE frame to Feishu")
except asyncio.TimeoutError:
logger.warning(
"[Feishu] CLOSE frame not acknowledged within 5s — "
"Feishu may briefly route messages to the stale "
"connection until server-side timeout"
)
except Exception as exc:
logger.debug(
"[Feishu] Could not send WebSocket CLOSE frame: %s",
exc,
exc_info=True,
)
if ws_thread_loop is not None and not ws_thread_loop.is_closed():
logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop")