Commit Graph

3 Commits

Author SHA1 Message Date
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
3988c19880 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(复现故障形态)⇒ 定时器与间隔两条转红。
2026-10-05 13:51:20 +08:00
bca791a14b fix(gui): 设备桥 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、不重试。

## 修复

发现成功后回写 deviceBridge.set({gateway}),让主进程真正用上。

两个刻意的约束:

1. **仅当本地 gateway 为空才写**(!cur.gateway)—— 手填值优先。
   用户可能故意指向内网直连或灰度实例,发现只做缺省补全。
2. **只传 gateway,不传 token** —— /device/gateway 不返回设备令牌
   (ws_token),写了反而会写坏已有的正确 token。

deviceBridge.set 会 stop+start 桥,改完立即生效,无需重启进程。

## 顺带记下一个实测坑(已写进门禁)

设备桥 WS 的认证**不走 cookie**。registry.go ServeWS 只认三种来源:
?token= / X-API-Key / Sec-WebSocket-Protocol: homeagent.<token>,
cookie 完全不参与 acceptBind。

排查时曾误以为「发 cookie 就能过」—— 实测服务端仍 401。
cookie 那条路是错的,已还原 connections.json。

## 门禁(cmd/gui/device-gateway-discovery.test.mjs)

9 条判据,文本扫描(自动回写由 refreshAll 的 15s 定时器驱动,
构造那个时序需真 Electron + 真后端 + 会话 cookie,CI 里没有)。

覆盖:发现调用存在、回写接通、本地非空则不覆盖、只传 gateway、
preload 暴露 set、主进程注册 handler、get 返回明文 token、
set 会重启桥、connectDeviceWS 把 token 放进 query。

变异测试验证非假红:
- 删掉回写块(复现本次故障形态)⇒ 第 2、3、4 条转红;
- 去掉「本地为空才写」改成无条件覆盖 ⇒ 第 3 条转红。

排障过程中判据自身报过 3 次假红(正则窗口不足、字符串切片后仍按
match 数组用 setHandler[0]),均已修正并记在注释里。
2026-10-05 13:51:20 +08:00