diff --git a/internal/devicebridge/client/bridge.go b/internal/devicebridge/client/bridge.go index af74ebe..639c549 100644 --- a/internal/devicebridge/client/bridge.go +++ b/internal/devicebridge/client/bridge.go @@ -411,7 +411,12 @@ func (b *Bridge) readLoop() { _ = ws.writePong() continue } - // 超时或其他错误,退出 + // 超时或其他错误,退出。 + // + // **必须记日志**:此前这里静默 return,设备断线的真因(读超时 / 对端 + // 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。 + // 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。 + log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err) return } if isClose { diff --git a/internal/plugins/remotedevice/ping_test.go b/internal/plugins/remotedevice/ping_test.go new file mode 100644 index 0000000..b8dd66b --- /dev/null +++ b/internal/plugins/remotedevice/ping_test.go @@ -0,0 +1,53 @@ +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) + } +} diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index 0bb3c8c..31b5499 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -606,7 +606,13 @@ func writeFrame(w *bufio.Writer, opcode byte, payload []byte) error { } func writePong(w *bufio.Writer) error { - return writeFrameHeader(w, 0xa, 0) + // 必须走 writeFrame(它 Flush)。 + // + // 回归的 bug:这里原先是裸的 writeFrameHeader,**不 Flush**。设备空闲时 + // 没有任何别的写会顺带把 bufio 缓冲刷出去,于是 pong 永远留在服务端缓冲里, + // 客户端等 2 倍 ping 间隔(默认 30s×2 = 60s)读超时断开、重连—— + // 实测表现就是「设备通道每 60 秒掉线一次」,连带着 outputch 反复注销/注册。 + return writeFrame(w, 0xa, nil) } func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {