Files

308 lines
8.4 KiB
Go

package websocket
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/pika-monitor/pika/internal/protocol"
"go.uber.org/zap"
)
func newTestClient(m *Manager, id, bootID string) *Client {
c := NewClient(id, nil, m)
m.Register(c, bootID)
return c
}
func readAck(t *testing.T, c *Client) uint64 {
t.Helper()
select {
case raw := <-c.send:
var msg protocol.InputMessage
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal ack: %v", err)
}
if msg.Type != protocol.MessageTypeAck {
t.Fatalf("expected ack message, got %s", msg.Type)
}
var ack protocol.AckData
if err := json.Unmarshal(msg.Data, &ack); err != nil {
t.Fatalf("unmarshal ack data: %v", err)
}
return ack.Seq
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for ack")
return 0
}
}
func TestReliableSessionDedupAckAndOrder(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
var mu sync.Mutex
var processed []string
done := make(chan struct{})
m.SetMessageHandler(func(_ context.Context, _, typ string, _ json.RawMessage) error {
mu.Lock()
processed = append(processed, typ)
if len(processed) == 2 {
close(done)
}
mu.Unlock()
return nil
})
client := newTestClient(m, "agent-1", "boot-1")
client.session.ackSeq.Store(1)
client.session.reliableQueue <- agentMessage{seq: 1, typ: "replayed"}
client.session.reliableQueue <- agentMessage{seq: 2, typ: "first"}
client.session.reliableQueue <- agentMessage{seq: 3, typ: "second"}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for processing")
}
mu.Lock()
got := append([]string(nil), processed...)
mu.Unlock()
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("processed = %v, want [first second]", got)
}
if ack := m.AckSeq("agent-1"); ack != 3 {
t.Fatalf("AckSeq = %d, want 3", ack)
}
for i, want := range []uint64{1, 2, 3} {
if ack := readAck(t, client); ack != want {
t.Fatalf("ack #%d = %d, want %d", i, ack, want)
}
}
}
func TestReliableFailureDoesNotAdvanceOrSkip(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
var allowFirst atomic.Bool
processed := make(chan string, 4)
m.SetMessageHandler(func(_ context.Context, _, typ string, _ json.RawMessage) error {
if typ == "first" && !allowFirst.Load() {
return errors.New("temporary database failure")
}
processed <- typ
return nil
})
client := newTestClient(m, "agent-2", "boot-1")
client.session.reliableQueue <- agentMessage{seq: 1, typ: "first"}
client.session.reliableQueue <- agentMessage{seq: 2, typ: "second"}
time.Sleep(100 * time.Millisecond)
if ack := m.AckSeq("agent-2"); ack != 0 {
t.Fatalf("AckSeq during failure = %d, want 0", ack)
}
select {
case typ := <-processed:
t.Fatalf("processed %s before seq 1 recovered", typ)
default:
}
allowFirst.Store(true)
for _, want := range []string{"first", "second"} {
select {
case got := <-processed:
if got != want {
t.Fatalf("processed %q, want %q", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for %s", want)
}
}
if ack := m.AckSeq("agent-2"); ack != 2 {
t.Fatalf("AckSeq after recovery = %d, want 2", ack)
}
}
func TestBootIDReplacementRejectsOldSessionCommit(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
oldStarted := make(chan struct{})
releaseOld := make(chan struct{})
newProcessed := make(chan struct{})
m.SetMessageHandler(func(_ context.Context, _, typ string, _ json.RawMessage) error {
switch typ {
case "old":
close(oldStarted)
<-releaseOld // 模拟不遵守 context 的旧业务处理器
case "new":
close(newProcessed)
}
return nil
})
oldClient := newTestClient(m, "agent-3", "boot-old")
oldSession := oldClient.session
oldSession.reliableQueue <- agentMessage{seq: 100, typ: "old"}
<-oldStarted
newClient := newTestClient(m, "agent-3", "boot-new")
if oldClient.session == newClient.session {
t.Fatal("BootID change should replace the session")
}
if ack := m.AckSeq("agent-3"); ack != 0 {
t.Fatalf("new session AckSeq = %d, want 0", ack)
}
close(releaseOld)
newClient.session.reliableQueue <- agentMessage{seq: 1, typ: "new"}
select {
case <-newProcessed:
case <-time.After(2 * time.Second):
t.Fatal("new session message was not processed")
}
if ack := readAck(t, newClient); ack != 1 {
t.Fatalf("new session ack = %d, want 1", ack)
}
time.Sleep(50 * time.Millisecond)
if ack := m.AckSeq("agent-3"); ack != 1 {
t.Fatalf("old session polluted AckSeq: got %d, want 1", ack)
}
}
func TestSameBootReconnectReusesSession(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
first := newTestClient(m, "agent-4", "boot-1")
first.session.ackSeq.Store(9)
second := newTestClient(m, "agent-4", "boot-1")
if first.session != second.session {
t.Fatal("same BootID reconnect should reuse session")
}
if ack := m.AckSeq("agent-4"); ack != 9 {
t.Fatalf("AckSeq after reconnect = %d, want 9", ack)
}
select {
case <-first.done:
default:
t.Fatal("old transport should be closed after replacement")
}
if m.Unregister(first) {
t.Fatal("old transport must not unregister the replacement")
}
if current, ok := m.GetClient("agent-4"); !ok || current != second {
t.Fatal("replacement transport was removed by old unregister")
}
}
func TestReplacedTransportCannotEnqueueOutOfOrderSequence(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
processed := make(chan string, 2)
m.SetMessageHandler(func(_ context.Context, _, typ string, _ json.RawMessage) error {
processed <- typ
return nil
})
oldClient := newTestClient(m, "agent-order", "boot-1")
newClient := newTestClient(m, "agent-order", "boot-1")
if err := m.enqueueReliable(context.Background(), oldClient, agentMessage{seq: 10, typ: "stale"}); !errors.Is(err, ErrClientNotFound) {
t.Fatalf("old transport enqueue = %v, want ErrClientNotFound", err)
}
if err := m.enqueueReliable(context.Background(), newClient, agentMessage{seq: 1, typ: "current"}); err != nil {
t.Fatalf("new transport enqueue: %v", err)
}
select {
case typ := <-processed:
if typ != "current" {
t.Fatalf("processed %q, want current", typ)
}
case <-time.After(time.Second):
t.Fatal("current transport message was not processed")
}
select {
case typ := <-processed:
t.Fatalf("processed stale transport message %q", typ)
case <-time.After(50 * time.Millisecond):
}
}
func TestBestEffortDoesNotBlockReliableEvents(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
blockTelemetry := make(chan struct{})
reliableDone := make(chan struct{})
m.SetMessageHandler(func(_ context.Context, _, typ string, _ json.RawMessage) error {
if typ == "metrics" {
<-blockTelemetry
} else if typ == "event" {
close(reliableDone)
}
return nil
})
client := newTestClient(m, "agent-5", "boot-1")
client.session.bestEffortQueue <- agentMessage{typ: "metrics"}
client.session.reliableQueue <- agentMessage{seq: 1, typ: "event"}
select {
case <-reliableDone:
case <-time.After(500 * time.Millisecond):
t.Fatal("reliable event was blocked by best-effort telemetry")
}
close(blockTelemetry)
}
func TestPermanentMessageAdvancesAck(t *testing.T) {
m := NewManager(zap.NewNop())
defer m.shutdown()
m.SetMessageHandler(func(context.Context, string, string, json.RawMessage) error {
return Permanent(errors.New("invalid payload"))
})
client := newTestClient(m, "agent-6", "boot-1")
client.session.reliableQueue <- agentMessage{seq: 1, typ: "bad"}
if ack := readAck(t, client); ack != 1 {
t.Fatalf("permanent message ack = %d, want 1", ack)
}
}
func TestClosedClientRejectsSendWithoutPanic(t *testing.T) {
m := NewManager(zap.NewNop())
client := newTestClient(m, "agent-7", "boot-1")
client.close()
if client.trySend([]byte("ack")) {
t.Fatal("closed client accepted non-blocking send")
}
if err := client.sendWithTimeout([]byte("command"), time.Second); !errors.Is(err, ErrClientNotFound) {
t.Fatalf("send to closed client = %v, want ErrClientNotFound", err)
}
m.shutdown()
}
func TestClientShouldWriteStatusDebounce(t *testing.T) {
m := NewManager(zap.NewNop())
client := NewClient("agent-8", nil, m)
if !client.ShouldWriteStatus(time.Minute) {
t.Fatal("first check should pass")
}
client.MarkStatusWritten()
if client.ShouldWriteStatus(time.Minute) {
t.Fatal("second check within interval should be debounced")
}
if !client.ShouldWriteStatus(0) {
t.Fatal("zero interval should always pass")
}
}