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

94 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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