From b782a3b173943d7610088d6bf6225149921b04bc Mon Sep 17 00:00:00 2001 From: NanPan <111261006+poijygfdyy@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:59:18 +0800 Subject: [PATCH] fix(wechat): persist confirmed QR auth before cancellation (#6199) * fix(wechat): drain QR auth state writes on cancellation * test(wechat): pin cancelled QR credential persistence * fix(wechat): preserve auth persistence contract --- backend/app/channels/wechat.py | 39 ++++++++++++++---- backend/tests/test_wechat_channel.py | 61 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index 92421efadb..41583475ca 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -27,6 +27,7 @@ from app.channels.base import Channel from app.channels.commands import is_known_channel_command from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment +from deerflow.utils.file_io import await_drained logger = logging.getLogger(__name__) @@ -777,6 +778,32 @@ class WechatChannel(Channel): params["verify_code"] = verify_code return await self._request_public_get_json("/ilink/bot/get_qrcode_status", params=params, timeout=timeout) + async def _save_auth_state_drained( + self, + *, + status: str, + bot_token: str | None = None, + ilink_bot_id: str | None = None, + qrcode: str | None = None, + qrcode_img_content: str | None = None, + ) -> dict[str, Any]: + """Persist QR auth state before propagating caller cancellation. + + The write is a small local state-file update and is intentionally drained + while _auth_lock is held so cancellation cannot expose an in-memory + credential before its durable state settles. + """ + return await await_drained( + asyncio.to_thread( + self._save_auth_state, + status=status, + bot_token=bot_token, + ilink_bot_id=ilink_bot_id, + qrcode=qrcode, + qrcode_img_content=qrcode_img_content, + ) + ) + async def _bind_via_qrcode(self) -> dict[str, Any]: qrcode_data = await self.request_login_qrcode() qrcode = str(qrcode_data.get("qrcode") or "").strip() @@ -788,8 +815,7 @@ class WechatChannel(Channel): if qrcode_img_content: logger.warning("[WeChat] qrcode_img_content=%s", qrcode_img_content) - await asyncio.to_thread( - self._save_auth_state, + await self._save_auth_state_drained( status="pending", qrcode=qrcode, qrcode_img_content=qrcode_img_content or None, @@ -808,8 +834,7 @@ class WechatChannel(Channel): if ilink_bot_id: self._ilink_bot_id = ilink_bot_id - return await asyncio.to_thread( - self._save_auth_state, + return await self._save_auth_state_drained( status="confirmed", bot_token=token, ilink_bot_id=self._ilink_bot_id, @@ -818,8 +843,7 @@ class WechatChannel(Channel): ) if status in {"expired", "canceled", "cancelled", "invalid", "failed"}: - await asyncio.to_thread( - self._save_auth_state, + await self._save_auth_state_drained( status=status, qrcode=qrcode, qrcode_img_content=qrcode_img_content or None, @@ -828,8 +852,7 @@ class WechatChannel(Channel): await asyncio.sleep(max(self._qrcode_poll_interval, 0.1)) - await asyncio.to_thread( - self._save_auth_state, + await self._save_auth_state_drained( status="timeout", qrcode=qrcode, qrcode_img_content=qrcode_img_content or None, diff --git a/backend/tests/test_wechat_channel.py b/backend/tests/test_wechat_channel.py index e63dd736b4..008cccc715 100644 --- a/backend/tests/test_wechat_channel.py +++ b/backend/tests/test_wechat_channel.py @@ -7,6 +7,7 @@ import base64 import json import logging import os +import threading from pathlib import Path from typing import Any from unittest import mock @@ -1490,6 +1491,66 @@ def test_qrcode_login_binds_and_persists_auth_state(monkeypatch, tmp_path: Path) _run(go()) +@pytest.mark.asyncio +async def test_qrcode_confirmation_drains_auth_persistence_across_cancellation(monkeypatch, tmp_path: Path): + from app.channels.wechat import WechatChannel + + state_dir = tmp_path / "wechat-state" + channel = WechatChannel( + bus=MessageBus(), + config={ + "state_dir": str(state_dir), + "qrcode_login_enabled": True, + "qrcode_poll_interval": 0.01, + "qrcode_poll_timeout": 1, + }, + ) + started = threading.Event() + release = threading.Event() + original_save_auth_state = channel._save_auth_state + + async def request_qrcode(): + return {"qrcode": "qr-cancel"} + + async def request_status(_qrcode, **_kwargs): + return {"status": "confirmed", "bot_token": "durable-token", "ilink_bot_id": "bot-cancel"} + + def blocking_save_auth_state(**kwargs): + if kwargs.get("status") == "confirmed": + started.set() + assert release.wait(timeout=5) + return original_save_auth_state(**kwargs) + + monkeypatch.setattr(channel, "request_login_qrcode", request_qrcode) + monkeypatch.setattr(channel, "request_login_status", request_status) + monkeypatch.setattr(channel, "_save_auth_state", blocking_save_auth_state) + + task = asyncio.create_task(channel._bind_via_qrcode()) + try: + assert await asyncio.to_thread(started.wait, 5) + assert channel._bot_token == "durable-token" + + task.cancel() + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await task + finally: + release.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + auth_state = json.loads((state_dir / "wechat-auth.json").read_text(encoding="utf-8")) + assert auth_state["status"] == "confirmed" + assert auth_state["bot_token"] == "durable-token" + assert auth_state["ilink_bot_id"] == "bot-cancel" + + def test_save_auth_state_tightens_preexisting_loose_file(tmp_path: Path): """A world-readable auth file is replaced by an owner-only one, atomically.