mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
用户要求全面修复「设备桥自动链接」这条链路上的问题。三个真实缺陷,
前两个是**服务端/客户端真 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 服务端与客户端自身。
63 lines
1.8 KiB
Go
63 lines
1.8 KiB
Go
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])
|
||
}
|
||
}
|