- 노드 연결 2단계 핸드셰이크 추가 (Register→NodeReady→DispatchReady) - ConnectionGeneration 기반 연결 세대 관리로 stale 연결 차폐 - configured 노드 catalog 기반 snapshot rebuild (Connected 상태 분리) - provider 리스 소유권 일원화: edge가 소유권 승인·반환 전까지 대기 - 모델 대기열 승인/해제/스냅샷 서비스 구현 - 재연결 준비도 통합 테스트, 아카이브된 하위태스크 8건 포함
1586 lines
54 KiB
Go
1586 lines
54 KiB
Go
package transport_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"git.toki-labs.com/toki/proto-socket/go/packets"
|
|
|
|
edgeevents "iop/apps/edge/internal/events"
|
|
edgenode "iop/apps/edge/internal/node"
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/apps/edge/internal/transport"
|
|
"iop/packages/go/config"
|
|
eventpkg "iop/packages/go/events"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func getFreePort(t *testing.T) string {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := l.Addr().String()
|
|
l.Close()
|
|
return addr
|
|
}
|
|
|
|
// handshakeAttemptTimeout / handshakeMaxAttempts bound one register/ready
|
|
// request and how many fresh connections the resilient helpers try. The repeated
|
|
// -race suite runs three race-instrumented binaries in parallel and occasionally
|
|
// stalls a fresh localhost connection's handshake for seconds while the machine
|
|
// is otherwise idle (a scheduling/netpoll artifact, not a product hang). Rather
|
|
// than inflate a single fixed request ceiling — which only makes a stalled
|
|
// attempt fail slower — the helpers abandon a stalled attempt quickly and retry
|
|
// on a fresh connection, which succeeds. Production heartbeat/timeout constants
|
|
// are untouched; these bound only the tests' own handshake.
|
|
const (
|
|
handshakeAttemptTimeout = 5 * time.Second
|
|
handshakeMaxAttempts = 4
|
|
)
|
|
|
|
func nodeParserMap() toki.ParserMap {
|
|
return toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RegisterResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.NodeReadyResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeReadyResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
}
|
|
|
|
func tryDialNode(ctx context.Context, listenAddr string) (*toki.TcpClient, error) {
|
|
host, portStr, _ := net.SplitHostPort(listenAddr)
|
|
port := 0
|
|
fmt.Sscanf(portStr, "%d", &port)
|
|
return toki.DialTcp(ctx, host, port, 30, 10, nodeParserMap())
|
|
}
|
|
|
|
func dialNode(t *testing.T, ctx context.Context, listenAddr string) *toki.TcpClient {
|
|
t.Helper()
|
|
client, err := tryDialNode(ctx, listenAddr)
|
|
if err != nil {
|
|
t.Fatalf("dial edge server: %v", err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
// resilientRegister performs the dial+register handshake, retrying the whole
|
|
// thing on a fresh connection when an attempt errors (a transient connection
|
|
// stall under the repeated -race suite). A completed round-trip — accepted OR
|
|
// rejected — is returned as-is; only connection/transport errors are retried, so
|
|
// a genuine rejection (e.g. a duplicate registration) is never masked. The
|
|
// returned client is the one that carried the successful round-trip.
|
|
func resilientRegister(t *testing.T, ctx context.Context, listenAddr, token string) (*toki.TcpClient, *iop.RegisterResponse) {
|
|
t.Helper()
|
|
var lastErr error
|
|
for attempt := 1; attempt <= handshakeMaxAttempts; attempt++ {
|
|
client, err := tryDialNode(ctx, listenAddr)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client.Communicator, &iop.RegisterRequest{Token: token}, handshakeAttemptTimeout)
|
|
if err == nil {
|
|
return client, resp
|
|
}
|
|
lastErr = err
|
|
client.Close()
|
|
}
|
|
t.Fatalf("register handshake failed after %d attempts: %v", handshakeMaxAttempts, lastErr)
|
|
return nil, nil
|
|
}
|
|
|
|
// signalReady drives the node→edge NodeReadyRequest handshake and asserts the
|
|
// edge marked the connection dispatch-ready. Registration only claims ownership
|
|
// and delivers config; the connect lifecycle and connected event fire on this
|
|
// signal, so tests that assert connectivity must call it after an accepted
|
|
// registration. The ready request is retried on the same (already-registered)
|
|
// connection when it stalls transiently; a duplicate ready is idempotent server
|
|
// side, so a retry is safe.
|
|
func signalReady(t *testing.T, client *toki.TcpClient, nodeID string) {
|
|
t.Helper()
|
|
var lastErr error
|
|
for attempt := 1; attempt <= handshakeMaxAttempts; attempt++ {
|
|
resp, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&client.Communicator, &iop.NodeReadyRequest{NodeId: nodeID}, handshakeAttemptTimeout)
|
|
if err != nil {
|
|
lastErr = err
|
|
if !client.IsAlive() {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
if !resp.GetReady() {
|
|
t.Fatalf("expected ready ack, got reason %q", resp.GetReason())
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("ready request failed after %d attempts: %v", handshakeMaxAttempts, lastErr)
|
|
}
|
|
|
|
// signalReadyResp is the raw single-shot ready handshake for tests that assert
|
|
// the response (duplicate/stale ready) rather than a successful ack.
|
|
func signalReadyResp(t *testing.T, client *toki.TcpClient, nodeID string) *iop.NodeReadyResponse {
|
|
t.Helper()
|
|
resp, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&client.Communicator, &iop.NodeReadyRequest{NodeId: nodeID}, handshakeAttemptTimeout)
|
|
if err != nil {
|
|
t.Fatalf("ready request: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func waitForRegistryEntry(ctx context.Context, registry *edgenode.Registry, nodeID string) (*edgenode.NodeEntry, bool) {
|
|
ticker := time.NewTicker(25 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if entry, ok := registry.Get(nodeID); ok {
|
|
return entry, true
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, false
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForRegistryAbsent(ctx context.Context, registry *edgenode.Registry, nodeID string) bool {
|
|
ticker := time.NewTicker(25 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if _, ok := registry.Get(nodeID); !ok {
|
|
return true
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEdgeServerGenericRegistrationKind(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{
|
|
ID: "node-test-01",
|
|
Alias: "generic-node",
|
|
Token: "node-token",
|
|
AgentKind: config.AgentKindGenericNode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
client := dialNode(t, ctx, listenAddr)
|
|
defer client.Close()
|
|
|
|
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client.Communicator,
|
|
&iop.RegisterRequest{Token: "node-token"},
|
|
45*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("register request: %v", err)
|
|
}
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected accepted register response, got reason %q", resp.GetReason())
|
|
}
|
|
|
|
entry, ok := waitForRegistryEntry(ctx, registry, "node-test-01")
|
|
if !ok {
|
|
t.Fatal("node was not registered in edge registry within timeout")
|
|
}
|
|
if entry.AgentKind != config.AgentKindGenericNode {
|
|
t.Fatalf("agent kind: got %q want %q", entry.AgentKind, config.AgentKindGenericNode)
|
|
}
|
|
if entry.LifecycleState != edgenode.LifecycleConnected {
|
|
t.Fatalf("lifecycle: got %q want %q", entry.LifecycleState, edgenode.LifecycleConnected)
|
|
}
|
|
}
|
|
|
|
func TestEdgeServerDuplicateRegistrationReason(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
kind string
|
|
wantReason string
|
|
}{
|
|
{"generic", config.AgentKindGenericNode, "node already connected"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "dup-01", Alias: "dup", Token: "dup-token", AgentKind: tc.kind},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
first := dialNode(t, ctx, listenAddr)
|
|
defer first.Close()
|
|
resp1, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&first.Communicator, &iop.RegisterRequest{Token: "dup-token"}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("first register: %v", err)
|
|
}
|
|
if !resp1.GetAccepted() {
|
|
t.Fatalf("expected first registration accepted, got reason %q", resp1.GetReason())
|
|
}
|
|
if _, ok := waitForRegistryEntry(ctx, registry, "dup-01"); !ok {
|
|
t.Fatal("first registration not visible in registry within timeout")
|
|
}
|
|
|
|
second := dialNode(t, ctx, listenAddr)
|
|
defer second.Close()
|
|
resp2, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&second.Communicator, &iop.RegisterRequest{Token: "dup-token"}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("second register: %v", err)
|
|
}
|
|
if resp2.GetAccepted() {
|
|
t.Fatal("expected second registration to be rejected")
|
|
}
|
|
if resp2.GetReason() != tc.wantReason {
|
|
t.Fatalf("duplicate reason: got %q want %q", resp2.GetReason(), tc.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEdgeServerReconnectAfterUnregisterAccepted(t *testing.T) {
|
|
// Widened from 5s/2s so the race detector's instrumentation slowdown, observed
|
|
// to trip a 2s request timeout once under the full -race suite, cannot flake
|
|
// this deterministically-passing reconnect handshake. Production heartbeat
|
|
// constants are untouched; only the test's own deadlines move.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "reconnect-01", Alias: "reconnect", Token: "reconnect-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
first := dialNode(t, ctx, listenAddr)
|
|
resp1, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&first.Communicator, &iop.RegisterRequest{Token: "reconnect-token"}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("first register: %v", err)
|
|
}
|
|
if !resp1.GetAccepted() {
|
|
t.Fatalf("expected first registration accepted, got reason %q", resp1.GetReason())
|
|
}
|
|
if _, ok := waitForRegistryEntry(ctx, registry, "reconnect-01"); !ok {
|
|
t.Fatal("first registration not visible in registry within timeout")
|
|
}
|
|
|
|
if err := first.Close(); err != nil {
|
|
t.Fatalf("close first client: %v", err)
|
|
}
|
|
if ok := waitForRegistryAbsent(ctx, registry, "reconnect-01"); !ok {
|
|
t.Fatal("registry still contains node after disconnect")
|
|
}
|
|
|
|
second := dialNode(t, ctx, listenAddr)
|
|
defer second.Close()
|
|
resp2, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&second.Communicator, &iop.RegisterRequest{Token: "reconnect-token"}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("second register: %v", err)
|
|
}
|
|
if !resp2.GetAccepted() {
|
|
t.Fatalf("expected reconnect after unregister accepted, got reason %q", resp2.GetReason())
|
|
}
|
|
if _, ok := waitForRegistryEntry(ctx, registry, "reconnect-01"); !ok {
|
|
t.Fatal("reconnect registration not visible in registry within timeout")
|
|
}
|
|
}
|
|
|
|
func TestEdgeServerIntegration(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{
|
|
ID: "node-test-01",
|
|
Alias: "test-node",
|
|
Token: "test-token",
|
|
Adapters: config.AdaptersConf{
|
|
Ollama: config.OllamaConf{Enabled: true, BaseURL: "http://localhost:11434"},
|
|
Mock: config.MockConf{Enabled: true},
|
|
},
|
|
Runtime: config.RuntimeConf{Concurrency: 2},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
// 1. Edge 서버 구동
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("failed to create server: %v", err)
|
|
}
|
|
nodeEventCh := make(chan *iop.EdgeNodeEvent, 4)
|
|
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
|
|
nodeEventCh <- event
|
|
})
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// 2. Mock Node 클라이언트 접속
|
|
host, portStr, _ := net.SplitHostPort(listenAddr)
|
|
port := 0
|
|
fmt.Sscanf(portStr, "%d", &port)
|
|
|
|
// Node parser map (mock)
|
|
nodeParser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RegisterResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.NodeReadyResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeReadyResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
|
|
client, err := toki.DialTcp(ctx, host, port, 30, 10, nodeParser)
|
|
if err != nil {
|
|
t.Fatalf("failed to dial edge server: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
// 3. Node가 RegisterRequest를 보내고 RegisterResponse를 받는다
|
|
resp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client.Communicator,
|
|
&iop.RegisterRequest{Token: "test-token"},
|
|
45*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("register request: %v", err)
|
|
}
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected accepted register response, got reason %q", resp.GetReason())
|
|
}
|
|
rec, ok := nodeStore.FindByToken("test-token")
|
|
if !ok {
|
|
t.Fatal("expected record for test-token in store")
|
|
}
|
|
wantNodeID := rec.ID
|
|
if wantNodeID != "node-test-01" {
|
|
t.Fatalf("expected stored id to match explicit config id, got %q", wantNodeID)
|
|
}
|
|
if resp.GetNodeId() != wantNodeID {
|
|
t.Fatalf("expected node id %q, got %q", wantNodeID, resp.GetNodeId())
|
|
}
|
|
if resp.GetAlias() != "test-node" {
|
|
t.Fatalf("expected alias %q, got %q", "test-node", resp.GetAlias())
|
|
}
|
|
if resp.GetConfig().GetRuntime().GetConcurrency() != 2 {
|
|
t.Fatalf("expected runtime concurrency 2, got %d", resp.GetConfig().GetRuntime().GetConcurrency())
|
|
}
|
|
if len(resp.GetConfig().GetAdapters()) != 2 {
|
|
t.Fatalf("expected mock and ollama adapters, got %d", len(resp.GetConfig().GetAdapters()))
|
|
}
|
|
// Registration only claimed ownership and delivered config; drive the ready
|
|
// signal so the connected lifecycle/event fire.
|
|
signalReady(t, client, resp.GetNodeId())
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeConnected {
|
|
t.Fatalf("event type: got %q want %q", event.GetType(), eventpkg.TypeNodeConnected)
|
|
}
|
|
if event.GetNodeId() != wantNodeID || event.GetAlias() != "test-node" || event.GetReason() != eventpkg.ReasonRegistered {
|
|
t.Fatalf("unexpected connected event: %+v", event)
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for node connected event")
|
|
}
|
|
|
|
// 4. Registry 등록 여부 확인
|
|
entry, ok := waitForRegistryEntry(ctx, registry, wantNodeID)
|
|
if !ok {
|
|
t.Fatal("node was not registered in edge registry within timeout")
|
|
}
|
|
|
|
if got := registry.Count(); got != 1 {
|
|
t.Fatalf("expected registry count 1, got %d", got)
|
|
}
|
|
if entry == nil {
|
|
t.Fatal("expected registry entry, got nil")
|
|
}
|
|
if got := entry.NodeID; got != wantNodeID {
|
|
t.Fatalf("expected node id %q, got %q", wantNodeID, got)
|
|
}
|
|
if got := entry.Alias; got != "test-node" {
|
|
t.Fatalf("expected alias %q, got %q", "test-node", got)
|
|
}
|
|
|
|
// 5. Alias로 Resolve 확인
|
|
if e, err := registry.Resolve("test-node"); err != nil || e.NodeID != wantNodeID {
|
|
t.Fatalf("failed to resolve by alias %q: %v", "test-node", err)
|
|
}
|
|
|
|
if err := client.Close(); err != nil {
|
|
t.Fatalf("close client: %v", err)
|
|
}
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeDisconnected {
|
|
t.Fatalf("event type: got %q want %q", event.GetType(), eventpkg.TypeNodeDisconnected)
|
|
}
|
|
if event.GetNodeId() != wantNodeID || event.GetAlias() != "test-node" || event.GetReason() != eventpkg.ReasonTransportClosed {
|
|
t.Fatalf("unexpected disconnected event: %+v", event)
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataTransportCloseReason] != toki.DisconnectReasonRemoteClosed {
|
|
t.Fatalf("transport close reason: got %q want %q", event.GetMetadata()[eventpkg.MetadataTransportCloseReason], toki.DisconnectReasonRemoteClosed)
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataTransportCloseError] == "" {
|
|
t.Fatalf("expected transport close error metadata, got %+v", event.GetMetadata())
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for node disconnected event")
|
|
}
|
|
}
|
|
|
|
func TestEdgeServerRegistrationFailureReasons(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{
|
|
ID: "node-fail-01",
|
|
Alias: "fail-node",
|
|
Token: "valid-token",
|
|
AgentKind: config.AgentKindGenericNode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
|
|
nodeEventCh := make(chan *iop.EdgeNodeEvent, 10)
|
|
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
|
|
nodeEventCh <- event
|
|
})
|
|
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// 1. Unknown Token Test
|
|
client1 := dialNode(t, ctx, listenAddr)
|
|
resp1, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client1.Communicator,
|
|
&iop.RegisterRequest{Token: "unknown-token-12345"},
|
|
45*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("send register 1: %v", err)
|
|
}
|
|
if resp1.GetAccepted() {
|
|
t.Fatal("expected rejected")
|
|
}
|
|
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeRegistrationFailed {
|
|
t.Fatalf("expected registration failed type, got %q", event.GetType())
|
|
}
|
|
if event.GetReason() != eventpkg.ReasonUnknownToken {
|
|
t.Fatalf("expected reason unknown_token, got %q", event.GetReason())
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonUnknownToken {
|
|
t.Fatalf("expected metadata failure reason, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataTokenPrefix] != "unknown-..." {
|
|
t.Fatalf("expected token prefix unknown-..., got %q", event.GetMetadata()[eventpkg.MetadataTokenPrefix])
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for unknown token registration failed event")
|
|
}
|
|
client1.Close()
|
|
|
|
// 2. Duplicate Connection Test
|
|
client2 := dialNode(t, ctx, listenAddr)
|
|
resp2, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client2.Communicator,
|
|
&iop.RegisterRequest{Token: "valid-token"},
|
|
45*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("send register 2: %v", err)
|
|
}
|
|
if !resp2.GetAccepted() {
|
|
t.Fatalf("expected register 2 accepted, got %q", resp2.GetReason())
|
|
}
|
|
// Ready signal opens the connected lifecycle/event for the accepted owner.
|
|
signalReady(t, client2, resp2.GetNodeId())
|
|
// Consume connection event
|
|
<-nodeEventCh
|
|
|
|
client3 := dialNode(t, ctx, listenAddr)
|
|
resp3, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client3.Communicator,
|
|
&iop.RegisterRequest{Token: "valid-token"},
|
|
45*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("send register 3: %v", err)
|
|
}
|
|
if resp3.GetAccepted() {
|
|
t.Fatal("expected duplicate connection registration rejected")
|
|
}
|
|
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeRegistrationFailed {
|
|
t.Fatalf("expected registration failed type, got %q", event.GetType())
|
|
}
|
|
if event.GetReason() != eventpkg.ReasonDuplicateConnection {
|
|
t.Fatalf("expected reason duplicate_connection, got %q", event.GetReason())
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonDuplicateConnection {
|
|
t.Fatalf("expected failure reason metadata, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataAgentKind] != config.AgentKindGenericNode {
|
|
t.Fatalf("expected agent kind metadata, got %q", event.GetMetadata()[eventpkg.MetadataAgentKind])
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for duplicate registration failed event")
|
|
}
|
|
client2.Close()
|
|
client3.Close()
|
|
}
|
|
|
|
func TestEdgeServerHeartbeatTimeoutDisconnect(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
logger := zap.NewNop()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{
|
|
ID: "node-timeout-01",
|
|
Alias: "timeout-node",
|
|
Token: "timeout-token",
|
|
AgentKind: config.AgentKindGenericNode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
server.HeartbeatInterval = 1
|
|
server.HeartbeatWait = 1
|
|
|
|
nodeEventCh := make(chan *iop.EdgeNodeEvent, 10)
|
|
server.SetNodeEventHandler(func(event *iop.EdgeNodeEvent) {
|
|
nodeEventCh <- event
|
|
})
|
|
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// Register and ready first, while the client still answers heartbeats (the ready
|
|
// signal opens the connected event), then stop answering so the server times the
|
|
// connection out. Muting heartbeats before the handshake let HeartbeatWait=1 drop
|
|
// the connection mid-register under the repeated -race suite ("not connected").
|
|
client, resp := registerNode(t, ctx, listenAddr, "timeout-token")
|
|
defer client.Close()
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected accepted, got %q", resp.GetReason())
|
|
}
|
|
client.RemoveListeners(toki.TypeNameOf(&packets.HeartBeat{}))
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeConnected {
|
|
t.Fatalf("expected connected event, got %q", event.GetType())
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for connected event")
|
|
}
|
|
|
|
select {
|
|
case event := <-nodeEventCh:
|
|
if event.GetType() != eventpkg.TypeNodeDisconnected {
|
|
t.Fatalf("expected disconnected event, got %q", event.GetType())
|
|
}
|
|
if event.GetReason() != eventpkg.ReasonHeartbeatTimeout {
|
|
t.Fatalf("expected reason heartbeat_timeout, got %q", event.GetReason())
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataFailureReason] != eventpkg.ReasonHeartbeatTimeout {
|
|
t.Fatalf("expected failure reason heartbeat_timeout, got %q", event.GetMetadata()[eventpkg.MetadataFailureReason])
|
|
}
|
|
if event.GetMetadata()[eventpkg.MetadataLifecycleState] != edgenode.LifecycleFailed {
|
|
t.Fatalf("expected lifecycle state failed, got %q", event.GetMetadata()[eventpkg.MetadataLifecycleState])
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("timeout waiting for heartbeat timeout disconnected event")
|
|
}
|
|
}
|
|
|
|
// disconnectRecorder captures the interleaving of the authoritative lifecycle
|
|
// callback and the observability event, which is the ordering under test, along
|
|
// with the connection generation the authoritative callback carries.
|
|
type disconnectRecorder struct {
|
|
mu sync.Mutex
|
|
sequence []string
|
|
nodeIDs []string
|
|
reasons []string
|
|
generations []uint64
|
|
}
|
|
|
|
func (r *disconnectRecorder) onDisconnect(nodeID string, generation uint64, reason string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sequence = append(r.sequence, "lifecycle")
|
|
r.nodeIDs = append(r.nodeIDs, nodeID)
|
|
r.reasons = append(r.reasons, reason)
|
|
r.generations = append(r.generations, generation)
|
|
}
|
|
|
|
func (r *disconnectRecorder) onNodeEvent(event *iop.EdgeNodeEvent) {
|
|
if event.GetType() != eventpkg.TypeNodeDisconnected {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sequence = append(r.sequence, "event")
|
|
}
|
|
|
|
func (r *disconnectRecorder) snapshot() ([]string, []string, []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.sequence...),
|
|
append([]string(nil), r.nodeIDs...),
|
|
append([]string(nil), r.reasons...)
|
|
}
|
|
|
|
func (r *disconnectRecorder) generationSnapshot() []uint64 {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]uint64(nil), r.generations...)
|
|
}
|
|
|
|
func newDisconnectTestServer(t *testing.T, ctx context.Context, nodeID, token string) (*transport.Server, *edgenode.Registry, *disconnectRecorder, string) {
|
|
t.Helper()
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: nodeID, Alias: nodeID, Token: token, AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
|
|
rec := &disconnectRecorder{}
|
|
server.SetNodeDisconnectHandler(rec.onDisconnect)
|
|
server.SetNodeEventHandler(rec.onNodeEvent)
|
|
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
t.Cleanup(func() { server.Stop() })
|
|
|
|
return server, registry, rec, listenAddr
|
|
}
|
|
|
|
// registerNode dials the edge and performs the full accepted-registration
|
|
// handshake resiliently: it retries dial+register on a transient connection
|
|
// stall (repeated -race suite) and, when accepted, drives the dispatch-ready
|
|
// signal so the node is fully connected (config claimed + ready) on return. A
|
|
// rejected registration is returned as-is with no ready signal. The returned
|
|
// client is the one that carried the successful register round-trip and must be
|
|
// used for any subsequent ready/disconnect steps.
|
|
func registerNode(t *testing.T, ctx context.Context, listenAddr, token string) (*toki.TcpClient, *iop.RegisterResponse) {
|
|
t.Helper()
|
|
client, resp := resilientRegister(t, ctx, listenAddr, token)
|
|
if resp.GetAccepted() {
|
|
signalReady(t, client, resp.GetNodeId())
|
|
}
|
|
return client, resp
|
|
}
|
|
|
|
// TestCurrentOwnerDisconnectReleasesLeasesBeforeEvent pins that the node whose
|
|
// connection actually owned the registry entry settles lifecycle cleanup exactly
|
|
// once, and that it happens before the disconnected event is published. The
|
|
// reverse order would let a dropped fanout strand the node's leases.
|
|
func TestCurrentOwnerDisconnectReleasesLeasesBeforeEvent(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
_, registry, rec, listenAddr := newDisconnectTestServer(t, ctx, "owner-01", "owner-token")
|
|
|
|
client, resp := registerNode(t, ctx, listenAddr, "owner-token")
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected registration accepted, got %q", resp.GetReason())
|
|
}
|
|
if _, ok := waitForRegistryEntry(ctx, registry, "owner-01"); !ok {
|
|
t.Fatal("registration not visible in registry")
|
|
}
|
|
|
|
if err := client.Close(); err != nil {
|
|
t.Fatalf("close client: %v", err)
|
|
}
|
|
if ok := waitForRegistryAbsent(ctx, registry, "owner-01"); !ok {
|
|
t.Fatal("registry still contains node after owner disconnect")
|
|
}
|
|
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
sequence, _, _ := rec.snapshot()
|
|
if len(sequence) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timeout waiting for lifecycle+event, got %v", sequence)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
sequence, nodeIDs, reasons := rec.snapshot()
|
|
if len(sequence) != 2 {
|
|
t.Fatalf("expected exactly one lifecycle callback and one event, got %v", sequence)
|
|
}
|
|
if sequence[0] != "lifecycle" || sequence[1] != "event" {
|
|
t.Errorf("expected cleanup before the observability event, got %v", sequence)
|
|
}
|
|
if len(nodeIDs) != 1 || nodeIDs[0] != "owner-01" {
|
|
t.Errorf("lifecycle callback node ids = %v, want [owner-01]", nodeIDs)
|
|
}
|
|
if len(reasons) != 1 || reasons[0] == "" {
|
|
t.Errorf("lifecycle callback reasons = %v, want one non-empty reason", reasons)
|
|
}
|
|
}
|
|
|
|
// TestStaleDisconnectDoesNotReleaseLiveLease pins the ownership gate: a rejected
|
|
// duplicate connection closing must neither release the live connection's leases
|
|
// nor announce that the still-connected node disconnected.
|
|
func TestStaleDisconnectDoesNotReleaseLiveLease(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
_, registry, rec, listenAddr := newDisconnectTestServer(t, ctx, "stale-01", "stale-token")
|
|
|
|
owner, resp := registerNode(t, ctx, listenAddr, "stale-token")
|
|
defer owner.Close()
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected first registration accepted, got %q", resp.GetReason())
|
|
}
|
|
if _, ok := waitForRegistryEntry(ctx, registry, "stale-01"); !ok {
|
|
t.Fatal("registration not visible in registry")
|
|
}
|
|
|
|
// The duplicate is rejected but still has a disconnect listener bound, which
|
|
// is precisely the connection that must not touch the live entry.
|
|
duplicate, dupResp := registerNode(t, ctx, listenAddr, "stale-token")
|
|
if dupResp.GetAccepted() {
|
|
t.Fatal("expected the duplicate registration to be rejected")
|
|
}
|
|
if err := duplicate.Close(); err != nil {
|
|
t.Fatalf("close duplicate: %v", err)
|
|
}
|
|
|
|
// Give the stale disconnect listener room to misbehave before asserting.
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
sequence, _, _ := rec.snapshot()
|
|
if len(sequence) != 0 {
|
|
t.Errorf("stale disconnect produced %v, want no lifecycle callback and no disconnected event", sequence)
|
|
}
|
|
if generations := rec.generationSnapshot(); len(generations) != 0 {
|
|
t.Errorf("stale disconnect delivered generations %v, want none", generations)
|
|
}
|
|
if _, ok := registry.Get("stale-01"); !ok {
|
|
t.Error("the live node was unregistered by the stale connection closing")
|
|
}
|
|
}
|
|
|
|
// TestDisconnectHandlerReceivesCurrentGeneration pins that the authoritative
|
|
// disconnect callback carries the exact connection generation the registry
|
|
// assigned to the owner, so the service can fence that owner's leases by identity.
|
|
func TestDisconnectHandlerReceivesCurrentGeneration(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
_, registry, rec, listenAddr := newDisconnectTestServer(t, ctx, "gen-01", "gen-token")
|
|
|
|
client, resp := registerNode(t, ctx, listenAddr, "gen-token")
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected registration accepted, got %q", resp.GetReason())
|
|
}
|
|
entry, ok := waitForRegistryEntry(ctx, registry, "gen-01")
|
|
if !ok {
|
|
t.Fatal("registration not visible in registry")
|
|
}
|
|
wantGeneration := entry.ConnectionGeneration
|
|
if wantGeneration == 0 {
|
|
t.Fatal("registered entry must carry a non-zero connection generation")
|
|
}
|
|
|
|
if err := client.Close(); err != nil {
|
|
t.Fatalf("close client: %v", err)
|
|
}
|
|
if ok := waitForRegistryAbsent(ctx, registry, "gen-01"); !ok {
|
|
t.Fatal("registry still contains node after owner disconnect")
|
|
}
|
|
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
if len(rec.generationSnapshot()) >= 1 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timeout waiting for the authoritative disconnect callback")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
generations := rec.generationSnapshot()
|
|
if len(generations) != 1 {
|
|
t.Fatalf("expected exactly one lifecycle callback, got generations %v", generations)
|
|
}
|
|
if generations[0] != wantGeneration {
|
|
t.Fatalf("disconnect generation = %d, want the owner's %d", generations[0], wantGeneration)
|
|
}
|
|
}
|
|
|
|
// TestReconnectDisconnectGenerationsAreMonotonic pins that a node reconnecting on
|
|
// the same id draws a strictly higher generation and that each of its own
|
|
// disconnects reports that connection's generation, so an earlier connection's
|
|
// late callback can never be confused with the reconnect's identity.
|
|
func TestReconnectDisconnectGenerationsAreMonotonic(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
_, registry, rec, listenAddr := newDisconnectTestServer(t, ctx, "recon-01", "recon-token")
|
|
|
|
connectAndCapture := func() uint64 {
|
|
t.Helper()
|
|
client, resp := registerNode(t, ctx, listenAddr, "recon-token")
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected registration accepted, got %q", resp.GetReason())
|
|
}
|
|
entry, ok := waitForRegistryEntry(ctx, registry, "recon-01")
|
|
if !ok {
|
|
t.Fatal("registration not visible in registry")
|
|
}
|
|
gen := entry.ConnectionGeneration
|
|
if err := client.Close(); err != nil {
|
|
t.Fatalf("close client: %v", err)
|
|
}
|
|
if ok := waitForRegistryAbsent(ctx, registry, "recon-01"); !ok {
|
|
t.Fatal("registry still contains node after disconnect")
|
|
}
|
|
return gen
|
|
}
|
|
|
|
firstGen := connectAndCapture()
|
|
secondGen := connectAndCapture()
|
|
if secondGen <= firstGen {
|
|
t.Fatalf("reconnect generation %d must exceed the first %d", secondGen, firstGen)
|
|
}
|
|
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
if len(rec.generationSnapshot()) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timeout waiting for two disconnect callbacks, got %v", rec.generationSnapshot())
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
generations := rec.generationSnapshot()
|
|
if len(generations) != 2 {
|
|
t.Fatalf("expected two lifecycle callbacks, got %v", generations)
|
|
}
|
|
if generations[0] != firstGen || generations[1] != secondGen {
|
|
t.Fatalf("disconnect generations = %v, want [%d %d]", generations, firstGen, secondGen)
|
|
}
|
|
}
|
|
|
|
// TestHeartbeatTimeoutDisconnectCarriesCurrentGenerationBeforeEvent pins that a
|
|
// heartbeat-timeout disconnect settles the same authoritative boundary a normal
|
|
// close does: the lifecycle callback carries the registered owner's connection
|
|
// generation and reason=heartbeat_timeout, and it runs before the disconnected
|
|
// observability event. The reverse order (or a missing/zero generation) would let
|
|
// a dropped fanout strand the timed-out connection's leases or fence the wrong
|
|
// generation.
|
|
func TestHeartbeatTimeoutDisconnectCarriesCurrentGenerationBeforeEvent(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "hb-01", Alias: "hb", Token: "hb-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
server.HeartbeatInterval = 1
|
|
server.HeartbeatWait = 1
|
|
|
|
rec := &disconnectRecorder{}
|
|
server.SetNodeDisconnectHandler(rec.onDisconnect)
|
|
server.SetNodeEventHandler(rec.onNodeEvent)
|
|
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// Register (and ready) first, while the client still answers heartbeats, so the
|
|
// aggressive HeartbeatWait=1 cannot race the handshake and drop the connection
|
|
// before it registers. Only after the entry is live do we stop answering
|
|
// heartbeats, which is what drives the server to time this connection out. The
|
|
// previous order — muting heartbeats before registering — let the server kill
|
|
// the connection mid-handshake under the repeated -race suite ("not connected").
|
|
client, resp := registerNode(t, ctx, listenAddr, "hb-token")
|
|
defer client.Close()
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected registration accepted, got %q", resp.GetReason())
|
|
}
|
|
entry, ok := waitForRegistryEntry(ctx, registry, "hb-01")
|
|
if !ok {
|
|
t.Fatal("registration not visible in registry")
|
|
}
|
|
wantGeneration := entry.ConnectionGeneration
|
|
if wantGeneration == 0 {
|
|
t.Fatal("registered entry must carry a non-zero connection generation")
|
|
}
|
|
|
|
// Stop answering heartbeats so the server drives this connection to timeout.
|
|
client.RemoveListeners(toki.TypeNameOf(&packets.HeartBeat{}))
|
|
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
sequence, _, _ := rec.snapshot()
|
|
if len(sequence) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timeout waiting for heartbeat lifecycle+event, got %v", sequence)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
sequence, nodeIDs, reasons := rec.snapshot()
|
|
if len(sequence) != 2 {
|
|
t.Fatalf("expected exactly one lifecycle callback and one event, got %v", sequence)
|
|
}
|
|
if sequence[0] != "lifecycle" || sequence[1] != "event" {
|
|
t.Errorf("expected cleanup before the observability event, got %v", sequence)
|
|
}
|
|
if len(nodeIDs) != 1 || nodeIDs[0] != "hb-01" {
|
|
t.Errorf("lifecycle callback node ids = %v, want [hb-01]", nodeIDs)
|
|
}
|
|
if len(reasons) != 1 || reasons[0] != eventpkg.ReasonHeartbeatTimeout {
|
|
t.Errorf("lifecycle callback reasons = %v, want [%s]", reasons, eventpkg.ReasonHeartbeatTimeout)
|
|
}
|
|
generations := rec.generationSnapshot()
|
|
if len(generations) != 1 {
|
|
t.Fatalf("expected exactly one lifecycle callback, got generations %v", generations)
|
|
}
|
|
if generations[0] != wantGeneration {
|
|
t.Fatalf("heartbeat disconnect generation = %d, want the owner's %d", generations[0], wantGeneration)
|
|
}
|
|
}
|
|
|
|
// connectRecorder captures the interleaving of the authoritative connect
|
|
// callback and the observability connected event — the ordering under test —
|
|
// together with the generation each accepted connection carries. Only
|
|
// TypeNodeConnected events are recorded, so a rejected duplicate's
|
|
// registration-failed event and a close's disconnected event never appear.
|
|
type connectRecorder struct {
|
|
mu sync.Mutex
|
|
sequence []string
|
|
nodeIDs []string
|
|
generations []uint64
|
|
}
|
|
|
|
func (r *connectRecorder) onConnect(nodeID string, generation uint64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sequence = append(r.sequence, "lifecycle")
|
|
r.nodeIDs = append(r.nodeIDs, nodeID)
|
|
r.generations = append(r.generations, generation)
|
|
}
|
|
|
|
func (r *connectRecorder) onNodeEvent(event *iop.EdgeNodeEvent) {
|
|
if event.GetType() != eventpkg.TypeNodeConnected {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sequence = append(r.sequence, "event")
|
|
}
|
|
|
|
func (r *connectRecorder) snapshot() ([]string, []uint64, []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.sequence...),
|
|
append([]uint64(nil), r.generations...),
|
|
append([]string(nil), r.nodeIDs...)
|
|
}
|
|
|
|
// TestAcceptedConnectHookRunsBeforeEventPerGeneration pins the accepted-connect
|
|
// authoritative lifecycle hook: an accepted registration fires exactly one
|
|
// connect callback carrying that connection's generation, before the connected
|
|
// event is published, and a reconnect on the same id fires a second callback with
|
|
// a strictly higher generation. A rejected duplicate fires no callback at all, so
|
|
// only genuine current owners drive resource activation.
|
|
func TestAcceptedConnectHookRunsBeforeEventPerGeneration(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "connect-01", Alias: "connect", Token: "connect-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
rec := &connectRecorder{}
|
|
server.SetNodeConnectHandler(rec.onConnect)
|
|
server.SetNodeEventHandler(rec.onNodeEvent)
|
|
if !server.HasNodeConnectHandler() {
|
|
t.Fatal("connect handler was not registered")
|
|
}
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// First accepted registration.
|
|
first, firstResp := registerNode(t, ctx, listenAddr, "connect-token")
|
|
if !firstResp.GetAccepted() {
|
|
t.Fatalf("expected first registration accepted, got %q", firstResp.GetReason())
|
|
}
|
|
firstEntry, ok := waitForRegistryEntry(ctx, registry, "connect-01")
|
|
if !ok {
|
|
t.Fatal("first registration not visible in registry")
|
|
}
|
|
firstGen := firstEntry.ConnectionGeneration
|
|
if firstGen == 0 {
|
|
t.Fatal("accepted entry must carry a non-zero connection generation")
|
|
}
|
|
|
|
// A duplicate registration while the owner is still connected is rejected and
|
|
// must not fire the connect callback: only accepted current owners activate.
|
|
duplicate, dupResp := registerNode(t, ctx, listenAddr, "connect-token")
|
|
if dupResp.GetAccepted() {
|
|
t.Fatal("expected the duplicate registration to be rejected")
|
|
}
|
|
if err := duplicate.Close(); err != nil {
|
|
t.Fatalf("close duplicate: %v", err)
|
|
}
|
|
|
|
// Disconnect the owner, then reconnect the same id.
|
|
if err := first.Close(); err != nil {
|
|
t.Fatalf("close first client: %v", err)
|
|
}
|
|
if ok := waitForRegistryAbsent(ctx, registry, "connect-01"); !ok {
|
|
t.Fatal("registry still contains node after disconnect")
|
|
}
|
|
second, secondResp := registerNode(t, ctx, listenAddr, "connect-token")
|
|
defer second.Close()
|
|
if !secondResp.GetAccepted() {
|
|
t.Fatalf("expected reconnect accepted, got %q", secondResp.GetReason())
|
|
}
|
|
secondEntry, ok := waitForRegistryEntry(ctx, registry, "connect-01")
|
|
if !ok {
|
|
t.Fatal("reconnect registration not visible in registry")
|
|
}
|
|
secondGen := secondEntry.ConnectionGeneration
|
|
if secondGen <= firstGen {
|
|
t.Fatalf("reconnect generation %d must exceed the first %d", secondGen, firstGen)
|
|
}
|
|
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
if _, gens, _ := rec.snapshot(); len(gens) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
_, gens, _ := rec.snapshot()
|
|
t.Fatalf("timeout waiting for two connect callbacks, got %v", gens)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
sequence, generations, nodeIDs := rec.snapshot()
|
|
// Exactly two accepted connections (first + reconnect); the rejected duplicate
|
|
// contributed no callback.
|
|
if len(generations) != 2 {
|
|
t.Fatalf("expected exactly two connect callbacks (accept + reconnect), got generations %v", generations)
|
|
}
|
|
if generations[0] != firstGen || generations[1] != secondGen {
|
|
t.Fatalf("connect generations = %v, want [%d %d]", generations, firstGen, secondGen)
|
|
}
|
|
for _, id := range nodeIDs {
|
|
if id != "connect-01" {
|
|
t.Fatalf("connect callback node ids = %v, want all connect-01", nodeIDs)
|
|
}
|
|
}
|
|
// Each accepted connection's lifecycle callback precedes its connected event,
|
|
// and no rejected/disconnected event interleaves.
|
|
want := []string{"lifecycle", "event", "lifecycle", "event"}
|
|
if len(sequence) != len(want) {
|
|
t.Fatalf("connect/event sequence = %v, want %v", sequence, want)
|
|
}
|
|
for i := range want {
|
|
if sequence[i] != want[i] {
|
|
t.Fatalf("connect/event sequence = %v, want %v", sequence, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRegistrationDoesNotDispatchBeforeReady pins the core fix: an accepted
|
|
// registration claims ownership and delivers config but fires no connect
|
|
// lifecycle and no connected event, and the entry is absent from dispatch-ready
|
|
// lookups. Only the node's NodeReadyRequest opens dispatch and, at that point,
|
|
// runs the connect lifecycle and connected event exactly once.
|
|
func TestRegistrationDoesNotDispatchBeforeReady(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "gate-01", Alias: "gate", Token: "gate-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
rec := &connectRecorder{}
|
|
server.SetNodeConnectHandler(rec.onConnect)
|
|
server.SetNodeEventHandler(rec.onNodeEvent)
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// Register only (no ready): this test asserts the pending state before it later
|
|
// signals ready. resilientRegister retries the dial+register on a transient
|
|
// connection stall under the repeated -race suite.
|
|
client, resp := resilientRegister(t, ctx, listenAddr, "gate-token")
|
|
defer client.Close()
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected accepted, got %q", resp.GetReason())
|
|
}
|
|
if resp.GetConfig() == nil {
|
|
t.Fatal("registration must still deliver config")
|
|
}
|
|
|
|
entry, ok := waitForRegistryEntry(ctx, registry, "gate-01")
|
|
if !ok {
|
|
t.Fatal("accepted registration must claim the registry id")
|
|
}
|
|
if entry.DispatchReady {
|
|
t.Fatal("entry must be pending, not dispatch-ready, before the ready signal")
|
|
}
|
|
|
|
// Before ready: no connect lifecycle, no connected event, not dispatchable.
|
|
time.Sleep(200 * time.Millisecond)
|
|
if seq, _, _ := rec.snapshot(); len(seq) != 0 {
|
|
t.Fatalf("registration fired connect/event before ready: %v", seq)
|
|
}
|
|
if n := len(registry.AllReady()); n != 0 {
|
|
t.Fatalf("pending entry must not be dispatch-ready, AllReady=%d", n)
|
|
}
|
|
|
|
// Ready opens dispatch: exactly one lifecycle then one connected event.
|
|
signalReady(t, client, resp.GetNodeId())
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
if seq, _, _ := rec.snapshot(); len(seq) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
seq, _, _ := rec.snapshot()
|
|
t.Fatalf("timeout waiting for ready lifecycle+event, got %v", seq)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
seq, gens, nodeIDs := rec.snapshot()
|
|
if len(seq) != 2 || seq[0] != "lifecycle" || seq[1] != "event" {
|
|
t.Fatalf("ready sequence = %v, want [lifecycle event]", seq)
|
|
}
|
|
if len(gens) != 1 || gens[0] != entry.ConnectionGeneration {
|
|
t.Fatalf("ready generation = %v, want [%d]", gens, entry.ConnectionGeneration)
|
|
}
|
|
if len(nodeIDs) != 1 || nodeIDs[0] != "gate-01" {
|
|
t.Fatalf("ready node ids = %v, want [gate-01]", nodeIDs)
|
|
}
|
|
if n := len(registry.AllReady()); n != 1 {
|
|
t.Fatalf("ready entry must be dispatchable, AllReady=%d", n)
|
|
}
|
|
}
|
|
|
|
// TestReadyHandshakeIdempotentAndStaleRejected pins that a duplicate ready for
|
|
// the current owner acks success without repeating the connect lifecycle/event,
|
|
// and that a ready from a non-owner (a rejected duplicate connection) is rejected
|
|
// and drives no lifecycle at all.
|
|
func TestReadyHandshakeIdempotentAndStaleRejected(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "idem-01", Alias: "idem", Token: "idem-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
rec := &connectRecorder{}
|
|
server.SetNodeConnectHandler(rec.onConnect)
|
|
server.SetNodeEventHandler(rec.onNodeEvent)
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
owner, resp := resilientRegister(t, ctx, listenAddr, "idem-token")
|
|
defer owner.Close()
|
|
if !resp.GetAccepted() {
|
|
t.Fatalf("expected accepted, got %q", resp.GetReason())
|
|
}
|
|
|
|
// First ready: one lifecycle + one event.
|
|
signalReady(t, owner, resp.GetNodeId())
|
|
deadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
if seq, _, _ := rec.snapshot(); len(seq) >= 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timeout waiting for first ready lifecycle+event")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
// Duplicate ready on the same owner: acked ready, but no extra lifecycle/event.
|
|
dup, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&owner.Communicator, &iop.NodeReadyRequest{NodeId: resp.GetNodeId()}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("duplicate ready: %v", err)
|
|
}
|
|
if !dup.GetReady() {
|
|
t.Fatalf("duplicate ready for current owner must ack ready, got reason %q", dup.GetReason())
|
|
}
|
|
|
|
// A rejected duplicate connection is not the owner; its ready must be rejected.
|
|
second, dupReg := resilientRegister(t, ctx, listenAddr, "idem-token")
|
|
defer second.Close()
|
|
if dupReg.GetAccepted() {
|
|
t.Fatal("expected duplicate registration to be rejected")
|
|
}
|
|
staleReady, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&second.Communicator, &iop.NodeReadyRequest{NodeId: "idem-01"}, 45*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("stale ready: %v", err)
|
|
}
|
|
if staleReady.GetReady() {
|
|
t.Fatal("ready from a non-owner connection must be rejected")
|
|
}
|
|
|
|
// Still exactly one lifecycle + one connected event across all readys.
|
|
time.Sleep(150 * time.Millisecond)
|
|
seq, gens, _ := rec.snapshot()
|
|
if len(seq) != 2 || seq[0] != "lifecycle" || seq[1] != "event" {
|
|
t.Fatalf("duplicate/stale ready changed the sequence: %v, want [lifecycle event]", seq)
|
|
}
|
|
if len(gens) != 1 {
|
|
t.Fatalf("expected exactly one connect callback, got generations %v", gens)
|
|
}
|
|
}
|
|
|
|
// TestReadyDisconnectRace wires the real Service.HandleNodeConnect /
|
|
// HandleNodeDisconnect — the actual queue handlers, not a dummy — as the
|
|
// authoritative connect/disconnect lifecycle, and forces the ready/disconnect
|
|
// race deterministically. It preserves the old owner's entry snapshot at the
|
|
// ready transition, completes the real server-side disconnect listener, accepts
|
|
// a pending reconnect, then resumes the old continuation against the real queue
|
|
// handler and WithCurrentOwner event gate. The stale continuation activates
|
|
// nothing and emits no connected event; only the reconnect's own ready produces
|
|
// exactly one connected event.
|
|
//
|
|
// The deterministic proof that a stale generation neither pumps nor re-activates
|
|
// a queued waiter lives in the white-box service test
|
|
// TestReconnectActivationLinearizesAgainstDisconnect; here the same real handler
|
|
// is exercised across the transport ready/disconnect boundary.
|
|
func TestReadyDisconnectRace(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
listenAddr := getFreePort(t)
|
|
registry := edgenode.NewRegistry()
|
|
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
|
|
{ID: "race-01", Alias: "race", Token: "race-token", AgentKind: config.AgentKindGenericNode},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("load node store: %v", err)
|
|
}
|
|
server, err := transport.NewServer(listenAddr, registry, nodeStore, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
|
|
// The real queue handlers back the transport lifecycle.
|
|
svc := edgeservice.New(registry, edgeevents.NewBus())
|
|
|
|
var connectMu sync.Mutex
|
|
var connectGenerations []uint64
|
|
handleConnect := func(nodeID string, gen uint64) {
|
|
connectMu.Lock()
|
|
connectGenerations = append(connectGenerations, gen)
|
|
connectMu.Unlock()
|
|
// Actual queue handler: a superseded generation is a no-op here.
|
|
svc.HandleNodeConnect(nodeID, gen)
|
|
}
|
|
server.SetNodeConnectHandler(handleConnect)
|
|
disconnectDone := make(chan uint64, 1)
|
|
server.SetNodeDisconnectHandler(func(nodeID string, generation uint64, reason string) {
|
|
svc.HandleNodeDisconnect(nodeID, generation, reason)
|
|
disconnectDone <- generation
|
|
})
|
|
|
|
var eventMutex sync.Mutex
|
|
var connectedEvents []*iop.EdgeNodeEvent
|
|
server.SetNodeEventHandler(func(e *iop.EdgeNodeEvent) {
|
|
if e.GetType() != eventpkg.TypeNodeConnected {
|
|
return
|
|
}
|
|
eventMutex.Lock()
|
|
connectedEvents = append(connectedEvents, e)
|
|
eventMutex.Unlock()
|
|
})
|
|
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
// 1. Old owner registers and becomes the current generation.
|
|
client1, resp1 := resilientRegister(t, ctx, listenAddr, "race-token")
|
|
defer client1.Close()
|
|
if !resp1.GetAccepted() {
|
|
t.Fatalf("register 1 rejected: %s", resp1.GetReason())
|
|
}
|
|
entry1, ok := registry.Get("race-01")
|
|
if !ok {
|
|
t.Fatal("expected race-01 in registry after registration")
|
|
}
|
|
oldGen := entry1.ConnectionGeneration
|
|
client1ServerSide := entry1.Client
|
|
|
|
// Preserve the exact snapshot that handleReadyRequest receives from the
|
|
// pending→ready transition, but delay its connect/event continuation until
|
|
// after the authoritative disconnect has settled.
|
|
staleReadyEntry, transitioned, ok := registry.MarkDispatchReadyOwner("race-01", client1ServerSide)
|
|
if !ok || !transitioned {
|
|
t.Fatalf("old owner ready transition=(ok=%v transitioned=%v), want true,true", ok, transitioned)
|
|
}
|
|
|
|
// 2. Close the peer normally and wait for bindDisconnectListener to finish
|
|
// current-owner unregister plus the real Service queue settlement.
|
|
if err := client1.Close(); err != nil {
|
|
t.Fatalf("close old owner: %v", err)
|
|
}
|
|
select {
|
|
case disconnectedGen := <-disconnectDone:
|
|
if disconnectedGen != oldGen {
|
|
t.Fatalf("disconnect generation=%d, want old owner %d", disconnectedGen, oldGen)
|
|
}
|
|
case <-time.After(45 * time.Second):
|
|
t.Fatal("authoritative disconnect listener did not complete")
|
|
}
|
|
|
|
client2, resp2 := resilientRegister(t, ctx, listenAddr, "race-token")
|
|
defer client2.Close()
|
|
if !resp2.GetAccepted() {
|
|
t.Fatalf("expected reconnect registration accepted, got reason: %s", resp2.GetReason())
|
|
}
|
|
entry2, ok := registry.Get("race-01")
|
|
if !ok || entry2.Client == client1ServerSide {
|
|
t.Fatal("registry must hold the reconnected owner")
|
|
}
|
|
if entry2.DispatchReady {
|
|
t.Fatal("reconnected owner must remain pending until its own ready")
|
|
}
|
|
|
|
// 3. Resume the stale continuation. It runs the real queue handler but the
|
|
// generation currency check makes it a no-op; the exact old entry snapshot is
|
|
// also rejected by the same WithCurrentOwner gate used for connected events.
|
|
handleConnect("race-01", oldGen)
|
|
staleEventRan := registry.WithCurrentOwner(staleReadyEntry, func() {
|
|
eventMutex.Lock()
|
|
connectedEvents = append(connectedEvents, &iop.EdgeNodeEvent{Type: eventpkg.TypeNodeConnected})
|
|
eventMutex.Unlock()
|
|
})
|
|
if staleEventRan {
|
|
t.Fatal("stale ready snapshot passed the connected-event owner gate")
|
|
}
|
|
eventMutex.Lock()
|
|
staleEvents := len(connectedEvents)
|
|
eventMutex.Unlock()
|
|
if staleEvents != 0 {
|
|
t.Fatalf("stale generation emitted %d connected events, want 0", staleEvents)
|
|
}
|
|
|
|
entryAfter, _ := registry.Get("race-01")
|
|
if entryAfter.DispatchReady {
|
|
t.Error("reconnected owner must remain pending after the stale continuation")
|
|
}
|
|
|
|
// 4. The reconnect's own ready activates and emits exactly one connected event.
|
|
signalReady(t, client2, resp2.GetNodeId())
|
|
readyDeadline := time.Now().Add(45 * time.Second)
|
|
for {
|
|
eventMutex.Lock()
|
|
n := len(connectedEvents)
|
|
eventMutex.Unlock()
|
|
if n >= 1 {
|
|
break
|
|
}
|
|
if time.Now().After(readyDeadline) {
|
|
t.Fatal("reconnect ready did not emit a connected event")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
eventMutex.Lock()
|
|
numEvents := len(connectedEvents)
|
|
eventMutex.Unlock()
|
|
if numEvents != 1 {
|
|
t.Errorf("expected exactly 1 connected event after reconnect ready, got %d", numEvents)
|
|
}
|
|
|
|
// The real connect handler was invoked for the reconnected generation too.
|
|
connectMu.Lock()
|
|
sawNew := false
|
|
for _, g := range connectGenerations {
|
|
if g == entry2.ConnectionGeneration {
|
|
sawNew = true
|
|
}
|
|
}
|
|
connectMu.Unlock()
|
|
if !sawNew {
|
|
t.Fatal("real connect handler was not invoked for the reconnected generation")
|
|
}
|
|
}
|