94 lines
5.6 KiB
Markdown
94 lines
5.6 KiB
Markdown
# 服务端切换链路调查
|
||
|
||
调查时间: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 事件回调内全量同步解析;首屏只读取
|
||
有界尾部,完整历史继续分页。
|
||
|