308 lines
8.4 KiB
Go
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")
|
|
}
|
|
}
|