mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
真因(实测定位):服务端 writePong 只调 writeFrameHeader,**不 Flush**。 pong 只有两个字节,且设备空闲时没有任何别的写会顺带把 bufio 缓冲刷出去 —— 于是 pong 永远留在服务端缓冲里。 链路:客户端每 30s 发一个 ping(pingLoop)→ 服务端算出 pong 却没发出 → 客户端的读循环设的是「2 倍 ping 间隔」读超时(默认 60s)→ 每 60 秒准点 i/o timeout → 桥断开 → 3s 后重连 → 服务端 markOffline 注销 outputch, 重连后再注册。 生产日志就是这个指纹(online :20 → offline 下一分钟 :20 → 重连 :23, 连续数小时无一次例外);面板上表现为设备通道/工具凭空消失又出现, /devices 列表跟着闪。 改法:writePong 复用 writeFrame(它 Flush)。另把客户端读循环退出时的 静默 return 改成带错误与 opcode 的日志 —— 此前断线真因在设备侧完全不可见, 只能靠对端日志倒推,正是这次排查一开始卡住的地方。 回归用例 TestWSPingGetsPongWhileIdle:只发一个 ping,随后什么都不发, 要求 2s 内必须收到 pong。**反向验证过**:把修复改回 writeFrameHeader, 用例即以 `read tcp ...: i/o timeout` 失败(与生产症状一致)。
54 lines
1.9 KiB
Go
54 lines
1.9 KiB
Go
package remotedevice
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 心跳回包必须**真的发出去**:pong 只有两个字节,且设备空闲时没有任何别的写
|
||
// 会顺带把 bufio 缓冲刷出去——`writePong` 一旦忘了 Flush,pong 就永远留在
|
||
// 服务端缓冲里。
|
||
//
|
||
// 这就是「device channel 不稳定」的真因(实测):客户端每 30s 发一个 ping,
|
||
// 服务端算好了 pong 却没发;客户端的读循环设的是 2 倍 ping 间隔(默认 60s)
|
||
// 读超时,于是**每 60 秒准点断开一次**,重连后 outputch 被注销又注册,
|
||
// 模型侧看到的就是工具/通道凭空消失又出现。
|
||
//
|
||
// 本用例只发一个 ping,随后**什么都不发**:pong 必须在无后续流量的情况下到达。
|
||
func TestWSPingGetsPongWhileIdle(t *testing.T) {
|
||
reg := NewRegistry()
|
||
token := "test-token-ping"
|
||
reg.SetAcceptToken(func(provided string) bool { return provided == token })
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||
defer srv.Close()
|
||
|
||
cli := dialTestWS(t, srv.URL, token)
|
||
defer cli.close()
|
||
|
||
// 先走完 hello + bind(服务端要先把设备登记进 conns,pong 才写得回来)。
|
||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"ping-dev","name":"前端机","kind":"computer","caps":["cmd"]}}`))
|
||
cli.readHelloAckAndBind(t, token)
|
||
|
||
cli.sendFrame(0x9, nil) // ping
|
||
|
||
if err := cli.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||
t.Fatalf("set read deadline: %v", err)
|
||
}
|
||
payload, isClose, opcode, err := readFrame(cli.rw.Reader)
|
||
if err != nil {
|
||
t.Fatalf("2s 内没收到 pong(writePong 忘了 Flush?): %v", err)
|
||
}
|
||
if isClose {
|
||
t.Fatal("连接被关闭,而不是回了 pong")
|
||
}
|
||
if opcode != 0xa {
|
||
t.Fatalf("期望 pong(0xa),实际 opcode=%#x payload=%q", opcode, payload)
|
||
}
|
||
if len(payload) != 0 {
|
||
t.Fatalf("pong 不该带负载,实际 %q", payload)
|
||
}
|
||
}
|