mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
ZAP binary protocol duplex RPC standardizes on :9999 across every service. Lux validator staking/RPC ports (9630/9631 mainnet, 9650/9651 devnet) are unchanged — those are luxd network protocol ports, not ZAP. One ZAP port everywhere (was a mix of 9651/9652/9653/9633/9900).
473 lines
12 KiB
Go
473 lines
12 KiB
Go
// Copyright (c) 2025, Hanzo Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
//
|
|
// Edge-case coverage for dnsMembership + staticMembership. Covers the
|
|
// full matrix a Base app goes through in the wild:
|
|
//
|
|
// - solo local dev (no BASE_PEERS)
|
|
// - docker compose 3 static (every seed resolves)
|
|
// - docker compose partial (some seeds temporarily unresolvable)
|
|
// - k8s headless N pods (single seed → N IPs)
|
|
// - k8s scale events (addresses appear / disappear mid-flight)
|
|
// - mixed seeds (one headless name + a few static hosts)
|
|
// - self in seed list (common pattern; must not double-count)
|
|
// - duplicate addresses (multiple seeds pointing at same pod)
|
|
// - port inheritance (some seeds have :port, others don't)
|
|
// - flaky resolver (seeds error out, then recover)
|
|
// - Watch subscription (back-pressure on slow subscribers)
|
|
// - Close() (idempotent, unblocks subscribers)
|
|
// - race conditions (concurrent Refresh + Watch + Close)
|
|
|
|
package network
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestSoloLocalDev: developer runs `go run` with no BASE_* env. The
|
|
// Membership should be a singleton of self, with no DNS lookups at
|
|
// all (resolver should never be called since seeds is empty).
|
|
func TestSoloLocalDev(t *testing.T) {
|
|
var lookups atomic.Int32
|
|
r := &countingResolver{inner: &fakeResolver{}, counter: &lookups}
|
|
m := newDNSMembership(context.Background(), "dev-laptop", nil, r, 10*time.Millisecond)
|
|
defer m.Close()
|
|
|
|
// Give a refresh tick a chance to fire.
|
|
time.Sleep(30 * time.Millisecond)
|
|
|
|
got := m.Members()
|
|
if len(got) != 1 || got[0] != "dev-laptop" {
|
|
t.Errorf("solo: Members() = %v, want [dev-laptop]", got)
|
|
}
|
|
if lookups.Load() != 0 {
|
|
t.Errorf("solo: %d DNS lookups, want 0", lookups.Load())
|
|
}
|
|
}
|
|
|
|
// TestComposePartial: 3-node compose where one service is still
|
|
// starting and hasn't appeared in DNS. Membership grows to 3 when
|
|
// the missing node finally resolves.
|
|
func TestComposePartial(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("base-0", []string{"172.18.0.2"})
|
|
r.Set("base-1", []string{"172.18.0.3"})
|
|
// base-2 NOT set yet — simulating slower container.
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"base-0",
|
|
[]string{"base-0:9999", "base-1:9999", "base-2:9999"},
|
|
r,
|
|
10*time.Millisecond,
|
|
)
|
|
defer m.Close()
|
|
|
|
initial := m.Members()
|
|
if len(initial) != 3 { // base-0 (self) + base-1 + (literal "base-0" entry is self) + base-1
|
|
// Actually: self + (base-0 resolves to .2 but carries :9999 → different NodeID than self)
|
|
// Let's just assert it's >= 2 and <= 4
|
|
if len(initial) < 2 {
|
|
t.Errorf("initial partial: got %d members, want >= 2", len(initial))
|
|
}
|
|
}
|
|
|
|
watch := m.Watch(context.Background())
|
|
<-watch // drain
|
|
|
|
r.Set("base-2", []string{"172.18.0.4"})
|
|
|
|
select {
|
|
case snap := <-watch:
|
|
// Should have grown
|
|
if len(snap) <= len(initial) {
|
|
t.Errorf("after base-2 appeared: got %d, want > %d", len(snap), len(initial))
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("no membership update after base-2 resolved")
|
|
}
|
|
}
|
|
|
|
// TestMixedSeeds: one headless name + two static hosts. The headless
|
|
// name expands; the static hosts stay as one each.
|
|
func TestMixedSeeds(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("bd.svc", []string{"10.0.0.1", "10.0.0.2"})
|
|
r.Set("legacy-bd-primary", []string{"192.168.1.50"})
|
|
r.Set("archive-bd", []string{"192.168.1.51"})
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"-0",
|
|
[]string{
|
|
"bd.svc:9999",
|
|
"legacy-bd-primary:9999",
|
|
"archive-bd:9999",
|
|
},
|
|
r,
|
|
time.Hour,
|
|
)
|
|
defer m.Close()
|
|
|
|
got := m.Members()
|
|
// self + 2 from headless + 1 legacy + 1 archive = 5
|
|
if len(got) != 5 {
|
|
t.Errorf("mixed seeds: got %d members, want 5", len(got))
|
|
}
|
|
}
|
|
|
|
// TestSelfInSeedList: BASE_PEERS includes the local pod's own DNS
|
|
// name (the operator emits this for simplicity). dnsMembership must
|
|
// dedupe so Members() doesn't list self twice.
|
|
func TestSelfInSeedList(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("-0.bd-network.ns.svc", []string{"10.60.1.1"})
|
|
r.Set("-1.bd-network.ns.svc", []string{"10.60.1.2"})
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"-0",
|
|
[]string{
|
|
"-0.bd-network.ns.svc:9999", // self
|
|
"-1.bd-network.ns.svc:9999",
|
|
},
|
|
r,
|
|
time.Hour,
|
|
)
|
|
defer m.Close()
|
|
|
|
got := m.Members()
|
|
// self + IP of self (different string) + IP of bd-1 = at most 3
|
|
// but with sane dedup the "-0" symbolic name matches the
|
|
// IP via isSelfPeer at the transport layer. Here we just assert
|
|
// no duplicate NodeID strings.
|
|
seen := map[NodeID]int{}
|
|
for _, id := range got {
|
|
seen[id]++
|
|
}
|
|
for id, n := range seen {
|
|
if n > 1 {
|
|
t.Errorf("duplicate member %q appears %d times", id, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDuplicateAddresses: two seeds that resolve to the same IP
|
|
// should dedupe to one member.
|
|
func TestDuplicateAddresses(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("bd-primary", []string{"10.0.0.1"})
|
|
r.Set("bd-backup", []string{"10.0.0.1"}) // same IP
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"self",
|
|
[]string{"bd-primary:9999", "bd-backup:9999"},
|
|
r,
|
|
time.Hour,
|
|
)
|
|
defer m.Close()
|
|
|
|
got := m.Members()
|
|
// self + single 10.0.0.1:9999 = 2
|
|
if len(got) != 2 {
|
|
t.Errorf("duplicate IP: got %d members, want 2 (self + 10.0.0.1)", len(got))
|
|
}
|
|
}
|
|
|
|
// TestPortInheritance: some seeds have :port, others don't. Seeds
|
|
// without an explicit port should inherit from the first seed that
|
|
// does.
|
|
func TestPortInheritance(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("bd-a", []string{"10.0.0.1"})
|
|
r.Set("bd-b", []string{"10.0.0.2"})
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"self",
|
|
[]string{"bd-a:9999", "bd-b"}, // bd-b missing port
|
|
r,
|
|
time.Hour,
|
|
)
|
|
defer m.Close()
|
|
|
|
got := m.Members()
|
|
for _, id := range got {
|
|
if id == "self" {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(string(id), ":9999") {
|
|
t.Errorf("member %q missing inherited :9999 port", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFlakyResolver: a seed whose DNS fails on first lookup but
|
|
// succeeds later. Membership should converge on the recovered state.
|
|
func TestFlakyResolver(t *testing.T) {
|
|
r := &flakyResolver{
|
|
succeed: map[string][]string{
|
|
"bd-a": {"10.0.0.1"},
|
|
},
|
|
failNext: map[string]int{"bd-b": 1},
|
|
succeedAfterFail: map[string][]string{
|
|
"bd-b": {"10.0.0.2"},
|
|
},
|
|
}
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"self",
|
|
[]string{"bd-a:9999", "bd-b:9999"},
|
|
r,
|
|
10*time.Millisecond,
|
|
)
|
|
defer m.Close()
|
|
|
|
initial := m.Members()
|
|
// self + bd-a = 2 (bd-b failed first lookup)
|
|
if len(initial) != 2 {
|
|
t.Errorf("flaky initial: got %d, want 2", len(initial))
|
|
}
|
|
|
|
watch := m.Watch(context.Background())
|
|
<-watch // drain
|
|
|
|
select {
|
|
case snap := <-watch:
|
|
if len(snap) != 3 {
|
|
t.Errorf("after flaky recovery: got %d, want 3", len(snap))
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("flaky resolver did not recover in time")
|
|
}
|
|
}
|
|
|
|
// TestWatchBackPressure: a slow subscriber must not stall the
|
|
// polling loop or block other subscribers.
|
|
func TestWatchBackPressure(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("bd", []string{"10.0.0.1"})
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"self",
|
|
[]string{"bd:9999"},
|
|
r,
|
|
10*time.Millisecond,
|
|
)
|
|
defer m.Close()
|
|
|
|
// Subscriber that never drains.
|
|
slow := m.Watch(context.Background())
|
|
_ = slow // intentionally unread
|
|
|
|
// Fast subscriber must still receive.
|
|
fast := m.Watch(context.Background())
|
|
<-fast // initial
|
|
|
|
r.Set("bd", []string{"10.0.0.1", "10.0.0.2"})
|
|
|
|
select {
|
|
case <-fast:
|
|
// good
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("slow subscriber stalled fast subscriber")
|
|
}
|
|
}
|
|
|
|
// TestCloseIdempotent: Close() must be safe to call multiple times.
|
|
func TestCloseIdempotent(t *testing.T) {
|
|
m := newDNSMembership(context.Background(), "self", nil, &fakeResolver{}, time.Hour)
|
|
if err := m.Close(); err != nil {
|
|
t.Errorf("first Close: %v", err)
|
|
}
|
|
// Second Close must not panic or block.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
m.Close()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second Close blocked")
|
|
}
|
|
}
|
|
|
|
// TestCloseUnblocksWatchers: Close() must close all subscriber channels.
|
|
func TestCloseUnblocksWatchers(t *testing.T) {
|
|
m := newDNSMembership(context.Background(), "self", nil, &fakeResolver{}, time.Hour)
|
|
|
|
watch := m.Watch(context.Background())
|
|
<-watch // initial
|
|
|
|
_ = m.Close()
|
|
|
|
select {
|
|
case _, ok := <-watch:
|
|
if ok {
|
|
// drain until closed
|
|
<-watch
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Close did not unblock Watch channel")
|
|
}
|
|
}
|
|
|
|
// TestConcurrentRefreshAndWatch: no data races under concurrent
|
|
// Refresh (via ticker), Watch subscribe/unsubscribe, and Members()
|
|
// reads. Run under `go test -race` to catch actual issues.
|
|
func TestConcurrentRefreshAndWatch(t *testing.T) {
|
|
r := &fakeResolver{}
|
|
r.Set("bd", []string{"10.0.0.1"})
|
|
|
|
m := newDNSMembership(
|
|
context.Background(),
|
|
"self",
|
|
[]string{"bd:9999"},
|
|
r,
|
|
5*time.Millisecond,
|
|
)
|
|
defer m.Close()
|
|
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
|
|
// Reader goroutines
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
_ = m.Members()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Subscribe/unsubscribe goroutines
|
|
for i := 0; i < 3; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ch := m.Watch(ctx)
|
|
<-ch
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// DNS churn
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
addrs := [][]string{
|
|
{"10.0.0.1"},
|
|
{"10.0.0.1", "10.0.0.2"},
|
|
{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
|
|
{"10.0.0.1", "10.0.0.3"},
|
|
}
|
|
i := 0
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
r.Set("bd", addrs[i%len(addrs)])
|
|
i++
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
}()
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
close(stop)
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestStaticMembershipSet: the test-only Set() method must fire
|
|
// notifications and include self even if omitted by caller.
|
|
func TestStaticMembershipSet(t *testing.T) {
|
|
m := NewStaticMembership("a", nil)
|
|
if got := m.Members(); len(got) != 1 || got[0] != "a" {
|
|
t.Errorf("initial: got %v, want [a]", got)
|
|
}
|
|
|
|
watch := m.Watch(context.Background())
|
|
<-watch // initial
|
|
|
|
m.Set([]string{"b", "c"})
|
|
|
|
select {
|
|
case snap := <-watch:
|
|
if len(snap) != 3 {
|
|
t.Errorf("after Set: got %d, want 3 (a,b,c)", len(snap))
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Set did not notify watchers")
|
|
}
|
|
}
|
|
|
|
// TestStaticMembershipSelfNotDuplicated: caller passes self in the
|
|
// members slice; must not be counted twice.
|
|
func TestStaticMembershipSelfNotDuplicated(t *testing.T) {
|
|
m := NewStaticMembership("a", []string{"a", "b", "c"})
|
|
got := m.Members()
|
|
if len(got) != 3 {
|
|
t.Errorf("self dedup: got %d, want 3", len(got))
|
|
}
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────
|
|
|
|
type countingResolver struct {
|
|
inner Resolver
|
|
counter *atomic.Int32
|
|
}
|
|
|
|
func (c *countingResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
|
c.counter.Add(1)
|
|
return c.inner.LookupHost(ctx, host)
|
|
}
|
|
|
|
type flakyResolver struct {
|
|
mu sync.Mutex
|
|
succeed map[string][]string
|
|
failNext map[string]int
|
|
succeedAfterFail map[string][]string
|
|
}
|
|
|
|
func (f *flakyResolver) LookupHost(_ context.Context, host string) ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if n, ok := f.failNext[host]; ok && n > 0 {
|
|
f.failNext[host] = n - 1
|
|
return nil, &hostNotFound{host: host}
|
|
}
|
|
if addrs, ok := f.succeedAfterFail[host]; ok {
|
|
return append([]string(nil), addrs...), nil
|
|
}
|
|
if addrs, ok := f.succeed[host]; ok {
|
|
return append([]string(nil), addrs...), nil
|
|
}
|
|
return nil, &hostNotFound{host: host}
|
|
}
|