Files
HomeAgent/cmd/gui/device-gateway-discovery.test.mjs
JianFeeeee 85d76ad495 fix(gui): 设备桥补心跳与断线重连,兑现 liveness 门禁
## 背景

`device-bridge-liveness.test.mjs`(判据)已在 main 上,但它要求的实现
一直缺席,跑起来是:

    Error: cannot find function sendDeviceControlFrame in main.js

判据注释描述了两个洞,合起来的效果是「一次失联,终身失联」:
  1. 不发 ping —— waiter 侧有 pingLoop,GUI 侧一次都没发过;
  2. 不重连 —— startDeviceBridge 失败就 console.error 结束。

我先前只补了 ping 且命名为 sendDevicePing,判据仍红;本次按判据契约补全。

## 本次实现

**通用控制帧** `sendDeviceControlFrame(sock, opcode, payload)`
控制帧有 ping(0x9)/pong(0xA)/close(0x8) 三种,签名写死一种就换不来另外两种。

**心跳** 每 30s 一个 0x9 帧(与 waiter 的 pingLoop 对齐)。
起在 doConnectDeviceBridge 而非 connectDeviceWS —— 跟着「一条实际连上的
桥」而不是「某次调用」,后者在重连路径上会漏。

**重连** `scheduleDeviceReconnect`:线性退避 base×n、封顶 60s,
成功即归零(否则「昨天断十次、今天断一次」要等 10×base 才有第一次重试)。
退避先递增后算:head 用 max(1,attempt) 会让 attempt 0 与 1 都算出 base,
出现「退避头两次一样」的假象。

三条排程路径,缺一条就终身失联:
  - socket close(已连上后被关)
  - connectDeviceWS reject(**没有** close 事件,最易漏)
  - deviceWanted闸:用户主动停用后不得再排程,否则把已关的桥偷偷拉起来

## 顺带修掉的隐性故障

`connectDeviceBridgeOnce` 里 host/cpus/process/app 等推导原先裸调。
它们在 ws.send 的实参里,抛错会被上层 catch 吞成「连接失败」—— 而实际
连接是好的,表现为「反复重连但永远连不上」。现已逐项 try 包裹。

## 判据分工(避免两个门禁互相打架)

删掉 device-gateway-discovery.test.mjs 里 6 条 keepalive 文本判据
(grep 0x89、找 setInterval 间隔…)。它们只能证明「代码里出现过这些
字符」,证明不了「心跳真发出去了」。liveness 那份用 node:vm 跑真实函数,
是强判据;弱的那份会在实现重构时先红、掩盖真信号。

## 验证

device-bridge-liveness 14 条全绿(改前是 0 条可跑)。
另外 7 个门禁全绿:device-gateway-discovery / device-align /
endpoint-align / connections-io / retry-guard / sse-backoff /
package-config。

对齐过程中判据自身报过 3 次假红(process 未注入、doConnectDeviceBridge
无 return、ws 为 undefined),根因都已写进代码注释。
2026-10-05 14:37:43 +08:00

177 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 设备桥 gateway 自动发现链路的门禁。
//
// ## 要判的是什么
//
// 2026-10-05 实测故障:GUI 面板里能看到设备通道地址,但设备桥连不上。
// 日志只有一行 `ws cookie len=0` + 401,没有任何「地址来自发现」的痕迹。
//
// 根因不是配置错,是**发现结果与实际连接配置从不联通**:
//
// · 渲染进程 app.js 的 loadDiscoveredGateway() 调 /device/gateway,
// 拿到的地址只写进 **state.discoveredGateway**(UI 展示用);
// · 主进程 startDeviceBridge() 读的是 **gui-prefs.deviceBridge.gateway**;
// · 两边从未交换过 —— state 是渲染进程内存,prefs 是主进程文件。
//
// 于是服务端明明已经通过 /device/gateway 告诉了客户端正确地址
// (proxy.go:1078 生成的 url_portal),GUI 却仍在用用户几个月前
// 手填的旧地址,且**永远 401/ECONNREFUSED,且不重试**。
//
// ## 判据形态:文本扫描而非运行时
//
// 与 endpoint-align / device-align 同路子。自动回写的触发点在
// loadDiscoveredGateway() 内,由 refreshAll 的 15s 定时器驱动 ——
// 构造那个时序需要真Electron + 真后端,而 /device/gateway 还要求
// 会话 cookie(CI 里没有)。故从源码文本判「链路是否接通」。
//
// 运行:node device-gateway-discovery.test.mjs
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const APP = join(here, "renderer", "app.js");
const MAIN = join(here, "main.js");
const PRELOAD = join(here, "preload.js");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
const appSrc = readFileSync(APP, "utf8");
const mainSrc = readFileSync(MAIN, "utf8");
const preloadSrc = readFileSync(PRELOAD, "utf8");
// ── 1) 发现函数存在且会调服务端权威端点 ────────────────────────────
check(
"存在 /device/gateway 发现调用",
appSrc.includes("/device/gateway"),
"找不到 —— 服务端发现端点没被调用",
);
// ── 2) 发现结果必须回写到主进程(本判据的核心)──────────────────────
//
// 这是本次修复的要点。若这段回写被删掉(重构时不慎),设备桥会退回
// 「只用用户手填地址」的行为,且没有任何报错——
// 这正是 2026-10-05 那次故障的形态。
check(
"发现结果回写到主进程 deviceBridge.set",
/discoveredGateway[\s\S]{0,600}?deviceBridge\s*\.\s*set\s*\(/.test(appSrc),
"loadDiscoveredGateway 里没有 deviceBridge.set 回写 —— " +
"state.discoveredGateway 只用于 UI,主进程仍在用旧的手填地址",
);
// ── 3) 回写不得覆盖用户手填值 ──────────────────────────────────────
//
// 发现只做**缺省补全**。若用户在设置页手填了内网直连地址(灰度实例、
// 绕过反代),自动回写把它冲掉就是新的回归。判据钉住「本地为空才写」。
// 注意不能用 /set\(\s*\{[\s\S]{0,300}?\}/ —— 调用点前后的 if 判断在花括号
// **外面**,而 300 字窗口会跨到下一个 deviceBridge.set(saveBridgeChannel
// 的手动保存),判据就误报了。改为直接取「发现函数体内」的那次调用。
const discoFn = appSrc.match(
/async function loadDiscoveredGateway\(\)[\s\S]*?\n}/,
);
const setCall = discoFn
? discoFn[0].match(/window\.homeagent\.deviceBridge\.set\([^)]*\)/)
: null;
check(
"回写前检查本地 gateway 为空",
discoFn ? /cur\s*&&\s*!cur\.gateway/.test(discoFn[0]) : false,
discoFn
? "回写处没看到「本地已配置则跳过」的判断 —— 会覆盖用户手填值"
: "找不到 loadDiscoveredGateway",
);
check(
"回写只传 gateway,不覆盖 token",
setCall ? !/token/.test(setCall[0]) : false,
setCall ? "回写里带了 token —— 发现端点不返回设备令牌,会写坏" : "找不到回写调用",
);
// ── 4) IPC 链路完整:preload 暴露 get/set,主进程处理 ──────────────
//
// 回写用的是 window.homeagent.deviceBridge.set(...);contextBridge
// 若没暴露或主进程没注册,这次调用是 undefined TypeError(被 catch
// 吞掉,于是又回到「静默不生效」)。
check(
"preload 暴露 deviceBridge.set",
/deviceBridge[\s\S]{0,120}?set\s*:/.test(preloadSrc),
"contextBridge 未暴露 set —— 渲染进程调不到",
);
check(
"主进程注册 device-bridge:set",
mainSrc.includes('ipcMain.handle("device-bridge:set"'),
"主进程没有该 handler",
);
check(
"主进程 device-bridge:get 返回明文 token",
/device-bridge:get[\s\S]{0,900}?token:\s*db\.token/.test(mainSrc),
"get 未返回 token 明文 —— 渲染侧拿不到设备令牌",
);
// ── 5) device-bridge:set 必须真正重启桥 ───────────────────────────
//
// 只存 prefs 不重启,配置不会立即生效(要等下次进程启动)。
// 这是同一类「存了但没生效」的静默失效。
// 不用大正则匹配整个 handler —— 返回对象里的嵌套 } 会让边界判断很脆。
// 改为取两个 handler 之间的**源码切片**,再在里面判 stop/start 是否存在。
const setStart = mainSrc.indexOf('ipcMain.handle("device-bridge:set"');
const setEnd = mainSrc.indexOf('ipcMain.handle("prefs:get"');
const setHandler =
setStart >= 0 && setEnd > setStart
? mainSrc.slice(setStart, setEnd)
: null;
check(
"device-bridge:set 会重启设备桥",
setHandler
? setHandler.includes("stopDeviceBridge") &&
setHandler.includes("startDeviceBridge")
: false,
setHandler
? "handler 里没有 stop+start —— 新配置要等重启进程才生效"
: "找不到 device-bridge:set handler",
);
// ── 6) 主进程连接必须带 token ─────────────────────────────────────
//
// 设备桥 WS 走 ?token=(registry.go ServeWS 只认 query/X-API-Key/
// Sec-WebSocket-Protocol 三种,**cookie 不参与** acceptBind)。
// 这是实测踩过的坑:曾误以为发 cookie 就能过,实际服务端仍 401。
const connectFn = mainSrc.match(
/function connectDeviceWS[\s\S]*?\n}\n/,
);
check(
"connectDeviceWS 把 token 放进 query",
connectFn ? /token=/.test(connectFn[0]) : false,
connectFn
? "query 里没带 token —— 服务端 acceptBind 会拒"
: "找不到 connectDeviceWS",
);
// ★ keepalive 与重连的判据**不在本文件**,在同目录的
// device-bridge-liveness.test.mjs —— 那份用 node:vm 把真实函数抽出来跑
// 「连接 → 断线 → 重连 → 再断线」,数真实的 ping 帧与重连次数。
// 本文件早期版本曾用文本判据盯keepalive(grep 0x89、找 setInterval 的
// 间隔),已删:它只能证明「代码里出现过这些字符」,证明不了「心跳真的
// 发出去了」「重连真的发生了」。两份盯同一件事时,弱的那份会在实现重构
// 时先红,掩盖真信号。
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
if (failures > 0) {
console.log(`失败 ${failures} 条`);
process.exit(1);
}
console.log("全部通过");