改用 HTTP 加载会话历史并更新 CentOS 7 发布包

This commit is contained in:
shiyue
2026-10-15 00:36:26 +08:00
parent 2b1c91488d
commit fb2c3360c9
22 changed files with 1466 additions and 175 deletions

View File

@@ -0,0 +1,118 @@
# 前端会话切换与 WebSocket 状态机调查
调查时间:2026-10-14(Asia/Shanghai)
## 结论
会话是否“运行中”不决定切换请求是否能够被接收。前端切换完全依赖当前
WebSocket:点击会话后构造 `load_session` 请求,只有该请求收到带匹配
`requestId` 的 `session_info`/历史尾块,才会结束加载状态。因而“刷新后当前
会话立刻正常”与旧 WebSocket 处于半开、浏览器仍报告 `OPEN` 的时序高度吻合。
本轮没有找到 `shared/session-switch-controller.js`;仓库内不存在该文件,切换
状态机实际集中在 `public/app.js`。
## 可核对的前端调用链
1. `openSession`(`public/app.js:7628-7672`)根据缓存命中情况进入
`beginSessionSwitch`。未命中或弱缓存会执行阻塞加载;运行中只影响快照/恢复
行为,不会绕过 WebSocket。
2. `beginSessionSwitch`(`:7508-7528`)先调用 `setSessionLoading` 创建
`activeSessionLoad={sessionId,requestId,...}`,再调用 `requestSessionLoad`。
3. `requestSessionLoad`(`:7530-7547`)把同一代次写入
`pendingSessionSwitchRequest`。当 `ws.readyState===1 && wsAuthenticated` 时立即
调用 `flushPendingSessionSwitch`;否则等待 `onopen`/`auth_result`。
4. `flushPendingSessionSwitch`(`:7582-7603`)先检查 `OPEN`,随后**先将
`pendingSessionSwitchRequest` 置空**(`:7585-7587`),再直接执行裸
`ws.send`(`:7596-7601`)。没有发送异常处理,也没有在发送后复核 socket 仍是
当前 socket。这是可直接证明的丢请求窗口:假 `OPEN` 或发送时 socket 已关闭时,
请求可能既未送达也未回队列,而 `activeSessionLoad` 仍等待其 45 秒定时器。
5. 服务端消息处理(`:8442-8512`)只接受匹配活动加载的 `sessionId` 与
`requestId`;晚到的旧响应会被丢弃。正常 `session_info` 后,若历史分片存在,
要等 `session_history_chunk` 最后一块(`:8597-8646`)才调用
`finalizeLoadedSession`,最终关闭加载状态。
## 触发“45 秒后超时”的可复现时序
### 时序 A:半开/假 OPEN(最符合“刷新后正常”)
```text
t0 旧 socket 在浏览器对象中仍为 OPEN,但服务端/代理已不再处理它
t1 用户点击运行中会话 B;activeSessionLoad=B,pending=B
t1 flush 通过 readyState 检查,清空 pending,向旧 socket send load_session
t1..t1+? 服务端没有 session_info;浏览器也没有触发 onclose
t1+12s 只释放遮罩,activeSessionLoad 仍在等待
t1+45s scheduleSessionLoadRequestTimeout 清空 active load 并提示“加载超时”
t1+45s 用户刷新,旧 socket 被销毁,新 socket 认证后重新发请求,B 立即显示
```
客户端应用心跳在认证成功后才开启(`:8345-8355`),间隔 15 秒、等待 ack 10
秒(`:8151-8176`)。假连接在下一次检测前仍可保持 `OPEN`;移动端后台节流还
可能推迟心跳计时器。只有 `onclose` 触发,才会在 `:8243-8264` 将活动请求放回
`pendingSessionSwitchRequest` 并安排重连。也就是说,heartbeat 不是切换协议,
只是延迟发现坏连接;在发现之前的窗口内,`load_session` 可以被写入黑洞。
### 时序 B:发送调用本身抛错
`flushPendingSessionSwitch` 在置空 pending 后调用裸 `ws.send`。若浏览器在检查
`readyState` 与 `send` 之间将 socket 转为 closing/closed,`send` 会同步抛出;
该异常从点击事件或 `auth_result` 处理路径冒出。`onmessage` 的总捕获
(`:8195-8206`)只记录异常并释放遮罩,**不会关闭 socket、重排队或清除活动
定时器**。结果仍是活动加载挂到 45 秒后才提示超时。
同类问题也存在于 `flushPendingSessionResume`(`:7605-7611`)的裸 `ws.send`,
但它主要影响运行中会话重连,不是普通切换的首要路径。
### 时序 C:已有关闭事件时的正确恢复路径(用于对照)
若 socket 先触发 `onclose`,代码会把活动加载复制回 pending(`:8243-8251`),
调用 `clearSessionLoading`,然后 `scheduleReconnect`。新连接认证成功后
`auth_result` 再调用 `flushPendingSessionSwitch`(`:8362-8373`)。这条路径本身
保留 `requestId`,可恢复快速切换的代次隔离;因此仅凭“切换失败”不能推断服务
端会话未运行,必须区分“已 close 并重试”和“仍 OPEN 未重试”。
## 现有代次隔离与缺口
- `activeSessionLoad`、`pendingSessionSwitchRequest` 和消息中的 `requestId` 已
防止 A 的晚到响应覆盖 B(`:8459-8477`、`:7472-7506`)。
- `session_history_chunk` 对活动请求同样检查 `requestId`(`:8514-8521`)。
- 45 秒计时器只做取消:`scheduleSessionLoadRequestTimeout`(`:7218-7232`)
清空 pending、清除加载并显示提示,没有执行一次强制断开/重连,也没有再次发送
同代次请求。因此它是用户可见兜底,不是恢复机制。
- `onmessage` 异常路径仅释放遮罩(`:8200-8206`);若渲染/快照处理抛错,活动
请求和计时器仍在,且连接继续被视为可用。
- 现有 `scripts/regression.js` 的 `assertSessionSwitchResilienceContract`
(`:3768-3926`)主要是源码字符串契约,虽然断言存在 heartbeat、超时和 close
重试代码,但没有模拟 `OPEN` 黑洞、`ws.send` 抛错、onmessage 异常或 45 秒
内的请求回队列。因此它无法覆盖本故障的关键时序。
## 最小修复建议(供主任务实现)
1. 将 `flushPendingSessionSwitch` 的 pending 清空动作移到成功 `send` 之后,
对 `send` 使用 `try/catch`。失败时保留原对象和 `requestId`,只关闭当前 socket
并调用既有重连逻辑;不要生成新代次,避免同一切换重复响应污染。
2. 发送前确认 `ws === socket`、`readyState===OPEN`、已认证;发送后若状态已变为
closing/closed,立即走与 `onclose` 相同的回队列路径。可抽取一个不重复覆盖新
请求的 `requeueActiveSessionLoad` 辅助函数。
3. 在 45 秒超时回调中,若请求仍是同一代次,先做一次受控的 socket 失效与重连
尝试;只有重试已发生且仍无响应才给用户“已取消”提示。重试必须保留
`requestId`/最新会话选择,不能让旧 A 响应关闭新 B。
4. `onmessage` 捕获到处理异常时应记录脱敏的消息类型、活动会话和 requestId,
清理/回队列并关闭该 socket;不能只释放遮罩后继续挂起。
## 隔离验证建议
在不接触真实会话的浏览器/Node 夹具中验证以下四种情况,单项超时上限 60 秒:
1. `FakeWebSocket.readyState=OPEN` 但 `send` 不触发服务端:点击 B 后应在心跳
检测/受控重连后自动完成,不应等到 45 秒才失败。
2. `send` 同步抛出 `InvalidStateError`:pending 必须仍保留,socket 被关闭并在
新认证后只发送一次 B;活动加载计时器应绑定同一 requestId。
3. 发送 A 后快速点击 B,先注入 A 的 `session_info`/历史尾块,再注入 B;A 不得
改变当前会话、关闭 B 的加载或清除 B 的计时器。
4. 让 `handleServerMessage` 对一条 `session_info` 抛异常:应记录并重连/重试,
不应只释放遮罩后 45 秒静默超时。
验收时同时记录 `load_session` 发送时间、socket 代号、requestId、首次响应时间
和重连次数(不记录消息正文),即可区分假 OPEN、服务端处理慢和前端渲染异常。

View File

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