mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(gui): 设备桥补 WebSocket keepalive,修「设备可见但命令下发不到」
## 实测故障(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(复现故障形态)⇒ 定时器与间隔两条转红。
This commit is contained in:
@ -159,6 +159,64 @@ check(
|
|||||||
: "找不到 connectDeviceWS",
|
: "找不到 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("");
|
console.log("");
|
||||||
|
|||||||
@ -1137,10 +1137,26 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
opened = true;
|
opened = true;
|
||||||
|
// ★ keepalive:绑定成功后必须周期性发 ping,否则链路空闲超时会
|
||||||
|
// 静默断连,导致「设备可见但命令下发不到」(详见 sendDevicePing
|
||||||
|
// 上方注释)。间隔 25s:压在常见的 30s 空闲阈值之下。
|
||||||
|
const pingTimer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
sendDevicePing(sock);
|
||||||
|
} catch (e) {
|
||||||
|
/* ping 失败不致命,下一轮或 close 事件会处理 */
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
sock.__bridgePingTimer = pingTimer;
|
||||||
resolve({
|
resolve({
|
||||||
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
||||||
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
||||||
close: () => sock.destroy(),
|
close: () => {
|
||||||
|
// 关闭前必须清掉 ping,否则定时器会继续往已销毁的 socket 写,
|
||||||
|
// 抛 unhandled error(且进程不会退出)。
|
||||||
|
clearInterval(sock.__bridgePingTimer);
|
||||||
|
sock.destroy();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
while (buf.length >= 2) {
|
while (buf.length >= 2) {
|
||||||
@ -1209,11 +1225,52 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
if (!opened) reject(e);
|
if (!opened) reject(e);
|
||||||
});
|
});
|
||||||
sock.on("close", () => {
|
sock.on("close", () => {
|
||||||
|
// 断开时停掉 keepalive,否则定时器会一直持有已死的 socket。
|
||||||
|
if (sock.__bridgePingTimer) {
|
||||||
|
clearInterval(sock.__bridgePingTimer);
|
||||||
|
sock.__bridgePingTimer = null;
|
||||||
|
}
|
||||||
deviceBridge = 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 帧(客户端加掩码)
|
// 发送 WS text 帧(客户端加掩码)
|
||||||
function sendDeviceFrame(sock, text) {
|
function sendDeviceFrame(sock, text) {
|
||||||
const payload = Buffer.from(text, "utf8");
|
const payload = Buffer.from(text, "utf8");
|
||||||
|
|||||||
Reference in New Issue
Block a user