修复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

@@ -32,6 +32,28 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.
class DashboardServer:
"""统计看板服务器"""
@property
def client(self) -> WechatAPIClient | None:
"""动态返回当前 robot 上挂载的微信客户端。
说明:
1. Dashboard 进程通常比 wechat 登录完成更早启动,因此不能只在构造时拍平一份固定引用;
2. 这里每次访问都优先回看 `robot.ipad_bot`,可自动吃到后续 provider 初始化完成后的真实对象;
3. 同时保留 `_client` 兜底,兼容未来如果需要在测试里手工注入 mock client 的场景。
"""
robot = getattr(self, "robot", None)
if robot is not None:
dynamic_client = getattr(robot, "ipad_bot", None)
if dynamic_client is not None:
self._client = dynamic_client
return dynamic_client
return getattr(self, "_client", None)
@client.setter
def client(self, value: WechatAPIClient | None) -> None:
"""允许初始化或测试场景显式覆写当前 client。"""
self._client = value
def __init__(self, host: str = None, port: int = None,
username: str = None, password: str = None,
robot_instance=None):
@@ -93,8 +115,12 @@ class DashboardServer:
self.contact_manager = robot_instance.contact_manager
self.plugin_manager = robot_instance.plugin_manager
self.plugin_registry = robot_instance.plugin_registry
self.client: WechatAPIClient = robot_instance.ipad_bot
self.robot = robot_instance
# Dashboard 启动时不再强绑一个“拍平后的 client 引用”:
# 1. wechat 线程可能还在 provider 初始化或登录流程中;
# 2. 这里只先记录一个初始值,后续统一通过 `client` 属性动态读取 robot.ipad_bot
# 3. 这样后台页在启动竞态下不会因为抢跑而持有一个永远为 None 的旧引用。
self.client = getattr(robot_instance, "ipad_bot", None)
self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要")
self.member_context_service = getattr(self.member_context_plugin, "service", None)

View File

@@ -108,7 +108,11 @@ def main():
# 初始化并启动wechat_ipad客户端
if robot.init_wechat_ipad():
robot.LOG.info("wechat_ipad客户端启动成功")
# 这里刻意不用“登录成功”措辞:
# 1. 主线程当前只能确认 provider 已成功创建、子线程已进入运行流程;
# 2. 真正的登录成功仍取决于缓存唤醒或扫码流程,日志会在 wechat 线程里继续输出;
# 3. 这样可以避免运维把“线程已启动”误认为“账号已完全在线”。
robot.LOG.info("wechat_ipad客户端线程已启动等待登录态完成")
else:
robot.LOG.error("wechat_ipad客户端启动失败")
# 注册定时任务

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()