fix(devicebridge): 修复设备反复掉线/静默失联 —— ping 路径断连 + bind 结果无人处理

用户要求全面修复「设备桥自动链接」这条链路上的问题。三个真实缺陷,
前两个是**服务端/客户端真 bug**(生产日志实证),第三个是我起初误判的。

## 缺陷 1(最严重):未 bind 时收到 ping → 服务端直接关连接

原实现:
    err := r.wsWriteLocked(curID, writePong)
    if err != nil { return }   // ← 关连接

而 conns 表**只在 bind 成功后才写入**(bind 前刻意不暴露连接给查询/命令
路径)。于是「握手完成、bind 尚未到达」这个窗口里来的 ping 找不到写入口,
函数返回错误,读循环 return —— 把连接关掉了。

生产后果(journalctl 实证):客户端每 30s ping 一次,只要有一次落在未 bind
窗口就断连。日志里同一设备 20 秒内多次 "ws connected",online/offline 与
输出通道注销/注册反复交替:

  17:29:14 ws connected → 17:29:17 ws connected → 17:29:24 ws connected
  → 17:29:29 → 17:29:35 → 17:29:40 online → 17:30:18 offline → ...循环

修法:pong 直接写本连接的 writer。此时该连接尚未进入 conns(没有 Push* 会
碰它的 writer),不存在并发写风险;已 bind 时才取写锁(Push* 可能正在写
同一 buffer)。

判据 TestPingBeforeBindDoesNotDropConnection 直打 bug 点(只握手、不发
hello/bind、发 ping、要求 pong),修复前报 `EOF`,修复后通过。

## 缺陷 2:bind_ack 的 ok 完全没被检查 → 失败静默失联

原实现(客户端):
    case "hello_ack", "bind_ack":
        log.Printf("... device=%v", msg["device"])

两处错:
- **取错字段**:服务端成功时回 {"op":"bind_ack","ok":true},没有 device
  字段,于是日志永远显示 `bind_ack device=<nil>`。这让我起初误判成"绑定
  失败",实际连接是好的(直连与经反代现象完全一致)。
- **不看 ok**:bind 被拒时服务端回 ok:false + error 并关闭连接,客户端既不
  报错也不重连,设备静默失联 —— TCP/WS 通但从未登记进网关。

修法:分别处理两种 ack;bind 判 ok,失败记原因并通知宿主。新增
Bridge.Bound() / BindError() / OnBoundState():**连接成功 ≠ 设备可用**,
只看连接状态的健康检查会给出假阳性。

判据 TestBindFailureIsObservable / TestBindStateCallback。

## 缺陷 3:-chat 一次性模式下桥存活时间过短

不是我最初以为的"bind 失败"。真因:`-chat` 走进 oneshot 后立刻 return,
触发 defer stopDeviceBridge(),桥只活几百毫秒,设备来不及完成 hello→bind。

修法:退出前等 bind 确认(最多 3s);bind 明确被拒则打印原因,不静默丢弃。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 经**反代自动发现**连接,保持连接期间查询服务端:

  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  bind 成功,设备已登记
  /api/v1/device/online → waiter-mainserver, online=true, caps=[11 项]

长连接稳定性:70 秒(跨 2 个 ping 周期)三次采样设备始终在线,
无 read loop exit / bind rejected 日志。

## 附:反代通路本身的判定性对照

裸客户端(直接构造 hello/bind 帧)**经反代**与**直连 9890** 返回逐字节
一致(bind_ack ok=true、设备注册、online=true)。所以这条链路上反代
不背锅,问题全在 remotedevice 服务端与客户端自身。
This commit is contained in:
JianFeeeee
2026-09-25 15:12:17 +08:00
parent 7f5bf1670f
commit 94c74b2ee6
5 changed files with 276 additions and 4 deletions

View File

@ -862,3 +862,77 @@ func TestAwaitResultReturnsResultDeliveredBeforeWaiter(t *testing.T) {
t.Fatalf("unexpected early result: %v", got)
}
}
// ===== ping 帧处理:不得因未 bind 而断连 =====
// ★ 未 bind(或已 bind)时收到 ping,服务端必须回 pong,**不得关闭连接**。
//
// 真实 bug(生产日志实证):原实现用 wsWriteLocked(curID, writePong),而
// conns[curID] 只在 bind 成功后才写入 ⇒ 握手后、bind 前到来的 ping 找不到
// 写锁入口,函数返回错误,读循环直接 return 关连接。
//
// 后果:客户端每 30s ping 一次,只要有一次落在未 bind 窗口就断连;生产日志里
// 同一设备 20 秒内多次 "ws connected" 且 online/offline 反复交替,正是这个。
//
// 判据直打 bug 点:只握手、**不发 hello/bind**,发 ping,要求收到 pong。
// readMsg 会跳过 pong,所以这里直接读原始帧。
func TestPingBeforeBindDoesNotDropConnection(t *testing.T) {
reg := NewRegistry()
token := "tok-ping-bind"
reg.SetAcceptToken(func(s string) bool { return s == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
c := dialTestWS(t, srv.URL, token)
defer c.close()
// 只握手,不发 hello/bind —— 模拟「尚未绑定完成」的窗口
c.sendFrame(0x9, nil) // ping
// 必须收到 pong;EOF/错误说明服务端在 ping 路径上断了连接
c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, _, opcode, err := readFrame(c.rw.Reader)
if err != nil {
t.Fatalf("未 bind 时 ping 导致连接不可用(服务端 bug): %v", err)
}
if opcode != 0xA {
t.Fatalf("期望 pong(0xA),收到 opcode=%#x", opcode)
}
}
// 已 bind 的设备发 ping 同样必须得到 pong,且连接与在线状态都保持。
func TestPingAfterBindGetsPong(t *testing.T) {
reg := NewRegistry()
token := "tok-ping-bound"
reg.SetAcceptToken(func(s string) bool { return s == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
c := dialTestWS(t, srv.URL, token)
defer c.close()
c.sendText(mustJSON(map[string]interface{}{
"op": "hello",
"device": map[string]interface{}{
"device_id": "d-ping", "name": "d", "kind": "computer", "caps": []string{"status"},
},
}))
c.readHelloAck(t)
c.bindDevice(t, "d-ping", token)
if !reg.Online("d-ping") {
t.Fatal("bind 后设备应在线")
}
c.sendFrame(0x9, nil) // ping
c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, _, opcode, err := readFrame(c.rw.Reader)
if err != nil {
t.Fatalf("已 bind 设备 ping 失败: %v", err)
}
if opcode != 0xA {
t.Fatalf("期望 pong(0xA),收到 %#x", opcode)
}
if !reg.Online("d-ping") {
t.Error("ping 之后设备不应掉线")
}
}

View File

@ -731,9 +731,27 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthor
payload, isClose, opcode, err := readFrame(rw.Reader)
if err != nil {
if err == errPing {
// pong 也走写锁:它可能在 Push* 持锁推送大块数据时到达。
err := r.wsWriteLocked(curID, writePong)
if err != nil {
// ★ 回 pong 绝不能因为「还没 bind」而失败。
//
// 原实现无条件走 wsWriteLocked(curID, writePong),而 conns 表
// **只在 bind 成功后才写入**(bind 之前刻意不把连接暴露给查询/
// 命令路径)。于是握手完成、bind 尚未到达时来的 ping 找不到写
// 入口 → 返回错误 → 读循环 return → **连接被关掉**。
//
// 生产后果(日志实证):客户端每 30s 一次 ping,只要有一次落在
// 未 bind 窗口就断连,表现为同一设备 20 秒内多次 ws connected、
// online/offline 反复交替,输出通道跟着反复注销/注册。
//
// 正确做法:pong 直接写本连接的 writer。此时该连接**尚未**进入
// conns(即没有 Push* 会碰它的 writer),不存在并发写风险;
// 已 bind 时才需要取写锁(Push* 可能正在写同一 buffer)。
var werr error
if bound {
werr = r.wsWriteLocked(curID, writePong)
} else {
werr = writePong(rw.Writer)
}
if werr != nil {
return
}
continue