Files
lucky-cat/docs/knowledge vault/bugfix-websocket-fresh-auth-reconnect.zh-CN.md
T

274 lines
8.9 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 断线后使用新鉴权上下文重连:问题原因与修复方案
## 1. 对应提交
```text
273550b fix(multimodal): reconnect websocket with fresh auth
```
本文说明多模态 WebSocket 在运行过程中断开后,设备可能持续重连失败、无法继续唤醒对话,而重启设备后又可以恢复的问题。
## 2. 用户可见现象
典型表现如下:
1. 设备启动后能够正常连接并完成若干轮对话;
2. 运行过程中 WebSocket 出现 error 或 disconnected;
3. 后续日志反复出现连接失败,设备无法重新进入可用的语音会话;
4. Wi-Fi 本身可能仍显示已连接;
5. 重启设备后,连接通常能够立即恢复。
“重启后恢复”说明问题不一定来自远端服务长期不可用。如果远端服务或公网一直不可达,重启设备一般也不会立刻解决。这个现象更符合设备内部旧 WebSocket、旧鉴权 Header 或重连状态没有被完整清理。
## 3. 原有设计的问题
### 3.1 WebSocket 重连存在两个控制者
修复前,底层 WebSocket 客户端配置为:
```c
.disable_auto_reconnect = false,
.enable_close_reconnect = true,
```
这意味着底层库可以自行重连。同时,项目的 control-plane 也负责鉴权、创建连接、处理断线和调度下一次启动。
因此一次断线后可能同时存在两条恢复路径:
```text
底层 WebSocket 库
└─ 使用原客户端和原 Header 自动重连
control-plane
└─ 将会话标记为不可用,准备重新鉴权和创建连接
```
两套状态机并不共享完整状态,容易产生以下不一致:
- control-plane 已认为连接不可用,但底层仍在自动重连;
- control-plane 已清空当前会话鉴权信息,但旧客户端仍持有创建时的 Header;
- 新一轮启动与旧客户端的关闭、回调或重连交错;
- 上层看到“未连接”,底层却可能仍处于 connecting、closing 或 reconnecting;
- 旧连接的异步回调可能影响新一轮连接。
### 3.2 WebSocket error/disconnected 没有强制回收旧 transport
修复前,`control_plane_multimodal_mark_transport_unavailable()` 会清理上层状态,但只有部分原因会触发底层 transport stop:
- 协议错误;
- 文本发送失败;
- listening 超时;
- no-response 超时。
以下两个最直接代表 WebSocket 已失效的原因却没有进入强制关闭条件:
```text
CONTROL_PLANE_TRANSPORT_LOSS_WS_ERROR
CONTROL_PLANE_TRANSPORT_LOSS_WS_DISCONNECTED
```
结果是上层状态已经退休,底层旧客户端却可能继续存在并自行重连。
### 3.3 断线事件没有稳定地调度新一轮连接
修复前,收到 transport error/disconnected 后主要执行:
- 清理待处理的云端业务状态;
- 停止音频上行。
但没有在这两个事件中明确调度一次新的 WebSocket 启动。因此恢复可能依赖底层自动重连,或者依赖后续其他状态变化碰巧触发连接。
### 3.4 一次启动失败后,重试请求可能被消费掉
Wi-Fi 状态任务会消费 `ws_start_requested`。修复前,如果本次 `control_plane_try_start_multimodal_from_state_machine()` 返回失败,请求已经被消费,但没有立即重新挂起。
这样会出现:
```text
收到重连请求
↓
本次启动失败
↓
请求被消费
↓
没有新的触发条件
↓
设备长期停留在未连接状态
```
## 4. 根本原因
根本原因不是单次网络错误,而是 WebSocket 恢复流程的所有权不唯一:
```text
底层库自动重连旧客户端
+
control-plane 清理并重建会话
+
WS 错误时旧 transport 未被强制回收
+
启动失败后重试请求可能丢失
↓
旧连接、旧 Header 和新会话状态互相干扰
↓
运行中持续连接失败,重启后因全部状态重建而恢复
```
## 5. 修复方案
### 5.1 禁止底层 WebSocket 自动重连
配置改为:
```c
.disable_auto_reconnect = true,
.enable_close_reconnect = false,
```
底层 transport 只负责一条连接的建立、收发和关闭,不再私自决定重连。
重连所有权统一交给 control-plane:
```text
WebSocket 异常
↓
通知 control-plane
↓
退休旧 transport
↓
重新进入多模态启动流程
↓
重新取得当前有效鉴权信息并生成连接 Header
↓
创建新的 WebSocket 客户端
```
### 5.2 WS error/disconnected 必须关闭旧 transport
以下原因被加入 `close_transport` 判断:
```text
CONTROL_PLANE_TRANSPORT_LOSS_WS_ERROR
CONTROL_PLANE_TRANSPORT_LOSS_WS_DISCONNECTED
```
这保证上层判定 WebSocket 不可用时,底层旧客户端也会被停止,而不是留在后台继续使用旧状态重连。
### 5.3 断线和错误事件主动调度重连
处理以下事件时,明确调用 `control_plane_schedule_websocket_wait_task()`:
```text
CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_DISCONNECTED
CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_ERROR
```
因此恢复不再依赖底层自动重连或其他偶发事件。
### 5.4 启动失败后重新挂起重试请求
Wi-Fi 状态任务尝试启动 WebSocket 后会检查结果:
```c
if (!ws_started) {
(void)control_plane_schedule_websocket_wait_task();
}
```
本次失败不会永久吃掉重试机会。后续仍由既有状态机和退避节奏继续尝试。
### 5.5 增加架构守卫
架构检查脚本增加规则,防止后续修改重新引入以下行为:
- 再次开启底层 WebSocket 自动重连;
- WS error/disconnected 不再回收旧 transport;
- transport error/disconnected 不再调度 control-plane 重连;
- 启动失败后不重新安排重试。
这条守卫的目的不是运行时恢复,而是在构建或代码检查阶段阻止同类设计回退。
## 6. “fresh auth”的准确含义
这里的 `fresh auth` 更准确地说是:
> 不复用旧 WebSocket 客户端内部固化的鉴权 Header,而是销毁旧 transport,重新通过 control-plane 的鉴权与建连流程构造连接。
它不必然表示每次断线都强制向服务端申请一个全新的 JWT。设备鉴权模块仍可能复用尚未过期、权限范围正确的有效 JWT。
两者的区别是:
| 概念 | 含义 |
| --- | --- |
| 重新走鉴权建连流程 | 重新检查当前鉴权状态,并为新客户端生成当前有效的连接参数和 Header。此次修复保证做到这一点。 |
| 强制刷新 JWT | 无论现有 JWT 是否有效,都向鉴权服务申请新 JWT。此次提交并没有要求每次都这样做。 |
## 7. 修复后的影响
### 正面影响
- WebSocket 重连只有一个控制者;
- 每轮重连前会回收旧客户端;
- 避免旧连接回调污染新会话;
- 重连会重新经过项目自己的鉴权和状态检查;
- 单次启动失败后仍能继续重试;
- 运行中断线后的行为更接近重启设备后的干净启动流程。
### 代价
- 每次 WebSocket error/disconnected 都需要销毁并新建 transport;
- 相比底层直接原地自动重连,会多经过一次清理和 control-plane 调度;
- 如果需要刷新鉴权,还会增加鉴权请求时间;
- 但换来的是状态一致性和可恢复性,避免设备陷入长期不可用状态。
该提交只重建多模态 WebSocket,不会主动让 Wi-Fi STA 断开并重新关联路由器。Wi-Fi 重关联属于另一层恢复策略。
## 8. 关键日志
修复后重点关注:
```text
Schedule fresh-auth reconnect after transport disconnect
Schedule fresh-auth reconnect after transport error
Retire unavailable transport: reason=...; next start requires fresh auth
```
预期顺序大致为:
```text
transport error/disconnected
↓
Retire unavailable transport
↓
Schedule fresh-auth reconnect
↓
旧 transport 完成清理
↓
鉴权准备
↓
新 WebSocket 建立成功
```
如果只看到反复的 schedule,但没有旧 transport 清理完成或新鉴权开始,应继续检查 transport stop 是否卡住。如果鉴权成功但新 WebSocket 仍连接失败,应继续区分 DNS、TCP/TLS 和远端拒绝问题。
## 9. 建议验证方法
1. 正常完成多轮语音对话,确认基础流程不受影响;
2. 对话空闲时短暂关闭路由器或屏蔽网络,制造 WebSocket disconnect;
3. 恢复网络,确认设备会退休旧 transport,并自动创建新连接;
4. 在 TTS 播放或音频上行期间制造断网,确认上行停止且不会留下假连接;
5. 连续重复断网、恢复至少 10 次,确认不需要重启设备;
6. 检查每次新连接前是否都有明确的旧 transport 清理过程;
7. 检查没有出现底层 WebSocket 自行无限重连的日志循环。
## 10. 涉及文件
```text
components/control_plane/src/multimodal/control_plane_multimodal_runtime.c
components/control_plane/src/multimodal/control_plane_multimodal_session_service.c
components/control_plane/src/runtime/control_plane_runtime_workers.c
components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c
tools/check_architecture_guards.sh
```