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

119 lines
7.4 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.

# 前端会话切换与 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、服务端处理慢和前端渲染异常。