Files
cc-web/.trellis/tasks/10-14-session-switch-timeout/research/server.md

5.6 KiB
Raw Blame History

服务端切换链路调查

调查时间:2026-10-14(Asia/Shanghai)

结论

会话“运行中”与切换请求是否能完成是两条链路。运行状态只决定 handleLoadSession 在发送 session_info 后是否调用 attachActiveRuntimeToWs;切换本身通过同一条 WebSocket 发送 load_session。因此刷新后恢复首先指向旧 WebSocket 已经半开、假 OPEN、 或服务端事件循环暂时无法处理消息,而不是模型仍在运行导致切换必须等待。

可核对的调用链

  1. 前端 public/app.js:7530-7547 记录待切换会话; :7582-7602 检查 readyState === 1 后直接发送 { type: 'load_session', sessionId, requestId }。发送前 pendingSessionSwitchRequest 已被置空(:7583-7587),ws.send 没有 try/catch。
  2. 服务端 server.js:10328-10330 在 WebSocket message 分发中同步调用 handleLoadSession,该分支没有请求级 try/catch。
  3. handleLoadSession(server.js:12081-12200)先做跨会话状态协调和两次 loadSession(..., { metadata: true }),然后在 :12106-12120 同步解析 首屏历史;只有完成这些步骤后,才在 :12143-12176 发送 session_info,最后在 :12198-12199 绑定运行时输出。
  4. 运行态绑定(attachActiveRuntimeToWs,server.js:11959-12037)只更新 entry.ws 并发送 resume_generating 或 Codex App 恢复消息。它不等待模型 回合结束,也不决定 load_session 是否被接收。

为什么“刷新后正常”能定位到连接层

  • 前端切换请求超时为 45 秒(public/app.js:105-106,7218-7231),遮罩 12 秒后先释放;这正是“看起来一直加载,随后取消”的用户提示来源。
  • 服务器应用心跳周期为 30 秒,连续 4 次漏 pong 才 terminate( server.js:10189-10190,10440-10459)。客户端应用心跳每 15 秒,10 秒无 ack 才主动关闭(public/app.js:105-110,8151-8168)。在这段窗口内, 浏览器可能仍把 TCP 半开连接报告为 OPEN,于是 flushPendingSessionSwitch 会把请求写入一条实际上不会到达服务端的连接,并一直等 45 秒。
  • onclose 才会把当前加载重新放回待发送队列(public/app.js:8243-8264)。 半开连接未触发 close 时不会重排队;刷新页面会销毁旧 socket、创建新 socket, 认证后重新发送请求,所以当前会话很快显示。
  • logs/process.log 有持续的连接失效证据:从 2026-09-27 到 2026-10-13 共 40 次 ws_heartbeat_terminate;例如 2026-10-13 15:13:42 的 wsId=97f7015d,missedPongs=4、lastActivityAgeMs=170270,随后会有 ws_disconnect。这证明服务确实遇到过失活 WebSocket,不能把现象归因于 用户网络波动。

服务端历史读取的第二个无响应风险

  • 没有归档清单时,loadSession(server.js:6238-6272)回退到 safeReadSessionJson,同步 statSync、readFileSync 和 JSON.parse;当前 目录中可见 4--7 MiB 的旧会话快照。这会阻塞 Node 单线程,期间同一 socket 的心跳和后续消息都无法处理。
  • 有归档时,resolveSessionHistory(server.js:4813-4871)调用 sessionHistoryStore.loadHistoryPage;lib/session-history-store.js:167-183 用同步 readFileSync、校验哈希并 JSON.parse 块。归档块损坏或缺失会抛出 异常(:172-182),而 handleLoadSession 外层没有捕获并返回带 requestId 的 error。这属于可证实的“服务端无响应/进程异常”路径,需与 半开 socket 分开记录。
  • wsSend(server.js:2227-2240)只在 readyState === 1 时发送;发送失败 仅记录 ws_send_failed,不会把原请求重新入队。因此即使服务端已完成读取, socket 在发送窗口失效也不会自动补发。

目前日志没有 load_session 的开始、结束、耗时或 requestId,所以不能从历史 日志判断某次现场究竟属于半开连接还是同步历史读取;两条路径都需要补诊断后再 做最终归因。

隔离验证

  • 现有静态回归目标必须使用 --target: node scripts/regression.js --target session-switch-race,本次在 2 秒内退出 0 并打印 Session switch race regression checks passed.。
  • 先前执行的 node scripts/regression.js session-switch-race 没有传 --target,因此没有选择该目标而是进入完整回归;60 秒无输出不能作为切换 失败证据。
  • 可在不接触真实会话的临时 CC_WEB_SESSIONS_DIR 中做两组小夹具: (a) 关闭/黑洞 WebSocket 后发送 load_session,观察浏览器 45 秒计时和 onclose 重试;(b) 构造缺失归档块,发送同一请求,确认服务端返回带 requestId 的错误而非等待。两组测试都应以 60 秒为上限。

推荐的最小修复方向(供主任务实现)

  1. 前端发送 load_session 前后都捕获 ws.send 异常;只有确认发送成功才清空 pendingSessionSwitchRequest。若 socket 在发送前后不是有效连接,立即关闭并 触发重连,保留原 requestId。
  2. 服务端在 load_session 分发处包住 handleLoadSession,捕获历史读取异常, 发送 { type: 'error', code, sessionId, requestId };不得把异常静默成 session_not_found。
  3. 给 load_session 记录开始、结束、耗时、脱敏 sessionId/requestId、 history source/size 和异常代码,不记录消息正文。
  4. 对超过阈值的旧快照避免在 WebSocket 事件回调内全量同步解析;首屏只读取 有界尾部,完整历史继续分页。