Files
HomeAgent/internal/devicebridge/client/bridge_test.go
JianFeeeee 94c74b2ee6 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 服务端与客户端自身。
2026-09-26 13:49:59 +08:00

63 lines
1.8 KiB
Go
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.

package client
import "testing"
// bind_ack 失败必须被识别(原实现完全不看 ok,失败静默)。
//
// 服务端 bind 被拒时回 {"op":"bind_ack","ok":false,"error":"bind rejected"}
// 并关闭连接。客户端若不看 ok,设备就静默失联:TCP/WS 是通的,
// 但设备从未登记进网关,命令永远下发不到 —— 只看"连接是否建立"的
// 健康检查会给出假阳性。
func TestBindFailureIsObservable(t *testing.T) {
b := New("ws://127.0.0.1:1/api/v1/device/ws", "tok", "dev-1", "n", []string{"status"}, nil)
if b.Bound() {
t.Error("初始不应处于已绑定状态")
}
if b.BindError() != "" {
t.Errorf("初始不应有绑定错误,实际 %q", b.BindError())
}
// 模拟收到 bind 失败
b.markUnbound("bind rejected")
if b.Bound() {
t.Error("失败后不应报 Bound=true")
}
if b.BindError() != "bind rejected" {
t.Errorf("失败原因应被保留,实际 %q", b.BindError())
}
// 成功后状态必须清空错误
b.markBound()
if !b.Bound() {
t.Error("成功后应 Bound=true")
}
if b.BindError() != "" {
t.Errorf("成功后应清空失败原因,实际 %q", b.BindError())
}
}
// 绑定状态变更必须通知宿主(GUI/waiter 据此提示用户)。
func TestBindStateCallback(t *testing.T) {
b := New("ws://127.0.0.1:1/api/v1/device/ws", "tok", "dev-2", "n", nil, nil)
type ev struct {
bound bool
reason string
}
var got []ev
b.OnBoundState(func(bound bool, reason string) { got = append(got, ev{bound, reason}) })
b.markUnbound("token mismatch")
b.markBound()
if len(got) != 2 {
t.Fatalf("应收到 2 次回调,实际 %d", len(got))
}
if got[0].bound || got[0].reason != "token mismatch" {
t.Errorf("第一次回调应为失败: %+v", got[0])
}
if !got[1].bound || got[1].reason != "" {
t.Errorf("第二次回调应为成功: %+v", got[1])
}
}