Files
shater/protocol/group/urltest_balance_lx_test.go
T
omarandClaude Fable 5 e60ad231f4 feat(group): failover fail-back — a revived higher-priority node re-takes the slot
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>
2026-07-22 18:31:17 +03:00

705 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}