修复wechat_ipad启动竞态与Dashboard抢跑问题

This commit is contained in:
liuwei
2026-05-07 10:55:34 +08:00
parent 0051574a1e
commit c628afc530
3 changed files with 79 additions and 6 deletions

View File

@@ -50,11 +50,21 @@ class Robot:
self.LOG.info(f"=" * 50)
# wechat_ipad 相关属性
self.ipad_bot: WechatAPIClient
# 这里先显式给出一个空值:
# 1. Dashboard 可能在 wechat 线程真正跑起来前就读取 `robot.ipad_bot`
# 2. 若只写类型标注不赋默认值,启动竞态下会直接抛 `AttributeError`
# 3. 先置为 None 后,其他模块就可以安全地做“是否已就绪”的判定。
self.ipad_bot: WechatAPIClient | None = None
self.ipad_config = None
self.ipad_running = False
self.ipad_thread = None
self.ipad_loop = None
# 启动结果同步事件:
# 1. `init_wechat_ipad()` 在主线程调用,但真正的 provider 初始化在子线程里执行;
# 2. 这里用 Event 把“子线程是否至少成功创建了 provider”回传给主线程
# 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。
self.ipad_startup_event = threading.Event()
self.ipad_startup_error = None
self.wxid = None
self.nickname = None
self.alias = None
@@ -217,6 +227,8 @@ class Robot:
def init_wechat_ipad(self):
"""初始化wechat_ipad客户端"""
try:
self.ipad_startup_event.clear()
self.ipad_startup_error = None
# wechat_ipad 静态配置统一走 Config
# 1. 用户现在只需要维护 `.env` / `config.yaml`,不必再手工维护独立 TOML
# 2. 登录态仍保留本地缓存文件,但只作为运行期状态,不再作为主配置源;
@@ -250,6 +262,18 @@ class Robot:
)
self.ipad_thread.start()
# 等待子线程至少完成 provider 创建或明确报错:
# 1. 这里不强求“已经登录成功”,否则首次扫码场景会被误判为启动失败;
# 2. 但至少要确认 Gateway / provider 能正常实例化,避免主线程盲目打印成功日志;
# 3. 若超时仍未收到回传,按失败处理,让运维更早感知异常启动。
startup_ready = self.ipad_startup_event.wait(timeout=15)
if not startup_ready:
self.LOG.error("wechat_ipad客户端初始化超时未在预期时间内完成 provider 启动")
return False
if self.ipad_startup_error:
self.LOG.error(f"wechat_ipad客户端初始化失败: {self.ipad_startup_error}")
return False
self.LOG.debug("wechat_ipad客户端初始化完成")
return True
except Exception as e:
@@ -272,6 +296,11 @@ class Robot:
server_type = str(self.ipad_config.get("server_type", "legacy_855") or "legacy_855").strip()
self.ipad_bot = WechatGateway(server_ip, server_port, server_type=server_type)
self.message_auto_revoke = MessageAutoRevoke(self.ipad_bot)
# 一旦 provider 已成功创建,就尽快通知主线程:
# 1. 这说明 `server_type`、Gateway 映射和 provider 构造链至少是可用的;
# 2. Dashboard 此时再读取 `robot.ipad_bot` 也不会踩到空对象;
# 3. 后续若登录失败,会由运行时日志和告警继续暴露,而不是伪装成“启动成功”。
self.ipad_startup_event.set()
# 855 provider 现在自行承接运行时模型:
# 1. provider 内部负责登录、历史消息拉取、心跳、长心跳、掉线恢复与实时轮询;
# 2. Robot 只注册业务回调,继续处理联系人初始化、消息归档、插件调度等项目内逻辑;
@@ -291,6 +320,8 @@ class Robot:
)
except Exception as e:
self.ipad_startup_error = e
self.ipad_startup_event.set()
self.LOG.exception(f"wechat_ipad客户端运行出错: {e}")
self.ipad_running = False
@@ -352,11 +383,23 @@ class Robot:
2. 后续新增 864 等 provider 时,可以天然形成“每个 provider 自己维护自己的状态”;
3. 这里统一在主程序收口默认路径,避免把路径规则写散到文档、脚本和 provider 内部。
"""
server_type = str(ipad_config.get("server_type", "legacy_855") or "legacy_855").strip().lower()
if not server_type:
server_type = "legacy_855"
server_type = Robot._normalize_wechat_provider_key(ipad_config.get("server_type", "legacy_855"))
return os.path.join("wechat_ipad", "providers", server_type, "runtime_state.toml")
@staticmethod
def _normalize_wechat_provider_key(server_type) -> str:
"""把对外可配置的 server_type 归一化成 provider 目录键。
说明:
1. 运行入口允许用户写 `legacy_855` / `855` / `859`,这是配置层的易用性;
2. 但这几种写法本质上都指向同一个 provider不应该因为别名不同就分裂出多份登录态文件
3. 因此这里统一把 855 家族收敛到 `legacy_855`,保证线上切换别名时缓存路径稳定。
"""
normalized_server_type = str(server_type or "legacy_855").strip().lower()
if normalized_server_type in {"855", "859", "legacy_855"}:
return "legacy_855"
return normalized_server_type or "legacy_855"
def _load_toml_config_if_exists(self, file_path: str) -> dict:
"""安全读取一个 TOML 文件,缺失或格式异常时回退为空配置。"""
normalized_path = str(file_path or "").strip()