新增: 小程序端跨端AI对话
This commit is contained in:
@@ -11,11 +11,23 @@
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 机制:
|
||||
* - 重连:指数退避(baseDelay * 2^n,封顶 maxDelay),握手失败/网络断/服务关闭触发
|
||||
* - 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* - JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
|
||||
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
|
||||
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
|
||||
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
|
||||
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
|
||||
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
|
||||
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
|
||||
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
|
||||
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
|
||||
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
|
||||
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
|
||||
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
|
||||
*
|
||||
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
|
||||
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
@@ -34,6 +46,25 @@ export interface WsHandlers {
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
|
||||
interface QueuedCmd {
|
||||
cmd: MiniCommand
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
|
||||
const HANDSHAKE_TIMEOUT_MS = 12000
|
||||
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
|
||||
const CONNECT_TIMEOUT_MS = 8000
|
||||
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
|
||||
const PENDING_MAX = 50
|
||||
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
|
||||
const PENDING_TTL_MS = 60000
|
||||
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
|
||||
const HEARTBEAT_DEAD_FACTOR = 2
|
||||
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
@@ -48,9 +79,22 @@ class WsClient {
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
/**
|
||||
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
||||
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
||||
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
|
||||
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
|
||||
*/
|
||||
private pendingQueue: QueuedCmd[] = []
|
||||
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
|
||||
private lastInboundAt = 0
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
@@ -68,20 +112,58 @@ class WsClient {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.openSocket()
|
||||
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
|
||||
this.reconnectAttempts = 0
|
||||
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
||||
this.reconnectNow()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.reconnectAttempts = 0
|
||||
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/** 若已断开则触发重连(onShow 时调用,小程序后台回前台) */
|
||||
/**
|
||||
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
|
||||
*
|
||||
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
|
||||
* - disconnected:onClose 已触发,直接重连;
|
||||
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
|
||||
*
|
||||
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
|
||||
*/
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.status === 'disconnected' && !this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
if (this.manualClose) return
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting') {
|
||||
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
|
||||
*
|
||||
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
|
||||
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
|
||||
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
|
||||
*/
|
||||
pauseBackground(): void {
|
||||
this.stopHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
|
||||
*/
|
||||
resumeBackground(): void {
|
||||
if (this.status === 'connected' && this.handshaked) {
|
||||
this.startHeartbeat()
|
||||
} else {
|
||||
this.resumeIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +174,16 @@ class WsClient {
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (!this.socket || !this.handshaked) {
|
||||
console.warn('[WsClient] 未连接或握手未完成,丢弃命令', cmd.cmd)
|
||||
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
||||
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
||||
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
|
||||
if (this.manualClose) {
|
||||
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
this.enqueuePending(cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
@@ -106,15 +196,77 @@ class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部实现 ──────────────────────────────────────────
|
||||
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
|
||||
private enqueuePending(cmd: MiniCommand): void {
|
||||
if (this.pendingQueue.length >= PENDING_MAX) {
|
||||
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
|
||||
this.pendingQueue.shift()
|
||||
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
|
||||
}
|
||||
this.pendingQueue.push({ cmd, ts: Date.now() })
|
||||
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
|
||||
*
|
||||
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
|
||||
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
|
||||
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
|
||||
*/
|
||||
private flushPendingQueue(): void {
|
||||
if (this.pendingQueue.length === 0) return
|
||||
const now = Date.now()
|
||||
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const item = this.pendingQueue.shift()!
|
||||
if (now - item.ts > PENDING_TTL_MS) {
|
||||
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (this.socket) {
|
||||
this.socket.send({ data: JSON.stringify(item.cmd) })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
|
||||
|
||||
/**
|
||||
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
|
||||
*
|
||||
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
|
||||
* 统一经此,保证:
|
||||
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
|
||||
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
|
||||
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
|
||||
*/
|
||||
private reconnectNow(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.cleanup()
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
let task: UniApp.SocketTask
|
||||
try {
|
||||
this.socket = uni.connectSocket({
|
||||
task = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
@@ -125,15 +277,32 @@ class WsClient {
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.socket = task
|
||||
|
||||
this.socket.onOpen(() => this.handleOpen())
|
||||
this.socket.onMessage((res) => this.handleMessage(res))
|
||||
this.socket.onClose((res) => this.handleClose(res))
|
||||
this.socket.onError((res) => this.handleError(res))
|
||||
task.onOpen(() => this.handleOpen())
|
||||
task.onMessage((res) => this.handleMessage(res))
|
||||
task.onClose((res) => this.handleClose(res))
|
||||
task.onError((res) => this.handleError(res))
|
||||
|
||||
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
|
||||
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (this.status === 'connecting') {
|
||||
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 */
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
|
||||
private handleOpen(): void {
|
||||
// onOpen 到达,取消连接超时
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
@@ -145,14 +314,35 @@ class WsClient {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
|
||||
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (!this.handshaked) {
|
||||
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
const text = typeof res.data === 'string' ? res.data : ''
|
||||
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
|
||||
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
|
||||
if (typeof res.data !== 'string') {
|
||||
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
|
||||
return
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
@@ -178,23 +368,72 @@ class WsClient {
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 握手通过后无显式 ack,首条业务消息即视为握手成功;
|
||||
* 失败时 relay 发 {"kind":"control","error":"..."} + 关连接) */
|
||||
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// relay 握手失败帧(relay.rs:200-238):{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"}
|
||||
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
|
||||
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
|
||||
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
|
||||
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
|
||||
let parsedOk = false
|
||||
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
|
||||
parsedOk = true
|
||||
} catch (e) {
|
||||
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
|
||||
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
|
||||
}
|
||||
|
||||
if (parsedOk && parsed) {
|
||||
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
|
||||
if (parsed.error !== undefined) {
|
||||
console.error('[WsClient] 握手失败', parsed.error, text)
|
||||
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
|
||||
this.clearHandshakeTimer()
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
|
||||
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
|
||||
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
|
||||
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
|
||||
// device 离线则无响应,文案保持「已连接中继」诚实表述。
|
||||
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
|
||||
this.flushPendingQueue()
|
||||
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
|
||||
return
|
||||
}
|
||||
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
|
||||
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败', text)
|
||||
console.error('[WsClient] 握手失败(includes 兜底)', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.clearHandshakeTimer()
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 首条非错误消息即视为握手通过(实际握手后 relay 会路由 device 的 Event 过来)
|
||||
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// 该首消息也是业务消息,转 handleMessage 处理(此时 handshaked=true,走 BroadcastMessage 解析路径)
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
|
||||
this.flushPendingQueue()
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
@@ -210,14 +449,31 @@ class WsClient {
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 不直接 scheduleReconnect:onClose 会跟着触发,在 onClose 统一处理重连
|
||||
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
|
||||
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
|
||||
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
|
||||
if (this.status === 'connecting' || this.status === 'handshaking') {
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'}) */
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
// 连接成功初始化活性时间(首次心跳检查不立即判死)
|
||||
this.lastInboundAt = Date.now()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
|
||||
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
|
||||
const now = Date.now()
|
||||
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
|
||||
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
@@ -237,36 +493,87 @@ class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避重连(baseDelay * 2^attempts,封顶 maxDelay) */
|
||||
/**
|
||||
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
|
||||
*
|
||||
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
|
||||
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
|
||||
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
|
||||
*
|
||||
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
|
||||
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
||||
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const delay = Math.min(cfg.reconnectBaseDelay * Math.pow(2, attempt), cfg.reconnectMaxDelay)
|
||||
const exp = Math.min(attempt, 6)
|
||||
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
|
||||
const jitter = 0.5 + Math.random() * 0.5
|
||||
const delay = Math.round(base * jitter)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.openSocket()
|
||||
this.reconnectNow()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清理 socket + 定时器(连接关闭时调) */
|
||||
/** 清握手超时定时器(握手成功/失败时调) */
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
|
||||
*
|
||||
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
|
||||
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
|
||||
*/
|
||||
private destroySocket(task: UniApp.SocketTask): void {
|
||||
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
|
||||
try {
|
||||
task.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
|
||||
*
|
||||
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearHandshakeTimer()
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
this.pendingQueue = []
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
|
||||
Reference in New Issue
Block a user