New balancer flag priority (option.URLTestBalancerOptions.Priority): the pool is re-derived from CONFIG ORDER every health-check tick via balancePoolPriority/planPriorityPool — the first live member owns slot 0, so when the top node answers probes again traffic returns to it on the next tick (30s failover interval). Probing walks top-down and stops at the first live node, so the steady-state cost stays one probe per tick. Replace-in-slot deliberately does not apply here: failover forces sticky ["none"], so relocating nodes across slots breaks no flow keys. Plain round_robin/random paths are untouched. failoverBalancer() now emits Priority:true; the KNOWN LIMITATION note and the panel's "nothing brings it back" blurb are gone because the limitation is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
705 lines
26 KiB
Go
705 lines
26 KiB
Go
package group
|
||
|
||
import (
|
||
"context"
|
||
"net/netip"
|
||
"testing"
|
||
|
||
"github.com/sagernet/sing-box/adapter"
|
||
C "github.com/sagernet/sing-box/constant"
|
||
"github.com/sagernet/sing-box/option"
|
||
M "github.com/sagernet/sing/common/metadata"
|
||
N "github.com/sagernet/sing/common/network"
|
||
)
|
||
|
||
// balNode is a minimal adapter.Outbound exposing only Tag/Network — all pick() reads.
|
||
type balNode struct {
|
||
adapter.Outbound
|
||
tag string
|
||
}
|
||
|
||
func (n *balNode) Tag() string { return n.tag }
|
||
func (n *balNode) Network() []string { return []string{N.NetworkTCP, N.NetworkUDP} }
|
||
|
||
// resolveFrom builds a pick() resolver over a fixed tag→node set.
|
||
func resolveFrom(tags ...string) ([]adapter.Outbound, func(string) adapter.Outbound) {
|
||
nodes := map[string]adapter.Outbound{}
|
||
for _, t := range tags {
|
||
nodes[t] = &balNode{tag: t}
|
||
}
|
||
return nil, func(tag string) adapter.Outbound { return nodes[tag] }
|
||
}
|
||
|
||
func rrBalancer(t *testing.T, pool int, stickyHash []string) *balancer {
|
||
t.Helper()
|
||
opts := option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{Pool: pool, StickyHash: stickyHash},
|
||
}
|
||
b, err := newBalancer(opts)
|
||
if err != nil {
|
||
t.Fatalf("newBalancer: %v", err)
|
||
}
|
||
if b == nil {
|
||
t.Fatal("round_robin balancer must not be nil")
|
||
}
|
||
return b
|
||
}
|
||
|
||
func destDomain(host string) M.Socksaddr { return M.Socksaddr{Fqdn: host} }
|
||
|
||
// allLive marks every given tag live — the common case for pick() tests that only exercise
|
||
// selection over a fully-healthy pool.
|
||
func allLive(tags ...string) map[string]bool {
|
||
m := make(map[string]bool, len(tags))
|
||
for _, t := range tags {
|
||
m[t] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
// randomBalancer builds a mode=random balancer with the given pool size (stickiness off).
|
||
func randomBalancer(t *testing.T, pool int) *balancer {
|
||
t.Helper()
|
||
b, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRandom,
|
||
Balancer: &option.URLTestBalancerOptions{Pool: pool},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("newBalancer(random): %v", err)
|
||
}
|
||
if b == nil {
|
||
t.Fatal("random balancer must not be nil")
|
||
}
|
||
return b
|
||
}
|
||
|
||
// --- newBalancer: modes, defaults, validation ---------------------------------------
|
||
|
||
func TestBalancerLeastTestIsNil(t *testing.T) {
|
||
for _, mode := range []string{"", C.URLTestModeLeastTest} {
|
||
b, err := newBalancer(option.URLTestOutboundOptions{Mode: mode})
|
||
if err != nil {
|
||
t.Fatalf("mode %q: %v", mode, err)
|
||
}
|
||
if b != nil {
|
||
t.Fatalf("mode %q must yield nil balancer (legacy path)", mode)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBalancerUnknownModeRejected(t *testing.T) {
|
||
if _, err := newBalancer(option.URLTestOutboundOptions{Mode: "bogus"}); err == nil {
|
||
t.Fatal("unknown mode must error")
|
||
}
|
||
}
|
||
|
||
func TestBalancerDefaults(t *testing.T) {
|
||
// round_robin without balancer → pool 3, stickiness on by [process,domain].
|
||
b, err := newBalancer(option.URLTestOutboundOptions{Mode: C.URLTestModeRoundRobin})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if b.poolSize != C.DefaultURLTestPool {
|
||
t.Fatalf("default pool = %d, want %d", b.poolSize, C.DefaultURLTestPool)
|
||
}
|
||
if len(b.stickyHash) != 2 {
|
||
t.Fatalf("default sticky_hash must be [process,domain], got %v", b.stickyHash)
|
||
}
|
||
}
|
||
|
||
func TestBalancerStickyHashNoneDisables(t *testing.T) {
|
||
// ["none"] is the explicit disable sentinel. A bare [] cannot be used (the config decoder
|
||
// collapses it to nil, indistinguishable from omitted), so disabling goes through "none".
|
||
b, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{StickyHash: []string{C.URLTestStickyNone}},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(b.stickyHash) != 0 {
|
||
t.Fatalf("[\"none\"] must disable stickiness, got %v", b.stickyHash)
|
||
}
|
||
}
|
||
|
||
func TestBalancerStickyHashEmptyIsDefault(t *testing.T) {
|
||
// A bare [] (or nil) means default, NOT off — the decoder can't preserve an empty list, so
|
||
// [] must fall back to the default rather than silently disabling stickiness.
|
||
b, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{StickyHash: []string{}},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(b.stickyHash) != 2 {
|
||
t.Fatalf("empty sticky_hash must default to [process,domain], got %v", b.stickyHash)
|
||
}
|
||
}
|
||
|
||
func TestBalancerStickyHashNoneMixedRejected(t *testing.T) {
|
||
// "none" mixed with real components is ambiguous → error.
|
||
_, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{StickyHash: []string{C.URLTestStickyNone, C.URLTestStickyDomain}},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("\"none\" mixed with a real component must error")
|
||
}
|
||
}
|
||
|
||
func TestBalancerNegativePoolRejected(t *testing.T) {
|
||
_, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{Pool: -1},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("negative pool must error")
|
||
}
|
||
}
|
||
|
||
func TestBalancerZeroPoolIsDefault(t *testing.T) {
|
||
// pool:0 is indistinguishable from "omitted" for a Go int with omitempty, so it means
|
||
// the default — NOT an error.
|
||
b, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{Pool: 0},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("pool:0 must be treated as default, got error: %v", err)
|
||
}
|
||
if b.poolSize != C.DefaultURLTestPool {
|
||
t.Fatalf("pool:0 → default %d, got %d", C.DefaultURLTestPool, b.poolSize)
|
||
}
|
||
}
|
||
|
||
func TestBalancerUnknownStickyComponentRejected(t *testing.T) {
|
||
_, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{StickyHash: []string{"bogus"}},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("unknown sticky_hash component must error")
|
||
}
|
||
}
|
||
|
||
// --- pick: rotation, fallback ------------------------------------------------------
|
||
|
||
func TestRoundRobinRotation(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyNone}) // no stickiness → pure counter rotation
|
||
b.setSlots([]string{"a", "b", "c"}, allLive("a", "b", "c"))
|
||
_, resolve := resolveFrom("a", "b", "c")
|
||
fb := &balNode{tag: "fb"}
|
||
const rounds = 3000
|
||
count := map[string]int{}
|
||
for i := 0; i < rounds; i++ {
|
||
count[b.pick(context.Background(), M.Socksaddr{}, fb, resolve).Tag()]++
|
||
}
|
||
for _, tag := range []string{"a", "b", "c"} {
|
||
if count[tag] != rounds/3 {
|
||
t.Errorf("node %s got %d, want %d", tag, count[tag], rounds/3)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPickEmptyPoolFallback(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{})
|
||
_, resolve := resolveFrom("a")
|
||
fb := &balNode{tag: "fb"}
|
||
// no setSlots → empty pool → fallback.
|
||
if got := b.pick(context.Background(), M.Socksaddr{}, fb, resolve); got.Tag() != "fb" {
|
||
t.Fatalf("empty pool must return fallback, got %s", got.Tag())
|
||
}
|
||
}
|
||
|
||
func TestPickUnresolvableTagFallsBack(t *testing.T) {
|
||
b := rrBalancer(t, 1, []string{})
|
||
b.setSlots([]string{"gone"}, allLive("gone"))
|
||
_, resolve := resolveFrom() // resolves nothing
|
||
fb := &balNode{tag: "fb"}
|
||
if got := b.pick(context.Background(), M.Socksaddr{}, fb, resolve); got.Tag() != "fb" {
|
||
t.Fatalf("unresolvable slot tag must fall back, got %s", got.Tag())
|
||
}
|
||
}
|
||
|
||
// --- sticky slot-hash: strict-zero reconnects ---------------------------------------
|
||
|
||
func TestStickySlotHashStable(t *testing.T) {
|
||
b := rrBalancer(t, 4, []string{C.URLTestStickyDomain})
|
||
b.setSlots([]string{"a", "b", "c", "d"}, allLive("a", "b", "c", "d"))
|
||
_, resolve := resolveFrom("a", "b", "c", "d")
|
||
first := b.pick(context.Background(), destDomain("example.com"), nil, resolve).Tag()
|
||
for i := 0; i < 100; i++ {
|
||
got := b.pick(context.Background(), destDomain("example.com"), nil, resolve).Tag()
|
||
if got != first {
|
||
t.Fatalf("sticky key must map to one node: %s != %s", got, first)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStickySlotHashLivingNodeKeepsKeysAcrossOtherSlotChanges(t *testing.T) {
|
||
// The strict-zero-reconnect invariant: replacing the occupant of OTHER slots must not
|
||
// move a key whose slot occupant is unchanged.
|
||
b := rrBalancer(t, 4, []string{C.URLTestStickyDomain})
|
||
b.setSlots([]string{"a", "b", "c", "d"}, allLive("a", "b", "c", "d"))
|
||
_, resolve := resolveFrom("a", "b", "c", "d", "x", "y")
|
||
dst := destDomain("keep.me")
|
||
pinnedTag := b.pick(context.Background(), dst, nil, resolve).Tag()
|
||
pinnedSlot := int(hashKey("keep.me") % 4)
|
||
|
||
// Replace every OTHER slot's occupant; the pinned slot keeps its tag.
|
||
newSlots := []string{"a", "b", "c", "d"}
|
||
for i := range newSlots {
|
||
if i != pinnedSlot {
|
||
newSlots[i] = []string{"x", "y", "x", "y"}[i]
|
||
}
|
||
}
|
||
b.setSlots(newSlots, allLive(newSlots...))
|
||
_, resolve2 := resolveFrom(append(newSlots, pinnedTag)...)
|
||
if got := b.pick(context.Background(), dst, nil, resolve2).Tag(); got != pinnedTag {
|
||
t.Fatalf("living node in its slot must keep its key: %s != %s", got, pinnedTag)
|
||
}
|
||
}
|
||
|
||
func TestStickyEmptyKeyFixedSlot(t *testing.T) {
|
||
// All components empty (no domain) → key "" → one fixed slot, no rotation.
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyDomain})
|
||
b.setSlots([]string{"a", "b", "c"}, allLive("a", "b", "c"))
|
||
_, resolve := resolveFrom("a", "b", "c")
|
||
first := b.pick(context.Background(), M.Socksaddr{}, nil, resolve).Tag()
|
||
for i := 0; i < 50; i++ {
|
||
if got := b.pick(context.Background(), M.Socksaddr{}, nil, resolve).Tag(); got != first {
|
||
t.Fatalf("empty-key flows must not rotate: %s != %s", got, first)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- sticky key building ------------------------------------------------------------
|
||
|
||
func TestStickyKeyComponents(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyDestIP, C.URLTestStickyDestPort})
|
||
dst := M.Socksaddr{Addr: netip.MustParseAddr("203.0.113.7"), Port: 443}
|
||
if key := b.stickyKey(context.Background(), dst); key != "203.0.113.7\x00443" {
|
||
t.Fatalf("unexpected key %q", key)
|
||
}
|
||
// Absent components collapse to "".
|
||
b2 := rrBalancer(t, 3, []string{C.URLTestStickyDomain})
|
||
if key := b2.stickyKey(context.Background(), M.Socksaddr{}); key != "" {
|
||
t.Fatalf("absent domain must yield empty key, got %q", key)
|
||
}
|
||
}
|
||
|
||
// Regression for the device-observed collapse-to-one-node bug: the router resolves a domain
|
||
// destination to an IP and overwrites metadata.Destination before the group dials, so
|
||
// destination.Fqdn is EMPTY at pick() time. The original domain survives in metadata.Domain.
|
||
// The sticky key must read metadata.Domain so domain traffic spreads across slots instead of
|
||
// every connection hashing to the same slot (process-only key). Verified on device: fixing this
|
||
// took chrome distribution from 28/1/1 (entropy 0.27) to spread across the pool.
|
||
func TestStickyKeyDomainFromMetadataWhenDestinationIsIP(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyProcess, C.URLTestStickyDomain})
|
||
// destination is a resolved IP (Fqdn empty), the domain is only in metadata.Domain.
|
||
resolved := M.Socksaddr{Addr: netip.MustParseAddr("142.250.1.1"), Port: 443}
|
||
ctx := adapter.WithContext(context.Background(), &adapter.InboundContext{
|
||
Domain: "www.google.com",
|
||
})
|
||
key := b.stickyKey(ctx, resolved)
|
||
// process empty (no ProcessInfo) + domain from metadata → "\x00www.google.com"
|
||
if key != "\x00www.google.com" {
|
||
t.Fatalf("domain must come from metadata.Domain when destination is an IP, got %q", key)
|
||
}
|
||
// Two different domains (same process) must yield different keys — the whole point.
|
||
ctx2 := adapter.WithContext(context.Background(), &adapter.InboundContext{Domain: "www.reddit.com"})
|
||
if k2 := b.stickyKey(ctx2, resolved); k2 == key {
|
||
t.Fatal("different domains must produce different sticky keys (otherwise all traffic collapses to one slot)")
|
||
}
|
||
}
|
||
|
||
func TestStickyKeyDomainFallsBackToFqdn(t *testing.T) {
|
||
// When metadata.Domain is empty but destination still carries an Fqdn (e.g. direct dial,
|
||
// no router resolve), fall back to destination.Fqdn.
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyDomain})
|
||
ctx := adapter.WithContext(context.Background(), &adapter.InboundContext{}) // Domain empty
|
||
if key := b.stickyKey(ctx, destDomain("example.com")); key != "example.com" {
|
||
t.Fatalf("must fall back to destination.Fqdn, got %q", key)
|
||
}
|
||
}
|
||
|
||
// --- planTolerantPool: top-N, replace-in-slot, fixed slots --------------------------
|
||
|
||
func cand(tag string, delay uint16) candidate {
|
||
return candidate{tag: tag, delay: delay, alive: true}
|
||
}
|
||
|
||
func TestPlanTolerantPoolTopN(t *testing.T) {
|
||
// Empty pool, 5 nodes, size 3 → 3 fastest by delay.
|
||
results := map[string]candidate{
|
||
"a": cand("a", 100), "b": cand("b", 20), "c": cand("c", 50),
|
||
"d": cand("d", 10), "e": cand("e", 80),
|
||
}
|
||
next := planTolerantPool(nil, results, 3, 0)
|
||
if len(next) != 3 {
|
||
t.Fatalf("pool size = %d, want 3", len(next))
|
||
}
|
||
got := map[string]bool{}
|
||
for _, tag := range next {
|
||
got[tag] = true
|
||
}
|
||
// fastest three: d(10), b(20), c(50)
|
||
for _, tag := range []string{"d", "b", "c"} {
|
||
if !got[tag] {
|
||
t.Errorf("top-3 must include %s, got %v", tag, next)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPlanTolerantPoolKeepsLivingInSlot(t *testing.T) {
|
||
// Current pool [a,b,c] all alive; a faster outsider exists but within tolerance → no churn.
|
||
current := []string{"a", "b", "c"}
|
||
results := map[string]candidate{
|
||
"a": cand("a", 100), "b": cand("b", 100), "c": cand("c", 100),
|
||
"x": cand("x", 90), // faster, but by only 10 — within tolerance 50
|
||
}
|
||
next := planTolerantPool(current, results, 3, 50)
|
||
for i, tag := range []string{"a", "b", "c"} {
|
||
if next[i] != tag {
|
||
t.Fatalf("living node within tolerance must keep slot %d: got %v", i, next)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPlanTolerantPoolEvictsBeyondTolerance(t *testing.T) {
|
||
// Outsider beats the slot occupant by more than tolerance → it takes that slot.
|
||
current := []string{"a", "b", "c"}
|
||
results := map[string]candidate{
|
||
"a": cand("a", 100), "b": cand("b", 100), "c": cand("c", 100),
|
||
"x": cand("x", 10), // beats by 90 > tolerance 50
|
||
}
|
||
next := planTolerantPool(current, results, 3, 50)
|
||
found := false
|
||
for _, tag := range next {
|
||
if tag == "x" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("outsider faster by > tolerance must enter the pool: %v", next)
|
||
}
|
||
if len(next) != 3 {
|
||
t.Fatalf("pool must stay size 3, got %v", next)
|
||
}
|
||
}
|
||
|
||
func TestPlanTolerantPoolDeadSlotReplaced(t *testing.T) {
|
||
// b died; a live outsider must replace it (replace-in-slot, pool stays full).
|
||
current := []string{"a", "b", "c"}
|
||
results := map[string]candidate{
|
||
"a": cand("a", 30), "c": cand("c", 30),
|
||
"b": {tag: "b", alive: false},
|
||
"x": cand("x", 40),
|
||
}
|
||
next := planTolerantPool(current, results, 3, 0)
|
||
if len(next) != 3 {
|
||
t.Fatalf("pool must stay full, got %v", next)
|
||
}
|
||
hasX, hasB := false, false
|
||
for _, tag := range next {
|
||
if tag == "x" {
|
||
hasX = true
|
||
}
|
||
if tag == "b" {
|
||
hasB = true
|
||
}
|
||
}
|
||
if !hasX || hasB {
|
||
t.Fatalf("dead b must be replaced by live x: %v", next)
|
||
}
|
||
}
|
||
|
||
// Regression: a living slot occupant must NEVER change slot index when an outsider claims a
|
||
// DIFFERENT slot. Before the fix, eviction did delete(inPool, occupant), re-circulating the
|
||
// evicted-but-living node into a later slot — relocating it and moving its sticky keys.
|
||
// planTolerantPool(["a","b"], {a:100,b:200,c:10}, 2, 0) must yield ["c","b"], not ["c","a"]:
|
||
// c rightfully takes slot 0 (beats a), and the SURVIVOR b keeps slot 1 (a, evicted from slot 0,
|
||
// must not reappear at slot 1).
|
||
func TestPlanTolerantPoolSurvivorKeepsSlot(t *testing.T) {
|
||
current := []string{"a", "b"}
|
||
results := map[string]candidate{
|
||
"a": cand("a", 100), "b": cand("b", 200), "c": cand("c", 10),
|
||
}
|
||
next := planTolerantPool(current, results, 2, 0)
|
||
want := []string{"c", "b"}
|
||
for i := range want {
|
||
if next[i] != want[i] {
|
||
t.Fatalf("survivor must keep its slot: got %v, want %v", next, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Regression: one fast newcomer must not cascade-relocate multiple living survivors. With the
|
||
// old delete(inPool, occupant), a single fast node entering slot 0 could chain-bump occupants
|
||
// across every slot. Here only slot 0 should change; slots 1 and 2 keep their occupants.
|
||
func TestPlanTolerantPoolNoCascadeRelocation(t *testing.T) {
|
||
current := []string{"a", "b", "c"}
|
||
results := map[string]candidate{
|
||
"a": cand("a", 100), "b": cand("b", 110), "c": cand("c", 120),
|
||
"z": cand("z", 1), // one very fast outsider
|
||
}
|
||
next := planTolerantPool(current, results, 3, 0)
|
||
// z takes slot 0 (beats a by > 0). b and c are each beaten by nobody NEW (z is used), so
|
||
// they keep their original slots. a (evicted) must not resurface at slot 1 or 2.
|
||
if next[0] != "z" {
|
||
t.Fatalf("fastest outsider must take slot 0: %v", next)
|
||
}
|
||
if next[1] != "b" || next[2] != "c" {
|
||
t.Fatalf("survivors b,c must keep their slots, no cascade: %v", next)
|
||
}
|
||
}
|
||
|
||
// --- planFirstLivePool (pool_tolerance == 0): replace-in-slot, the FI-outlier guard ---------
|
||
|
||
// The core regression for the device-observed DE→FI→DE outlier. Pool [a,b,c]; the MIDDLE slot
|
||
// occupant b transiently fails its test (not live); a and c stay live; a live non-pool node d
|
||
// exists. Before the fix, balancePoolFirstLive compacted with append → c (slot 2) shifted to
|
||
// slot 1, moving every sticky key bound to slot 1/2 onto a different node. With replace-in-slot,
|
||
// c must KEEP slot 2 and d must fill the freed slot 1.
|
||
func TestPlanFirstLivePoolLiveNodeKeepsSlotAcrossDeadNeighbor(t *testing.T) {
|
||
current := []string{"a", "b", "c"}
|
||
live := map[string]bool{"a": true, "c": true, "d": true} // b is dead this round
|
||
fillOrder := []string{"d"} // one live non-pool replacement
|
||
next := planFirstLivePool(current, live, fillOrder, 3)
|
||
want := []string{"a", "d", "c"}
|
||
for i := range want {
|
||
if next[i] != want[i] {
|
||
t.Fatalf("dead middle slot must be replaced in place, neighbours fixed: got %v, want %v", next, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A dead pool member with no live replacement keeps its own slot (pool never shrinks, and the
|
||
// dead node does not displace a living neighbour).
|
||
func TestPlanFirstLivePoolDeadMemberKeepsSlotWhenNoReplacement(t *testing.T) {
|
||
current := []string{"a", "b", "c"}
|
||
live := map[string]bool{"a": true, "c": true} // b dead, no live non-pool node
|
||
next := planFirstLivePool(current, live, nil, 3)
|
||
want := []string{"a", "b", "c"} // b stays in its slot
|
||
for i := range want {
|
||
if next[i] != want[i] {
|
||
t.Fatalf("dead member must keep its slot when irreplaceable: got %v, want %v", next, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Growing a short pool fills holes at the tail by index without disturbing existing members.
|
||
func TestPlanFirstLivePoolGrowsKeepingExisting(t *testing.T) {
|
||
current := []string{"a"} // pool was size 1, now growing to 3
|
||
live := map[string]bool{"a": true, "b": true, "c": true}
|
||
next := planFirstLivePool(current, live, []string{"b", "c"}, 3)
|
||
want := []string{"a", "b", "c"}
|
||
for i := range want {
|
||
if next[i] != want[i] {
|
||
t.Fatalf("grow must keep existing slot 0 and fill tail: got %v, want %v", next, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- planPriorityPool (priority balancer / failover): config order IS the ranking -----
|
||
|
||
// #1 live → it owns the (only) slot.
|
||
func TestPlanPriorityPoolFirstLiveTakesSlot(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2", "n3"}, allLive("n1", "n2", "n3"), 1)
|
||
if len(next) != 1 || next[0] != "n1" {
|
||
t.Fatalf("first live node must own the slot: got %v", next)
|
||
}
|
||
}
|
||
|
||
// #1 dead, #2 live → slot 0 = #2 (first LIVE in config order).
|
||
func TestPlanPriorityPoolSkipsDeadTop(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2", "n3"}, map[string]bool{"n2": true, "n3": true}, 1)
|
||
if len(next) != 1 || next[0] != "n2" {
|
||
t.Fatalf("first live node in config order must own the slot: got %v", next)
|
||
}
|
||
}
|
||
|
||
// FAIL-BACK: with BOTH #1 and #2 live, the higher-priority #1 owns the slot — a live
|
||
// lower-priority node never keeps a slot ahead of a revived higher-priority one. (Contrast
|
||
// planFirstLivePool, which would keep the incumbent #2 in place: that is the no-fail-back path
|
||
// this balancer exists to fix.) The planner is stateless, so "#1 revived while #2 was serving"
|
||
// is expressed exactly as "#1 and #2 both live" — and #1 must win.
|
||
func TestPlanPriorityPoolFailsBackToHigherPriority(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2"}, allLive("n1", "n2"), 1)
|
||
if len(next) != 1 || next[0] != "n1" {
|
||
t.Fatalf("revived higher-priority node must re-take the slot: got %v", next)
|
||
}
|
||
}
|
||
|
||
// Every node dead → the pool never shrinks: it keeps `size` slots, holding dead nodes in config
|
||
// order. pick() returns the fallback in this state regardless of which dead node holds the slot.
|
||
func TestPlanPriorityPoolAllDeadKeepsSlot(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2", "n3"}, map[string]bool{}, 1)
|
||
if len(next) != 1 || next[0] != "n1" {
|
||
t.Fatalf("all-dead pool must keep one dead slot in config order: got %v", next)
|
||
}
|
||
}
|
||
|
||
// size > 1: the first N LIVE nodes in config order, in order.
|
||
func TestPlanPriorityPoolSizeGreaterThanOne(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2", "n3", "n4"}, map[string]bool{"n2": true, "n3": true, "n4": true}, 2)
|
||
want := []string{"n2", "n3"}
|
||
if len(next) != 2 || next[0] != want[0] || next[1] != want[1] {
|
||
t.Fatalf("size>1: first N live in config order: got %v, want %v", next, want)
|
||
}
|
||
}
|
||
|
||
// size > 1 with too few live nodes: live take the leading slots, remaining holes are filled with
|
||
// dead nodes (config order) so the pool never shrinks and a dead node never displaces a live one.
|
||
func TestPlanPriorityPoolFillsHolesWithDead(t *testing.T) {
|
||
next := planPriorityPool([]string{"n1", "n2", "n3"}, map[string]bool{"n2": true}, 2)
|
||
if len(next) != 2 {
|
||
t.Fatalf("pool must keep size 2 (never-shrink): got %v", next)
|
||
}
|
||
if next[0] != "n2" {
|
||
t.Fatalf("live node must take the first slot: got %v", next)
|
||
}
|
||
if next[1] != "n1" { // first not-yet-placed node in config order
|
||
t.Fatalf("hole must be filled with a dead node in config order: got %v", next)
|
||
}
|
||
}
|
||
|
||
// newBalancer must thread Priority through, and it must default off for a plain round_robin.
|
||
func TestBalancerPriorityFlag(t *testing.T) {
|
||
b, err := newBalancer(option.URLTestOutboundOptions{
|
||
Mode: C.URLTestModeRoundRobin,
|
||
Balancer: &option.URLTestBalancerOptions{Pool: 1, Priority: true, StickyHash: []string{C.URLTestStickyNone}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("newBalancer: %v", err)
|
||
}
|
||
if !b.priority {
|
||
t.Fatal("Priority: true must set balancer.priority")
|
||
}
|
||
if b2 := rrBalancer(t, 3, nil); b2.priority {
|
||
t.Fatal("priority must default to false for a plain round_robin balancer")
|
||
}
|
||
}
|
||
|
||
// --- random mode: config, uniform spread, live-only selection -----------------------
|
||
|
||
func TestRandomModeDefaultsStickyOff(t *testing.T) {
|
||
// mode=random with no balancer block → stickiness OFF (nil), random flag set.
|
||
b, err := newBalancer(option.URLTestOutboundOptions{Mode: C.URLTestModeRandom})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if b == nil {
|
||
t.Fatal("random balancer must not be nil")
|
||
}
|
||
if !b.random {
|
||
t.Fatal("random flag must be set for mode=random")
|
||
}
|
||
if len(b.stickyHash) != 0 {
|
||
t.Fatalf("random mode must default stickiness off, got %v", b.stickyHash)
|
||
}
|
||
if b.poolSize != C.DefaultURLTestPool {
|
||
t.Fatalf("random default pool = %d, want %d", b.poolSize, C.DefaultURLTestPool)
|
||
}
|
||
}
|
||
|
||
// (а) random draws spread across MORE THAN ONE live node — the whole point of the mode.
|
||
func TestRandomSpreadsAcrossLiveNodes(t *testing.T) {
|
||
b := randomBalancer(t, 3)
|
||
b.setSlots([]string{"a", "b", "c"}, allLive("a", "b", "c"))
|
||
_, resolve := resolveFrom("a", "b", "c")
|
||
fb := &balNode{tag: "fb"}
|
||
const rounds = 3000
|
||
count := map[string]int{}
|
||
for i := 0; i < rounds; i++ {
|
||
count[b.pick(context.Background(), M.Socksaddr{}, fb, resolve).Tag()]++
|
||
}
|
||
for _, tag := range []string{"a", "b", "c"} {
|
||
// Uniform over 3 with 3000 draws → ~1000 each; a huge margin proves "more than one".
|
||
if count[tag] < rounds/10 {
|
||
t.Errorf("random must spread across live nodes; %s got only %d of %d", tag, count[tag], rounds)
|
||
}
|
||
}
|
||
if count["fb"] != 0 {
|
||
t.Errorf("fallback must never be used while live nodes exist, got %d", count["fb"])
|
||
}
|
||
}
|
||
|
||
// (б) pick must NEVER return a dead slot while a live one exists — for random AND round_robin.
|
||
func TestRandomNeverPicksDeadSlot(t *testing.T) {
|
||
b := randomBalancer(t, 3)
|
||
// b is dead this round; a and c are live.
|
||
b.setSlots([]string{"a", "b", "c"}, map[string]bool{"a": true, "c": true})
|
||
_, resolve := resolveFrom("a", "b", "c")
|
||
fb := &balNode{tag: "fb"}
|
||
for i := 0; i < 1000; i++ {
|
||
got := b.pick(context.Background(), M.Socksaddr{}, fb, resolve).Tag()
|
||
if got == "b" {
|
||
t.Fatal("random picked the dead slot b")
|
||
}
|
||
if got == "fb" {
|
||
t.Fatal("random fell back to fallback despite live slots")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRoundRobinNeverPicksDeadSlot(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyNone}) // counter rotation
|
||
b.setSlots([]string{"a", "b", "c"}, map[string]bool{"a": true, "c": true})
|
||
_, resolve := resolveFrom("a", "b", "c")
|
||
fb := &balNode{tag: "fb"}
|
||
count := map[string]int{}
|
||
for i := 0; i < 600; i++ {
|
||
got := b.pick(context.Background(), M.Socksaddr{}, fb, resolve).Tag()
|
||
if got == "b" {
|
||
t.Fatal("round_robin picked the dead slot b")
|
||
}
|
||
count[got]++
|
||
}
|
||
// Rotation over the two live slots must alternate evenly between a and c.
|
||
if count["a"] != 300 || count["c"] != 300 {
|
||
t.Fatalf("round_robin over live slots must split evenly a/c, got %v", count)
|
||
}
|
||
}
|
||
|
||
// (в) every slot dead → fallback (no live slot to route through).
|
||
func TestPickAllDeadFallsBack(t *testing.T) {
|
||
for _, name := range []string{"round_robin", "random"} {
|
||
var b *balancer
|
||
if name == "random" {
|
||
b = randomBalancer(t, 2)
|
||
} else {
|
||
b = rrBalancer(t, 2, []string{C.URLTestStickyNone})
|
||
}
|
||
b.setSlots([]string{"a", "b"}, map[string]bool{}) // nothing live
|
||
_, resolve := resolveFrom("a", "b")
|
||
fb := &balNode{tag: "fb"}
|
||
if got := b.pick(context.Background(), M.Socksaddr{}, fb, resolve).Tag(); got != "fb" {
|
||
t.Fatalf("%s: all-dead pool must return fallback, got %s", name, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sticky flow whose hashed slot is dead must degrade to a LIVE slot, not to the fallback and not
|
||
// to the dead occupant.
|
||
func TestStickyDeadSlotDegradesToLive(t *testing.T) {
|
||
b := rrBalancer(t, 3, []string{C.URLTestStickyDomain})
|
||
tags := []string{"a", "b", "c"}
|
||
dst := destDomain("example.com")
|
||
deadSlot := int(hashKey("example.com") % 3)
|
||
live := allLive(tags...)
|
||
delete(live, tags[deadSlot]) // kill exactly the slot this flow hashes to
|
||
b.setSlots(tags, live)
|
||
_, resolve := resolveFrom(tags...)
|
||
fb := &balNode{tag: "fb"}
|
||
got := b.pick(context.Background(), dst, fb, resolve).Tag()
|
||
if got == tags[deadSlot] {
|
||
t.Fatalf("sticky flow must not land on its dead slot %s", tags[deadSlot])
|
||
}
|
||
if got == "fb" {
|
||
t.Fatal("sticky flow must degrade to a live slot, not the fallback")
|
||
}
|
||
}
|