From c645f65b3af26797901b2f508618a4eb1ec8654f Mon Sep 17 00:00:00 2001 From: OUAO-FRANK <152768228+OUAO-FRANK@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:18:58 +0800 Subject: [PATCH] fix(wechat): extend QR-code status request timeout (#6783) * fix(wechat): extend QR status polling timeout * fix(wechat): bound QR polling request timeout * fix(wechat): enforce QR status deadline --- backend/app/channels/wechat.py | 26 +++++++-- backend/app/channels/wechat_qr_login.py | 6 +- backend/tests/test_wechat_channel.py | 74 +++++++++++++++++++++++++ backend/tests/test_wechat_qr_login.py | 20 +++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index 21f72400d7..cb15d0e331 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -31,6 +31,7 @@ from app.channels.message_bus import InboundMessage, InboundMessageType, Message from deerflow.utils.file_io import await_drained logger = logging.getLogger(__name__) +_monotonic = time.monotonic class MessageItemType(IntEnum): @@ -227,6 +228,9 @@ class WechatChannel(Channel): DEFAULT_QRCODE_POLL_INTERVAL = 2.0 DEFAULT_QRCODE_POLL_TIMEOUT = 180.0 DEFAULT_QRCODE_BOT_TYPE = 3 + DEFAULT_QRCODE_STATUS_TIMEOUT = 30.0 + # Browser QR login allows a small cushion over the channel polling timeout. + DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT = DEFAULT_QRCODE_STATUS_TIMEOUT + 5.0 DEFAULT_API_TIMEOUT = 15.0 DEFAULT_CONFIG_TIMEOUT = 10.0 DEFAULT_CDN_TIMEOUT = 30.0 @@ -836,7 +840,11 @@ class WechatChannel(Channel): params = {"qrcode": qrcode} if verify_code: params["verify_code"] = verify_code - return await self._request_public_get_json("/ilink/bot/get_qrcode_status", params=params, timeout=timeout) + return await self._request_public_get_json( + "/ilink/bot/get_qrcode_status", + params=params, + timeout=self.DEFAULT_QRCODE_STATUS_TIMEOUT if timeout is None else timeout, + ) async def _save_auth_state_drained( self, @@ -881,9 +889,19 @@ class WechatChannel(Channel): qrcode_img_content=qrcode_img_content or None, ) - deadline = time.monotonic() + max(self._qrcode_poll_timeout, 1.0) - while time.monotonic() < deadline: - status_data = await self.request_login_status(qrcode) + deadline = _monotonic() + max(self._qrcode_poll_timeout, 1.0) + while True: + remaining = deadline - _monotonic() + if remaining <= 0: + break + try: + async with asyncio.timeout(remaining): + status_data = await self.request_login_status( + qrcode, + timeout=min(remaining, self.DEFAULT_QRCODE_STATUS_TIMEOUT), + ) + except TimeoutError: + break status = str(status_data.get("status") or "").strip().lower() if status == "confirmed": token = str(status_data.get("bot_token") or "").strip() diff --git a/backend/app/channels/wechat_qr_login.py b/backend/app/channels/wechat_qr_login.py index 78ea51d9df..0b20b0f374 100644 --- a/backend/app/channels/wechat_qr_login.py +++ b/backend/app/channels/wechat_qr_login.py @@ -73,7 +73,11 @@ class WechatQRLogin: try: if qrcode is None: return await channel.request_login_qrcode() - return await channel.request_login_status(qrcode, timeout=35, verify_code=verify_code) + return await channel.request_login_status( + qrcode, + timeout=WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT, + verify_code=verify_code, + ) finally: await channel.stop() diff --git a/backend/tests/test_wechat_channel.py b/backend/tests/test_wechat_channel.py index 3adf89e7a3..e21567b0f8 100644 --- a/backend/tests/test_wechat_channel.py +++ b/backend/tests/test_wechat_channel.py @@ -117,6 +117,80 @@ def test_timing_config_requires_positive_finite_values(): assert channel._retry_delay == 0.25 +def test_qrcode_status_uses_dedicated_request_timeout(): + from app.channels.wechat import WechatChannel + + async def go(): + channel = WechatChannel(bus=MessageBus(), config={"bot_token": "test-token"}) + request = AsyncMock(return_value={"status": "wait"}) + channel._request_public_get_json = request # type: ignore[method-assign] + + await channel.request_login_status("qr-token") + + request.assert_awaited_once_with( + "/ilink/bot/get_qrcode_status", + params={"qrcode": "qr-token"}, + timeout=WechatChannel.DEFAULT_QRCODE_STATUS_TIMEOUT, + ) + + _run(go()) + + +def test_qrcode_poll_request_timeout_is_bounded_by_remaining_deadline(monkeypatch): + from app.channels import wechat as wechat_module + from app.channels.wechat import WechatChannel + + async def go(): + channel = WechatChannel( + bus=MessageBus(), + config={"bot_token": "test-token", "qrcode_poll_timeout": 10}, + ) + channel.request_login_qrcode = AsyncMock(return_value={"qrcode": "qr-token"}) # type: ignore[method-assign] + channel.request_login_status = AsyncMock( # type: ignore[method-assign] + return_value={"status": "confirmed", "bot_token": "bound-token"} + ) + channel._save_auth_state_drained = AsyncMock(return_value={"status": "confirmed"}) # type: ignore[method-assign] + + monotonic_values = iter([0.0, 9.0]) + monkeypatch.setattr(wechat_module, "_monotonic", lambda: next(monotonic_values)) + + result = await channel._bind_via_qrcode() + + assert result == {"status": "confirmed"} + channel.request_login_status.assert_awaited_once_with("qr-token", timeout=1.0) + + _run(go()) + + +def test_qrcode_poll_request_is_cancelled_at_total_deadline(monkeypatch): + from app.channels import wechat as wechat_module + from app.channels.wechat import WechatChannel + + async def go(): + channel = WechatChannel( + bus=MessageBus(), + config={"bot_token": "test-token", "qrcode_poll_timeout": 1}, + ) + channel.request_login_qrcode = AsyncMock(return_value={"qrcode": "qr-token"}) # type: ignore[method-assign] + + async def request_status(_qrcode, **_kwargs): + await asyncio.sleep(10) + + channel.request_login_status = request_status # type: ignore[method-assign] + save_auth_state = AsyncMock(return_value={"status": "timeout"}) + channel._save_auth_state_drained = save_auth_state # type: ignore[method-assign] + + monotonic_values = iter([0.0, 0.0]) + monkeypatch.setattr(wechat_module, "_monotonic", lambda: next(monotonic_values)) + + with pytest.raises(TimeoutError): + await asyncio.wait_for(channel._bind_via_qrcode(), timeout=2) + + assert save_auth_state.await_args_list[-1].kwargs["status"] == "timeout" + + _run(go()) + + def test_handle_update_publishes_private_chat_message(): from app.channels.wechat import WechatChannel diff --git a/backend/tests/test_wechat_qr_login.py b/backend/tests/test_wechat_qr_login.py index e94338810d..37baff62d0 100644 --- a/backend/tests/test_wechat_qr_login.py +++ b/backend/tests/test_wechat_qr_login.py @@ -137,6 +137,26 @@ async def test_browser_transport_closes_http_client_without_starting_channel(mon start.assert_not_awaited() +@pytest.mark.asyncio +async def test_browser_status_request_uses_channel_qrcode_timeout(monkeypatch): + from app.channels.wechat import WechatChannel + + assert WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT == 35.0 + request = AsyncMock(return_value={"status": "wait"}) + stop = AsyncMock() + monkeypatch.setattr(WechatChannel, "request_login_status", request) + monkeypatch.setattr(WechatChannel, "stop", stop) + + await WechatQRLogin().request({}, "qr-token") + + request.assert_awaited_once_with( + "qr-token", + timeout=WechatChannel.DEFAULT_QRCODE_ADMIN_STATUS_TIMEOUT, + verify_code=None, + ) + stop.assert_awaited_once() + + @pytest.mark.asyncio async def test_concurrent_polls_only_apply_once(login): session = await login.start("alice", {})