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

7.4 KiB
Raw Blame History

前端会话切换与 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(最符合“刷新后正常”)

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、服务端处理慢和前端渲染异常。