From e38fde90cd008312f2d1a515aab670393b58537f Mon Sep 17 00:00:00 2001 From: nftpoetrist Date: Thu, 23 Apr 2026 16:56:34 +0300 Subject: [PATCH] fix(qqbot): notify gateway when reconnect loop exhausts via _set_fatal_error + _notify_fatal_error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _listen_loop() had three return points where MAX_RECONNECT_ATTEMPTS was reached but neither _set_fatal_error() nor _notify_fatal_error() was called. The gateway runner kept the platform marked as connected while the adapter was dead, and systemd Restart=on-failure never triggered because the process did not exit. Adds _set_fatal_error("qq_reconnect_exhausted", ..., retryable=False) and await self._notify_fatal_error() to all three exhaustion paths — 4008 rate-limit, QQCloseError, and Exception. _set_fatal_error() alone only writes to the status file; _notify_fatal_error() is required to invoke the GatewayRunner's _handle_adapter_fatal_error handler, which disconnects the adapter and stops the gateway. Matches the pattern in the Telegram adapter (gateway/platforms/telegram.py:366, 460). Fixes #14539 --- gateway/platforms/qqbot/adapter.py | 19 ++++++ tests/gateway/test_qqbot.py | 99 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 9328464584128..4d779b5b923d3 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -484,6 +484,13 @@ async def _listen_loop(self) -> None: RATE_LIMIT_DELAY, ) if backoff_idx >= MAX_RECONNECT_ATTEMPTS: + logger.error("[%s] Max reconnect attempts reached (4008 rate-limit)", self._log_tag) + self._set_fatal_error( + "qq_reconnect_exhausted", + "Max reconnect attempts reached (4008 rate-limit)", + retryable=False, + ) + await self._notify_fatal_error() return await asyncio.sleep(RATE_LIMIT_DELAY) if await self._reconnect(backoff_idx): @@ -537,6 +544,12 @@ async def _listen_loop(self) -> None: backoff_idx += 1 if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag) + self._set_fatal_error( + "qq_reconnect_exhausted", + "Max reconnect attempts reached (QQCloseError)", + retryable=False, + ) + await self._notify_fatal_error() return except Exception as exc: @@ -548,6 +561,12 @@ async def _listen_loop(self) -> None: if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached", self._log_tag) + self._set_fatal_error( + "qq_reconnect_exhausted", + "Max reconnect attempts reached", + retryable=False, + ) + await self._notify_fatal_error() return if await self._reconnect(backoff_idx): diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index a5aeb62516a07..fc5c127669544 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -582,3 +582,102 @@ async def test_send_media_waits_for_reconnect(self): assert not result.success assert result.retryable is True assert "Not connected" in result.error + + +# --------------------------------------------------------------------------- +# _listen_loop — reconnect exhaustion notifies gateway via _set_fatal_error +# --------------------------------------------------------------------------- + +class TestListenLoopReconnectExhaustion: + """Verify _listen_loop calls _set_fatal_error + _notify_fatal_error on all exhaustion paths.""" + + def _make_adapter(self, **extra): + from gateway.platforms.qqbot import QQAdapter + adapter = QQAdapter(_make_config(app_id="a", client_secret="b", **extra)) + adapter._notify_fatal_error = mock.AsyncMock() + return adapter + + def test_exception_path_sets_fatal_error(self): + """Exception branch: exhausted backoff marks adapter as fatal and notifies gateway.""" + adapter = self._make_adapter() + adapter._running = True + adapter._mark_disconnected = mock.MagicMock() + adapter._fail_pending = mock.MagicMock() + + call_count = 0 + + async def _raise_once(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("persistent network failure") + adapter._running = False + + adapter._read_events = _raise_once + adapter._reconnect = mock.AsyncMock(return_value=False) + + with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 0): + asyncio.run(adapter._listen_loop()) + + assert adapter.has_fatal_error + assert adapter._fatal_error_code == "qq_reconnect_exhausted" + assert adapter._fatal_error_retryable is False + adapter._notify_fatal_error.assert_awaited_once() + + def test_qqcloseerror_path_sets_fatal_error(self): + """QQCloseError branch: exhausted backoff marks adapter as fatal and notifies gateway.""" + from gateway.platforms.qqbot.adapter import QQCloseError + + adapter = self._make_adapter() + adapter._running = True + adapter._mark_disconnected = mock.MagicMock() + adapter._fail_pending = mock.MagicMock() + + call_count = 0 + + async def _raise_close(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise QQCloseError(1006, "abnormal closure") + adapter._running = False + + adapter._read_events = _raise_close + adapter._reconnect = mock.AsyncMock(return_value=False) + + with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 1): + asyncio.run(adapter._listen_loop()) + + assert adapter.has_fatal_error + assert adapter._fatal_error_code == "qq_reconnect_exhausted" + assert adapter._fatal_error_retryable is False + adapter._notify_fatal_error.assert_awaited_once() + + def test_rate_limit_4008_path_sets_fatal_error(self): + """4008 rate-limit branch: exhausted backoff marks adapter as fatal and notifies gateway.""" + from gateway.platforms.qqbot.adapter import QQCloseError + + adapter = self._make_adapter() + adapter._running = True + adapter._mark_disconnected = mock.MagicMock() + adapter._fail_pending = mock.MagicMock() + + call_count = 0 + + async def _raise_rate_limit(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise QQCloseError(4008, "rate limited") + adapter._running = False + + adapter._read_events = _raise_rate_limit + adapter._reconnect = mock.AsyncMock(return_value=False) + + with mock.patch("gateway.platforms.qqbot.adapter.MAX_RECONNECT_ATTEMPTS", 0): + asyncio.run(adapter._listen_loop()) + + assert adapter.has_fatal_error + assert adapter._fatal_error_code == "qq_reconnect_exhausted" + assert adapter._fatal_error_retryable is False + adapter._notify_fatal_error.assert_awaited_once()