package proto_socket_test import ( "context" "fmt" "io" "net" "net/http" "strings" "testing" "time" "nhooyr.io/websocket" toki "git.toki-labs.com/toki/proto-socket/go" ) func TestHeartbeatDisconnectsWithoutResponse(t *testing.T) { clientConn, peerConn := net.Pipe() defer peerConn.Close() go func() { _, _ = io.Copy(io.Discard, peerConn) }() client := toki.NewTcpClient(clientConn, 1, 1, testParserMap()) defer client.Close() disconnected := make(chan struct{}, 1) client.AddDisconnectListener(func(*toki.TcpClient) { disconnected <- struct{}{} }) select { case <-disconnected: case <-time.After(3500 * time.Millisecond): t.Fatal("heartbeat timeout did not disconnect client") } if client.IsAlive() { t.Fatal("client is still marked alive after heartbeat timeout") } info := client.DisconnectInfo() if info.Reason != toki.DisconnectReasonHeartbeatTimeout { t.Fatalf("disconnect reason: got %q want %q", info.Reason, toki.DisconnectReasonHeartbeatTimeout) } if !strings.Contains(info.Error, "no heartbeat response") { t.Fatalf("disconnect error should describe heartbeat wait, got %q", info.Error) } } // TestHeartbeatSurvivesPairedIdle exercises the synchronized two-peer heartbeat // race: when both peers' heartbeat timers fire in the same cycle, each treats // the incoming heartbeat as a response (clearing waitingHBResponse) without // echoing back. If the wait timer was installed after the response was already // processed, the prior code unconditionally disconnected with heartbeat_timeout. // This test runs a real paired TCP loop with 250ms interval / 250ms wait for // several seconds; both peers must remain alive across many cycles. func TestHeartbeatSurvivesPairedIdle(t *testing.T) { port := freePort(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() const ( intervalSec = 0 // 0 means we drive intervals manually below — we use heartbeatSubSec for that _ = intervalSec runDuration = 4 * time.Second pollInterval = 50 * time.Millisecond ) // Server accepts a TcpClient with 1s/1s heartbeat. acceptedCh := make(chan *toki.TcpClient, 1) server := toki.NewTcpServer("127.0.0.1", port, func(conn net.Conn) *toki.TcpClient { c := toki.NewTcpClient(conn, 1, 1, testParserMap()) acceptedCh <- c return c }) if err := server.Start(ctx); err != nil { t.Fatal(err) } defer server.Stop() clientA, err := toki.DialTcp(ctx, "127.0.0.1", port, 1, 1, testParserMap()) if err != nil { t.Fatal(err) } defer clientA.Close() var clientB *toki.TcpClient select { case clientB = <-acceptedCh: case <-time.After(2 * time.Second): t.Fatal("server did not accept connection") } deadline := time.Now().Add(runDuration) for time.Now().Before(deadline) { time.Sleep(pollInterval) if !clientA.IsAlive() { info := clientA.DisconnectInfo() t.Fatalf("client A died at %v: reason=%q error=%q", time.Since(deadline.Add(-runDuration)), info.Reason, info.Error) } if !clientB.IsAlive() { info := clientB.DisconnectInfo() t.Fatalf("client B died at %v: reason=%q error=%q", time.Since(deadline.Add(-runDuration)), info.Reason, info.Error) } } } func TestWsHeartbeatDisconnectsWithoutResponse(t *testing.T) { port := freePort(t) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if err != nil { return } defer conn.Close(websocket.StatusNormalClosure, "") for { if _, _, err := conn.Read(r.Context()); err != nil { return } } }) server := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: mux} ln, err := net.Listen("tcp", server.Addr) if err != nil { t.Fatal(err) } defer server.Close() go func() { _ = server.Serve(ln) }() ctx, cancel := context.WithCancel(context.Background()) defer cancel() client, err := toki.DialWsWithHeartbeat(ctx, "127.0.0.1", port, "/", 1, 1, testParserMap()) if err != nil { t.Fatal(err) } defer client.Close() disconnected := make(chan struct{}, 1) client.AddDisconnectListener(func(*toki.WsClient) { disconnected <- struct{}{} }) select { case <-disconnected: case <-time.After(3500 * time.Millisecond): t.Fatal("websocket heartbeat timeout did not disconnect client") } if client.IsAlive() { t.Fatal("websocket client is still marked alive after heartbeat timeout") } info := client.DisconnectInfo() if info.Reason != toki.DisconnectReasonHeartbeatTimeout { t.Fatalf("disconnect reason: got %q want %q", info.Reason, toki.DisconnectReasonHeartbeatTimeout) } if !strings.Contains(info.Error, "no heartbeat response") { t.Fatalf("disconnect error should describe heartbeat wait, got %q", info.Error) } }