Skip to content

bridge.cts 启动竞态: startStdioServer 应在 core.init 之前启动 #1747

@numuly

Description

@numuly

问题描述

Hermes Python 客户端(或其他基于 stdio 的适配器)连接 MemOS bridge 时,首次 session.open RPC 调用持续超时 30-75 秒。Python 适配器 _open_session() 默认 30 秒超时,首次连接抛出 asyncio.TimeoutError。多次重试后 core.init() 执行完毕,调用才最终成功,导致问题难以诊断——看起来像是瞬时的网络或资源问题。


根因

bridge.ctsstartStdioServer() 之前调用 core.init(),导致孤儿 episode 恢复阻塞了 stdio 读循环。

原启动顺序:

bootstrapMemoryCoreFull()         // 打开 SQLite,初始化 core 对象
  ↓
await core.init()                  // ◄── 处理孤儿 episode,调用 LLM
  ↓
startStdioServer({ core })         // ◄── stdio 读循环至此才启动(太晚了)

core.init()core/pipeline/memory-core.ts:594)在返回前执行以下操作:

  1. 扫描 episodes 表,查找状态为 status="open" 的孤儿 episode(第 608 行)
  2. 同步关闭轻量级孤儿 episode
  3. 通过 recoverOpenEpisodesAsSessionEnd() 恢复陈旧的 open episode——调用 LLM 计算奖励(第 637 行),通常耗时 10-60+ 秒
  4. 通过 recoverDirtyClosedEpisodes() 恢复脏 closed episode——再次调用 LLM(第 644 行)

这些恢复操作运行时,stdio 读循环尚未启动。Python 适配器将 session.open 写入 stdin,但数据停留在管道缓冲区中无人读取。适配器有 30 秒超时,而孤儿恢复可能超过这个时间,因此调用超时。

为什么最终能成功: 多次重试后 core.init() 完成,stdio 循环启动,session.open 才被处理。

为什么新安装不受影响: 新安装没有或只有极少的孤儿 episode,core.init() 毫秒级完成——竞态条件不会触发。只有在 bridge 有活跃聊天会话、崩溃或重启后留下孤儿 episode 时才会出现。


为什么 core.init() 放在前面?

可能是假设 core 必须完全初始化后才能接受 RPC 调用。但 SESSION_OPENbridge/methods.ts:119)路由到 core.openSession()memory-core.ts:1232),其执行路径:

  1. 调用 ensureLive()——只检查 shutDown 标志(第 490 行),不检查 initialized
  2. 调用 withNamespaceMeta()namespaceFromHints()——纯函数
  3. 调用 sessionManager.openSession()——纯 SQLite upsert + 事件总线发射

所有这些在 core.init() 之前都能正常工作,因为 SQLite 数据库和事件总线在 bootstrapMemoryCoreFull() 期间已经建立。会话创建不依赖 core.init()

此外,startStdioServer() 在模块加载时订阅 core 事件和日志(第 97-102 行),事件处理器在任何事件触发前就已注册。


修复方案

文件: bridge.cts

startStdioServer({ core }) 移到 await core.init() 之前

// 修改前(有问题):
await core.init();
// ...(遥测、错误处理、daemon 检查)...
stdio = startStdioServer({ core });    // ≈第 290 行

// 修改后(修复):
stdio = startStdioServer({ core });    // ← 立即开始读取 stdin
bridgeStatus?.markConnected();
const bridgeHeartbeat = bridgeStatus?.startHeartbeat();

await core.init();                     // ← 孤儿恢复在后台运行
                                       //    此时 stdio 已在处理 RPC

这样确保孤儿恢复开始前 stdio 读循环已经激活。Python 发送 session.open 时立即被处理。孤儿恢复在后台进行(core.init() 内的所有异步工作),恢复完成时会话已经打开。


安全性分析

关注点 评估
ensureLive() 在 init 前 只检查 shutDown 标志(默认 false)——通过
openSession() SQLite 依赖 数据库在 bootstrapMemoryCoreFull() 时已打开,早于两者
事件总线处理器 startStdioServer() 构造函数中订阅,不依赖 init
initialized 标志 init() 最顶部设为 true(第 601 行),在任何 await 之前
竞态:孤儿恢复修改会话 孤儿恢复只处理 closed episode(脏奖励)和 有陈旧 trace 的 open episode——新建的会话永远不会被触及

附加说明

CORE_INIT RPC 方法

bridge/methods.tsCORE_INIT RPC 方法存在(第 107 行),但没有任何 stdio 适配器调用它——bridge 始终在进程内调用 core.init()。这个 RPC 看起来是死代码。如果确实是,建议清理或标记。

建议

  1. bridge.cts 中将 startStdioServer() 移到 core.init() 之前(1 行结构性变更,零风险,见上方修复方案)
  2. 可选:添加注释说明排序原因(孤儿恢复可能因 LLM 调用将 stdio 处理延迟数分钟)
  3. 可选:添加就绪信号到 stdio 协议(例如 core.init() 完成后发射 server.ready 通知),让适配器在需要依赖 init 的功能(如 capture、reflection)时可以等待完全就绪

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions