fix(channels): recover from undecodable runtime config and WeChat state (#7273)

`store.py` already treats a state file whose bytes are not UTF-8 as corruption:
`_read_legacy_file` catches `UnicodeDecodeError` and the three call paths start
fresh or skip the import (#7233, AGENTS.md: "Store JSON decode errors follow
corrupt-file recovery"). Four readers in the same package read the same kind of
file with an explicit `encoding="utf-8"` but catch only
`json.JSONDecodeError`/`OSError`; `UnicodeDecodeError` is a `ValueError`, so it
escapes and takes the caller down instead of following that policy:

* `ChannelRuntimeConfigStore._load` — `ChannelService.start()` and the
  channel-connections router construct the store, so one undecodable
  `runtime-config.json` stops every IM channel from starting, including the
  non-fallback path where a peer's copy should be rewritten from this process's
  snapshot.
* `_read_legacy_file` — the one-time `runtime-config.json` import aborts instead
  of keeping the file and reporting it as corrupt.
* `WechatChannel._load_state` / `_load_auth_state` — `start()` loads both through
  `asyncio.to_thread`, so an undecodable cursor or auth file raises out of the
  channel's own start instead of warning and continuing unauthenticated.

Each reader now names `UnicodeDecodeError` next to the existing handlers, so the
outcome matches the sibling store: a warning, the in-memory fallback, and the
file left in place.
This commit is contained in:
yetuge 2026-10-22 19:26:38 +08:00 committed by GitHub
parent 27aa05f54e
commit b57488734d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 96 additions and 3 deletions

View File

@ -130,7 +130,7 @@ class ChannelRuntimeConfigStore:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
if fallback is not None:
logger.warning("Corrupt channel runtime config store at %s, rewriting it from this process's copy", self._path)
return {name: dict(config) for name, config in fallback.items()}
@ -369,6 +369,9 @@ def _read_legacy_file(path: Path) -> dict[str, Any] | None:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except UnicodeDecodeError:
logger.warning("Corrupt channel runtime config %s; skipping the import (file kept)", path)
return None
except OSError:
logger.warning("Cannot read channel runtime config %s; skipping the import (file kept)", path, exc_info=True)
return None

View File

@ -1695,7 +1695,7 @@ class WechatChannel(Channel):
return
try:
data = json.loads(self._cursor_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
logger.warning("[WeChat] failed to read cursor state from %s", self._cursor_path)
return
cursor = data.get("get_updates_buf")
@ -1719,7 +1719,7 @@ class WechatChannel(Channel):
return
try:
data = json.loads(self._auth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
logger.warning("[WeChat] failed to read auth state from %s", self._auth_path)
return
if not isinstance(data, dict):

View File

@ -234,6 +234,18 @@ def test_a_corrupt_legacy_file_is_kept(session_factory, tmp_path):
assert legacy.read_text(encoding="utf-8") == "{not json"
def test_an_undecodable_legacy_file_is_kept(session_factory, tmp_path, caplog):
"""A file whose bytes are not UTF-8 is corrupt, not a crash: the import skips and keeps it."""
legacy = tmp_path / "runtime-config.json"
legacy.write_bytes(b'{"slack": {"enabled": "\xff"}}')
with caplog.at_level(logging.WARNING, logger="app.channels.runtime_config_store"):
assert _store(session_factory, cipher=_cipher(), legacy_path=legacy).import_legacy_json() == 0
assert legacy.read_bytes() == b'{"slack": {"enabled": "\xff"}}'
assert "Corrupt channel runtime config" in caplog.text
def test_without_a_cipher_the_import_keeps_the_file(session_factory, tmp_path):
legacy = tmp_path / "runtime-config.json"
_write_legacy(legacy, {"slack": SLACK})

View File

@ -0,0 +1,78 @@
"""Undecodable channel state follows the corrupt-state recovery policy, not a crash.
``app/channels/store.py`` recovers from a state file whose bytes are not UTF-8
(``_read_legacy_file`` catches ``UnicodeDecodeError`` and starts fresh), and its
AGENTS.md states that rule for channel store JSON. The other state readers in the
same package read the same kind of file with an explicit ``encoding="utf-8"`` but
only catch ``json.JSONDecodeError`` and ``OSError``; ``UnicodeDecodeError`` is a
``ValueError``, not an ``OSError``, so it escapes and the channel cannot start.
Each test writes undecodable bytes into the file that reader owns and asserts the
documented outcome: a warning, the in-memory fallback, and the file left in place.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import pytest
from app.channels import runtime_config_store
from app.channels.message_bus import MessageBus
from app.channels.wechat import WechatChannel
UNDECODABLE = b'{"enabled": "\xff"}'
def test_runtime_config_store_starts_fresh_on_undecodable_state(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
path = tmp_path / "channels" / "runtime-config.json"
path.parent.mkdir(parents=True)
path.write_bytes(UNDECODABLE)
with caplog.at_level(logging.WARNING, logger="app.channels.runtime_config_store"):
store = runtime_config_store.ChannelRuntimeConfigStore(path)
assert store.get_provider_config("buzz") is None
assert "Corrupt channel runtime config store" in caplog.text
assert path.read_bytes() == UNDECODABLE
def test_runtime_config_store_reports_the_peer_copy_on_undecodable_state(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""The reload path under ``_mutation`` must fall back to this process's copy."""
path = tmp_path / "channels" / "runtime-config.json"
store = runtime_config_store.ChannelRuntimeConfigStore(path)
store.set_provider_config("buzz", {"enabled": True})
path.write_bytes(UNDECODABLE)
with caplog.at_level(logging.WARNING, logger="app.channels.runtime_config_store"):
store.set_provider_config("slack", {"enabled": True})
assert "rewriting it from this process's copy" in caplog.text
assert store.get_provider_config("buzz") == {"enabled": True}
@pytest.mark.asyncio
async def test_wechat_starts_on_undecodable_state(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
(tmp_path / "wechat-getupdates.json").write_bytes(UNDECODABLE)
channel = WechatChannel(MessageBus(), config={"bot_token": "test-token", "state_dir": str(tmp_path)})
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel._load_state()
assert channel._get_updates_buf == ""
assert "failed to read cursor state" in caplog.text
@pytest.mark.asyncio
async def test_wechat_starts_on_undecodable_auth_state(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
(tmp_path / "wechat-auth.json").write_bytes(UNDECODABLE)
channel = WechatChannel(MessageBus(), config={"bot_token": "", "state_dir": str(tmp_path), "qrcode_login_enabled": True})
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel._load_auth_state()
assert channel._bot_token == ""
assert "failed to read auth state" in caplog.text
assert json.dumps({}) == "{}"