deer-flow/backend/tests/blocking_io/test_wechat_channel_state.py
hataa b85c672cc1
fix(channels): offload blocking filesystem IO in Wechat channel (#3925)
WechatChannel made synchronous filesystem calls (mkdir, write_text,
read_bytes, Path.replace, unlink) directly inside async entry points:
_poll_loop, _bind_via_qrcode, _ensure_authenticated, _extract_image_file,
_extract_file_item, start, _send_image_attachment, _send_file_attachment.
Under slow disks, large files, or concurrent load these blocked the
asyncio event loop and stalled the channel worker.

Construction was also blocking: __init__ called _load_state() (os.stat +
read_text) synchronously, and ChannelService._start_channel() instantiates
the channel directly on the async path, so constructing WechatChannel in an
async context raised BlockingError. Persisted state (auth token + cursor)
is now loaded in start() via asyncio.to_thread, leaving __init__ IO-free.

Offload each call to a thread via asyncio.to_thread, matching the existing
pattern in channels/manager.py and dingtalk.py. The sync helpers
(_save_state, _save_auth_state, _load_auth_state, _stage_downloaded_file)
keep their signatures; only the async call sites wrap them.

Adds tests/blocking_io/test_wechat_channel_state.py as a regression anchor
covering the IO-free constructor (the production _start_channel path), the
staging write path, and the auth-state read path. Detected by
`make detect-blocking-io`.
2026-07-04 21:35:05 +08:00

115 lines
4.6 KiB
Python

"""Regression anchor: WeChat channel filesystem IO must not block the event loop.
Two production paths touch the filesystem, and both must stay off the asyncio
loop:
1. **Construction** — ``ChannelService._start_channel()`` instantiates the
channel directly on the async path (``channel_cls(...)`` with no thread
offload), so ``WechatChannel.__init__`` must be IO-free; persisted state is
loaded later in ``start()`` via ``asyncio.to_thread``. Constructing the
channel in an async context used to raise ``BlockingError: Blocking call to
os.stat`` because ``__init__`` called ``_load_state`` synchronously.
2. **Runtime** — ``_handle_update`` stages downloaded inbound files
(``mkdir`` + ``write_bytes``) and ``_ensure_authenticated`` reads persisted
auth state (``read_text``); both offload via ``asyncio.to_thread``.
If any of this regresses back onto the event loop, the strict Blockbuster gate
raises ``BlockingError`` and these tests fail.
The constructors below are invoked *directly* on the event loop (no
``asyncio.to_thread`` wrapper) on purpose: that mirrors the production
``_start_channel`` path the gate is meant to protect. Test-only file setup
(writing the auth fixture) is still wrapped in ``asyncio.to_thread`` because
that scaffolding IO would otherwise trip the gate even though it is not the
code under test.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from app.channels.message_bus import MessageBus
from app.channels.wechat import WechatChannel, _encrypt_aes_128_ecb
pytestmark = pytest.mark.asyncio
async def test_wechat_constructor_is_io_free_on_async_path(tmp_path: Path) -> None:
"""``__init__`` must not touch the filesystem — ``_start_channel`` constructs inline."""
bus = MessageBus()
# Seed an auth file that WOULD restore a different token if __init__ read it.
auth_path = tmp_path / "wechat-auth.json"
await asyncio.to_thread(auth_path.write_text, json.dumps({"status": "confirmed", "bot_token": "from-disk"}))
# Direct construction on the event loop, no asyncio.to_thread wrapper —
# this is exactly the production _start_channel path. If __init__ regresses
# to doing os.stat / read_text (e.g. re-adding _load_state here), the gate
# raises BlockingError right at this line.
channel = WechatChannel(bus=bus, config={"bot_token": "from-config", "state_dir": str(tmp_path)})
# Token came from config, not the disk file — proving __init__ did not read it.
assert channel._bot_token == "from-config"
async def test_wechat_inbound_file_staging_does_not_block_event_loop(tmp_path: Path) -> None:
"""Staging a downloaded inbound image writes through ``asyncio.to_thread``."""
bus = MessageBus()
published = []
async def capture(msg):
published.append(msg)
bus.publish_inbound = capture # type: ignore[method-assign]
channel = WechatChannel(bus=bus, config={"bot_token": "test-token", "state_dir": str(tmp_path)})
plaintext = b"fake-image-bytes"
aes_key = b"1234567890abcdef"
encrypted = _encrypt_aes_128_ecb(plaintext, aes_key)
async def _fake_download(_url: str, *, timeout: float | None = None):
return encrypted
channel._download_cdn_bytes = _fake_download # type: ignore[method-assign]
await channel._handle_update(
{
"message_type": 1,
"message_id": 101,
"from_user_id": "wx-1",
"context_token": "ctx-img",
"item_list": [
{
"type": 2,
"image_item": {"aeskey": aes_key.hex(), "media": {"full_url": "https://cdn.example/image.bin"}},
}
],
}
)
assert len(published) == 1
assert len(published[0].files) == 1
staged = Path(published[0].files[0]["path"])
staged_exists = await asyncio.to_thread(staged.exists)
assert staged_exists, "inbound image should be staged under the tmp state dir"
async def test_wechat_auth_state_load_does_not_block_event_loop(tmp_path: Path) -> None:
"""``_ensure_authenticated`` reads persisted auth state through ``asyncio.to_thread``."""
bus = MessageBus()
# bot_token="" forces _ensure_authenticated to fall through to the
# _load_auth_state branch (offloaded) instead of returning early.
channel = WechatChannel(bus=bus, config={"bot_token": "", "state_dir": str(tmp_path)})
auth_path = tmp_path / "wechat-auth.json"
await asyncio.to_thread(auth_path.write_text, json.dumps({"status": "confirmed", "bot_token": "loaded-token"}))
result = await channel._ensure_authenticated()
assert result is True
assert channel._bot_token == "loaded-token"