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");