From a39297e056827ff09c2557bf70eb286e0ebbe11a Mon Sep 17 00:00:00 2001 From: chenlinfeng Date: Sun, 3 May 2026 10:03:20 +0800 Subject: [PATCH 1/3] fix(weixin): replace all aiohttp ClientTimeout with asyncio.wait_for() aiohttp ClientTimeout uses BaseTimerContext which calls loop.call_later() internally. When invoked via asyncio.run_coroutine_threadsafe() from cron jobs, this triggers "Timeout context manager should be used inside a task" errors, causing message delivery failures. Replace all direct ClientTimeout usage with asyncio.wait_for(): - _upload_ciphertext: CDN upload (120s timeout) - _download_bytes: CDN download (configurable timeout) - _download_remote_media: remote media fetch (30s timeout) Also set total=None on _send_session to disable aiohttp built-in timeout, and change trust_env=True to False to bypass proxy for WeChat CDN connections. --- gateway/platforms/weixin.py | 54 ++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 482692ee7a14..2d2448a3f563 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -548,17 +548,21 @@ async def _upload_ciphertext( Accepts either a constructed CDN URL (from upload_param) or a direct upload_full_url — both use POST with the raw ciphertext as the body. """ - timeout = aiohttp.ClientTimeout(total=120) - async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response: - if response.status == 200: - encrypted_param = response.headers.get("x-encrypted-param") - if encrypted_param: - await response.read() - return encrypted_param + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors when + # invoked via asyncio.run_coroutine_threadsafe() from cron jobs. + async def _do_upload() -> str: + async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response: + if response.status == 200: + encrypted_param = response.headers.get("x-encrypted-param") + if encrypted_param: + await response.read() + return encrypted_param + raw = await response.text() + raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}") raw = await response.text() - raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}") - raw = await response.text() - raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}") + raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}") + return await asyncio.wait_for(_do_upload(), timeout=120) async def _download_bytes( @@ -567,10 +571,13 @@ async def _download_bytes( url: str, timeout_seconds: float = 60.0, ) -> bytes: - timeout = aiohttp.ClientTimeout(total=timeout_seconds) - async with session.get(url, timeout=timeout) as response: - response.raise_for_status() - return await response.read() + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors. + async def _do_download() -> bytes: + async with session.get(url) as response: + response.raise_for_status() + return await response.read() + return await asyncio.wait_for(_do_download(), timeout=timeout_seconds) _WEIXIN_CDN_ALLOWLIST: frozenset[str] = frozenset( @@ -1216,7 +1223,12 @@ async def connect(self) -> bool: logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc) self._poll_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) - self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) + # Disable aiohttp's built-in ClientTimeout (total=None) to prevent + # "Timeout context manager should be used inside a task" errors when + # send() is invoked via asyncio.run_coroutine_threadsafe() from cron. + # Timeout is managed externally via asyncio.wait_for() in _api_post/_api_get. + _no_aiohttp_timeout = aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None) + self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout) self._token_store.restore(self._account_id) self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll") self._mark_connected() @@ -1824,10 +1836,14 @@ async def _download_remote_media(self, url: str) -> str: raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}") assert self._send_session is not None - async with self._send_session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response: - response.raise_for_status() - data = await response.read() - suffix = Path(url.split("?", 1)[0]).suffix or ".bin" + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors. + async def _do_fetch(): + async with self._send_session.get(url) as response: + response.raise_for_status() + return await response.read() + data = await asyncio.wait_for(_do_fetch(), timeout=30) + suffix = Path(url.split("?", 1)[0]).suffix or ".bin" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: handle.write(data) return handle.name From 796ba43d8b2fcee326ab78c3bbc9efed965cc882 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 05:08:56 -0700 Subject: [PATCH 2/3] test(weixin): update timeout assertion for asyncio.wait_for migration --- tests/gateway/test_weixin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 8deccf18cb78..1cb5910615a7 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -461,7 +461,9 @@ def put(self, *_args, **_kwargs): assert upload_url == "https://upload.example.com/media" assert upload_kwargs["headers"] == {"Content-Type": "application/octet-stream"} assert upload_kwargs["data"] - assert upload_kwargs["timeout"].total == 120 + # Timeout is now enforced externally via asyncio.wait_for() rather than + # aiohttp.ClientTimeout, so it no longer appears as a post() kwarg. + assert "timeout" not in upload_kwargs payload = api_post_mock.await_args.kwargs["payload"] media = payload["msg"]["item_list"][0]["image_item"]["media"] assert media["encrypt_query_param"] == "enc-param" From ff66ac3d21470712912fc3f1916c949b81cfd3a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 05:09:36 -0700 Subject: [PATCH 3/3] chore: AUTHOR_MAP entry for chenlinfeng@ruije / @noOne-list --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index f62f755770e0..80d0e15081b2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -855,6 +855,7 @@ # Debug share upload-time redaction (May 2026) "dhuysamen@gmail.com": "GodsBoy", # PR #19318 "mrcoferland@gmail.com": "mrcoferland", # PR #19023 + "chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050 }