Files
HomeAgent/internal/plugins/remotedevice/ping_test.go
JianFeeeee 75f377fd4d fix(remotedevice): 心跳 pong 忘了 Flush —— 修「设备通道每 60 秒掉线重连」
真因(实测定位):服务端 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` 失败(与生产症状一致)。
2026-09-14 11:15:21 +08:00

54 lines
1.9 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 remotedevice
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 心跳回包必须**真的发出去**pong 只有两个字节,且设备空闲时没有任何别的写
// 会顺带把 bufio 缓冲刷出去——`writePong` 一旦忘了 Flushpong 就永远留在
// 服务端缓冲里。
//
// 这就是「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服务端要先把设备登记进 connspong 才写得回来)。
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 内没收到 pongwritePong 忘了 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)
}
}