从 2026-05-11 开始,多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程(openclaw_gateway.exe)明确在运行,/health 返回 200,但面板显示"已停止重连,请手动刷新"——用户必须手动重启才能恢复。
关键线索在控制台:看不到任何 [ws] 计划重连 的日志。
通常断线后客户端会频繁尝试重连,日志里应该是满屏的重连记录。没有日志意味着问题不在重连失败,而在压根没启动重连机制。说明客户端进入了某个永久休眠状态。
三条独立的永久断开路径
代码审查发现了三条完全不同的、独立的永久断开路径。任意一条命中,就会停止调度任何重连计时器。
路径 A:凭据刷新异常
在 _scheduleReconnect 方法末尾,每次普通重连都会调用:
this._reconnectTimer = setTimeout(() => {
if (!this._intentionalClose) {
this._refreshCredentialsAndReconnect(0)
}
}, delay)
而 _refreshCredentialsAndReconnect 是异步方法,catch 分支没有任何后续处理:
} catch (e) {
console.error('[ws] 刷新凭据失败:', e)
this._setConnected(false, 'error', `凭据刷新失败: ${e}`)
}
如果 api.readOpenclawConfig() 或 api.autoPairDevice() 抛错——磁盘 IO 抖、Rust 端处理器忙、Tauri IPC 队列阻塞——这次重连就永久终止。便携磁盘的 IO 抖动 + 配置文件读写争抢时最容易发生。
路径 B:认证失败后的强制关闭
当 WebSocket 收到 1008 unauthorized 响应时的处理逻辑:
if (this._authRetryCount < 2) {
this._authRetryCount++
this._refreshCredentialsAndReconnect()
return
}
this._setConnected(false, 'auth_failed', `认证失败: ${e.reason}。请检查 Gateway Token 配置。`)
this._intentionalClose = true // ← 永久关闭标记
this._flushPending()
return
设置 _intentionalClose = true 之后,所有重连逻辑都被 if (!this._intentionalClose) 短路。即使只是 Gateway 重启窗口碰好出现 token 短暂不匹配,也会一次性把客户端打死,必须刷新页面才能恢复。
路径 C:快速重连配额耗尽
定义了常数 MAX_RECONNECT_ATTEMPTS = 60:
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
this._setConnected(false, 'error', `连接失败,已停止重连。请手动刷新页面重试。`)
return
}
客户机晚上挂机,Gateway 因为便携磁盘 GC 或 Windows 休眠争抢资源短暂掉线,客户端尝试 60 次仍未成功重连,就永久停摆。早上用户回来面板已成死链。
为什么三条缺陷同时存在
这是逐步累加的历史债:
- 路径 B 是在修复"避免无限自动配对循环"时添加的保护,但用错了对象——
_intentionalClose=true本来是用户主动断开的语义,不该用在被动失败上 - 路径 C 是早期"避免无穷重试"的防护,但 60 次后完全放弃而不留任何复活路径是绝对错误
- 路径 A 是在"凭据 reload"改动时把所有重连都改走 refreshCredentials,没留意 catch 分支已经成了终态
三条各自独立、都能单独打死客户端,组合在一起命中率非常高。
修复方案
核心思想是永不彻底放弃。任何"快速重连配额耗尽"的分支都转入慢轮询,留出窗口让用户改配置或等待 Gateway 恢复后能自动复连。
新增辅助方法 _schedulePoll(delayMs, kind) 作为慢轮询的触发器:
const AUTH_RETRY_LIMIT = 2
const SLOW_POLL_DELAY_AUTH = 60_000 // 认证持续失败:1 分钟探一次
const SLOW_POLL_DELAY_GENERAL = 300_000 // 一般持续失败:5 分钟探一次
_schedulePoll(delayMs, kind) {
this._clearReconnectTimer()
this._reconnectAttempts = 0
if (kind === 'auth') this._authRetryCount = 0
this._reconnectState = 'scheduled'
this._pendingReconnect = true
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null
if (this._intentionalClose) return
this._reconnectState = 'attempting'
if (kind === 'auth') {
this._refreshCredentialsAndReconnect(0)
} else {
this._doConnect()
}
}, delayMs)
}
慢轮询命中后失败会重新进入 _scheduleReconnect 快速重连周期(因为 _reconnectAttempts 重置为 0),相当于"快速 60 次 → 慢一次 → 快速 60 次 → 慢一次"的循环,永不放弃。
三条修复并行:
Fix A:普通重连直接走 _doConnect(),凭据刷新隔离到认证失败分支,避免配置读取错误牵连普通重连。
Fix B:认证失败耗尽后转入 _schedulePoll('auth'),不再设置 _intentionalClose=true,给认证恢复留出 60 秒的探测窗口。
Fix C:重连次数超过 MAX_RECONNECT_ATTEMPTS 后转入 _schedulePoll('general'),设置 UI 状态为"连接持续失败,300 秒后重试"而不是终态。
慢轮询失败后重新进入快速重连周期,形成"快速 60 次 → 慢一次 → 快速 60 次"的循环,永不放弃。
防回归验证
新增 wakou-full/src/lib/ws-client.slow-poll.test.js,覆盖 7 个 case:
- Fix A:普通重连命中
_doConnect,不调用_refreshCredentialsAndReconnect - Fix C:MAX_RECONNECT_ATTEMPTS 后状态是
reconnecting(非终态error),5 分钟后触发_doConnect - Fix C:第二轮慢轮询失败后还能再调度快速重连
- Fix B:
_schedulePoll('auth')等待 60 秒调用_refreshCredentialsAndReconnect(0) - Fix B:
_schedulePoll('general')等待后调用_doConnect _intentionalClose=true时慢轮询应跳过_refreshCredentialsAndReconnect抛错路径调度慢轮询
测试全部通过(7/7)。
外部因素的叠加
这个时期同步发生了另一个外部问题:CDN 对 WebSocket 空闲连接会在 4-9 分钟后强制切断。客户端收到连接中断后根据指数退避策略重连,如果恰好命中上述三条路径之一,就进入永久断开状态。这解释了为什么故障特别在长时间挂机后高发——空闲足够长,CDN 必定切断,而后续重连很容易落入某条缺陷路径。两个问题各自独立,但组合效果是"挂机过夜必死"。
排查验证
故障修复后,可以用以下方式验证重连状态:
__clawpanelWsClient.getConnectionInfo()
// {
// connected: false,
// reconnectState: 'scheduled' | 'attempting',
// reconnectAttempts: 0~60,
// ...
// }
reconnectState='scheduled' 且 reconnectAttempts=0 表示在慢轮询窗口正常工作;reconnectState='idle' 且 connected=false 是老的永久断开路径。
重连机制里避免静默终态是最基本的要求。任何异常路径都必须有可见的状态提示和后续操作入口,否则用户端看到的就是死机。这次故障的根本教训是,不能让客户端在任何情况下进入"不可恢复"的状态而没有任何提示。慢轮询的引入给了所有失败情景一个"最后的机会",即使前面的快速重连机制彻底耗尽了,用户等待足够长的时间后系统仍有自动恢复的可能。
■