From 3988c19880d20cc6d837adb811fd14a4c2a26f8a Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:45:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E8=AE=BE=E5=A4=87=E6=A1=A5?= =?UTF-8?q?=E8=A1=A5=20WebSocket=20keepalive=EF=BC=8C=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=8F=AF=E8=A7=81=E4=BD=86=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E4=B8=8D=E5=88=B0=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测故障(2026-10-05) 设备能力全部调用失败,但设备**在面板里能看到**。服务端实测: POST /api/v1/device/push {"device_id":"gui-JianF","command":"..."} → {"error":"device gui-JianF not online"} 同一时刻 NAS 上的 waiter 完全正常(last_seen 持续刷新、push 返回 ok)。 ## 根因 GUI **从不发WebSocket ping**。全文件搜不到,是唯一没有 keepalive 的设备端。 链路中间是 frp + TLS 反代,空闲超时(常见 30~60s)会静默关掉连接 —— 没有 close 事件、没有错误日志,只是数据不再流动。 服务端 handleWS 的 defer 于是执行 markOffline(curID) → 从 conns 删除。 于是形成这个割裂状态: · devices 表里有gui-JianF ⇒ webui 面板显示在线 · conns 表里没有 ⇒ PushCmd 报 not online 「可见但不可用」正是这个割裂的表象。registry.go:735 那段 ping/pong 注释修的是「**回** pong 不能因未 bind 而失败」,与「客户端有没有发」 是两个方向的坑 —— 那段代码一直在,却从未被 GUI 用上。 ## 修复 connectDeviceWS 在 opened 后启动 25s 周期的 ping(0x89 控制帧,客户端 必须加掩码)。25s 压在常见 30s 空闲阈值之下。 两条清理路径都 clearInterval:close() 与 sock.on("close")。缺任一条都会 泄漏定时器并向已销毁 socket 写入(进程不退出)。 ## 验证 修复前:gui-JianF 不在 devices 列表,push → not online 修复后:90 秒后仍在列表且 online:true,push → {"status":"ok"} (同一时刻不再掉线,keepalive 生效) 门禁新增 6 条 keepalive 判据(共 15 条): 存在 sendDevicePing / 0x89 控制帧 / opened 后启动定时器 / 间隔 ≤30s / close 清理 / close 事件清理。 变异测试:删掉 setInterval(复现故障形态)⇒ 定时器与间隔两条转红。 --- cmd/gui/device-gateway-discovery.test.mjs | 58 ++++++++++++++++++++++ cmd/gui/main.js | 59 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/cmd/gui/device-gateway-discovery.test.mjs b/cmd/gui/device-gateway-discovery.test.mjs index 4240dba5..4dae59f3 100644 --- a/cmd/gui/device-gateway-discovery.test.mjs +++ b/cmd/gui/device-gateway-discovery.test.mjs @@ -159,6 +159,64 @@ check( : "找不到 connectDeviceWS", ); +// ── 6) keepalive:设备桥必须有主动 ping ────────────────────────────── +// +// 2026-10-05 实测故障:GUI bind 成功、设备进了 devices 表(面板可见), +// 但几秒后能力调用全失败: +// POST /api/v1/device/push {"device_id":"gui-JianF"} +// → {"error":"device gui-JianF not online"} +// 同时 waiter/NAS 完全正常(last_seen 持续刷新)。 +// +// 根因:GUI **从不发 ping**。链路是 frp + TLS 反代,空闲超时(30~60s) +// 静默关连接 → 服务端 handleWS 的 defer 走 markOffline → conns 删除 +// ⇒ 「设备可见但命令下发不到」。 +// +// 服务端 registry.go:735 那段 ping/pong 注释修的是「**回** pong 不能因 +// 未bind 而失败」,不是「客户端有没有发」—— 那是另一个方向的坑。 + +const pingFn = mainSrc.match(/function sendDevicePing[\s\S]*?\n}\n/); +check( + "存在 sendDevicePing", + !!pingFn, + "找不到 sendDevicePing —— 设备桥没有 keepalive 能力", +); +check( + "ping 用 WebSocket 控制帧 0x89", + pingFn ? /0x89/.test(pingFn[0]) : false, + pingFn ? "ping 帧构造不对(应为 0x89 = FIN|ping 且置掩码位)" : "", +); + +// ping 必须被定时器驱动,且间隔要压在常见空闲阈值之下。 +const openBlock = mainSrc.slice( + mainSrc.indexOf("opened = true"), + mainSrc.indexOf("opened = true") + 2000, +); +check( + "连接建立后启动 ping 定时器", + /setInterval/.test(openBlock) && /sendDevicePing/.test(openBlock), + "opened 之后没有启动 keepalive 定时器 —— 连接仍会因空闲超时断开", +); +const intervalMatch = openBlock.match(/setInterval\([^,]*,\s*(\d+)\)/); +check( + "keepalive 间隔 ≤ 30s", + intervalMatch ? parseInt(intervalMatch[1], 10) <= 30000 : false, + intervalMatch + ? `间隔 ${intervalMatch[1]}ms 太长,压不住常见 30s 空闲超时` + : "找不到 setInterval 的间隔参数", +); + +// 关闭路径必须清定时器,否则进程不退出 / 往死 socket 写。 +check( + "close 时清理 ping 定时器", + /close:[\s\S]{0,200}?clearInterval/.test(mainSrc), + "close() 里没有 clearInterval —— 定时器泄漏且会向已销毁 socket 写入", +); +check( + "socket close 事件也清理定时器", + /on\("close"[\s\S]{0,300}?clearInterval/.test(mainSrc), + 'sock.on("close") 里没有 clearInterval —— 异常断开后定时器泄漏', +); + // ── 汇总 ──────────────────────────────────────────────────────────── console.log(""); diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 87a4c71e..01604774 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -1137,10 +1137,26 @@ function connectDeviceWS(url, token, onMsg) { ); } opened = true; + // ★ keepalive:绑定成功后必须周期性发 ping,否则链路空闲超时会 + // 静默断连,导致「设备可见但命令下发不到」(详见 sendDevicePing + // 上方注释)。间隔 25s:压在常见的 30s 空闲阈值之下。 + const pingTimer = setInterval(() => { + try { + sendDevicePing(sock); + } catch (e) { + /* ping 失败不致命,下一轮或 close 事件会处理 */ + } + }, 25000); + sock.__bridgePingTimer = pingTimer; resolve({ send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)), __sock: sock, // 暴露底层 socket 供二进制分块发送 - close: () => sock.destroy(), + close: () => { + // 关闭前必须清掉 ping,否则定时器会继续往已销毁的 socket 写, + // 抛 unhandled error(且进程不会退出)。 + clearInterval(sock.__bridgePingTimer); + sock.destroy(); + }, }); } while (buf.length >= 2) { @@ -1209,11 +1225,52 @@ function connectDeviceWS(url, token, onMsg) { if (!opened) reject(e); }); sock.on("close", () => { + // 断开时停掉 keepalive,否则定时器会一直持有已死的 socket。 + if (sock.__bridgePingTimer) { + clearInterval(sock.__bridgePingTimer); + sock.__bridgePingTimer = null; + } deviceBridge = null; }); }); } +// 发送 WS ping 控制帧(客户端必须加掩码)。 +// +// ★ 为什么必须主动 ping(2026-10-05 实测故障): +// +// GUI 的 bind 能成功(日志有 bind_ok),设备也确实进了服务端 devices 表 +// (webui 面板里能看到),但**几秒后所有设备能力调用全部失败**: +// +// POST /api/v1/device/push {"device_id":"gui-JianF",...} +// → {"error":"device gui-JianF not online"} +// +// 而同一时刻 NAS 上的 waiter 完全正常(last_seen 持续刷新)。 +// +// 根因:GUI **没有任何 keepalive**。全文件搜不到发ping 的地方,而 +// waiter / 嵌入式客户端都有。链路中间是 frp + TLS 反代,它们的空闲超 +// 时(常见 30~60s)会静默关掉这条连接。 +// +// 服务端 handleWS 虽有 ping/pong 处理(registry.go:735,且那段注释 +// 记录了「回 pong 不能因未 bind 而失败」的修复),但那解决的是**服务端 +// 回** pong;连接能不能活下去,取决于**客户端有没有主动发**。 +// 连接一断,handleWS 的 defer 走 markOffline(curID) → conns 里删除, +// 于是「设备可见(devices 表)但不可下发(conns 表)」—— +// 正是本故障的表象。 +// +// 对照组说明这不是服务端能力缺失:同一 registry 下waiter 一直好好的。 +const deviceBridgePingTimerKey = "__bridgePingTimer"; + +function sendDevicePing(sock) { + try { + // 控制帧:FIN + opcode 0x9 (ping),payload 为空;客户端帧必须掩码。 + const mask = crypto.randomBytes(4); + sock.write(Buffer.concat([Buffer.from([0x89, 0x80]), mask])); + } catch (e) { + console.log("[device-bridge] ping failed: " + e.message); + } +} + // 发送 WS text 帧(客户端加掩码) function sendDeviceFrame(sock, text) { const payload = Buffer.from(text, "utf8");