Files
shater/protocol/group/urltest_health_lx_test.go
T
omar 4f0618515e health plan wave 2: alive-only selection+retry, observatory replaces sweep
- S3: Select() alive-only by verdict; dial failure marks board + retries <=3 within ctx; ListenPacket retries to first send; balancer slot liveness reads board verdict; testNodes marks-fail instead of delete; SPEC 019 slot invariants preserved; selector untouched
- S4: new observatory.go (reachability plan from rules, batch<=24/concurrency<=12/timeout 5s, freshness gate, cursor preserved on identical plan); probeplan BuildObservatoryPlan; health.go on board verdicts (TTL=max(3*interval,10min)); engine.dead overlay removed; sweep.go+probeall.go+TestAllNodes+/api/nodes/test removed (->404); GroupHealth.Used published; exit-test extended to chains; panel unused-badge + chain Test button; stats on board
2026-07-24 16:33:28 +03:00

506 lines
18 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
// lx: health board §5.B tests — verdict-driven selection, slot liveness read-through,
// and the dial-retry path, per mode × {alive, dead, untested, stale}.
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/interrupt"
"github.com/sagernet/sing-box/common/urltest"
"github.com/sagernet/sing-box/log"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
// --- fixtures -----------------------------------------------------------------------
// healthNode is a dialable fake outbound: DialContext/ListenPacket succeed or fail per
// the fail flag and record the dial order into dialed.
type healthNode struct {
adapter.Outbound
tag string
fail bool
dialed *[]string
}
func (n *healthNode) Tag() string { return n.tag }
func (n *healthNode) Network() []string { return []string{N.NetworkTCP, N.NetworkUDP} }
func (n *healthNode) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if n.dialed != nil {
*n.dialed = append(*n.dialed, n.tag)
}
if n.fail {
return nil, errors.New("dial refused")
}
left, right := net.Pipe()
_ = right.Close()
return left, nil
}
func (n *healthNode) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
if n.dialed != nil {
*n.dialed = append(*n.dialed, n.tag)
}
if n.fail {
return nil, errors.New("listen refused")
}
return net.ListenPacket("udp", "127.0.0.1:0")
}
// fakeOutboundManager resolves tags over a fixed node set (only Outbound is used).
type fakeOutboundManager struct {
adapter.OutboundManager
nodes map[string]adapter.Outbound
}
func (m *fakeOutboundManager) Outbound(tag string) (adapter.Outbound, bool) {
node, ok := m.nodes[tag]
return node, ok
}
func managerOf(nodes ...adapter.Outbound) *fakeOutboundManager {
m := &fakeOutboundManager{nodes: make(map[string]adapter.Outbound, len(nodes))}
for _, node := range nodes {
m.nodes[node.Tag()] = node
}
return m
}
// healthTestGroup builds a minimal URLTestGroup (not started: no ticker, no checker).
// interval 3m → healthTTL = 10m floor.
func healthTestGroup(hist *urltest.HistoryStorage, manager adapter.OutboundManager, nodes ...adapter.Outbound) *URLTestGroup {
return &URLTestGroup{
ctx: context.Background(),
outbound: manager,
outbounds: nodes,
history: hist,
interval: 3 * time.Minute,
tolerance: 50,
logger: log.NewNOPFactory().Logger(),
interruptGroup: interrupt.NewGroup(),
}
}
// healthURLTest wraps a group into a dialable *URLTest, wiring the balancer the same way
// Start() does (slot liveness through the board verdict).
func healthURLTest(g *URLTestGroup, bal *balancer, manager adapter.OutboundManager) *URLTest {
g.balancer = bal
if bal != nil {
bal.verdict = g.slotVerdict
}
return &URLTest{
group: g,
balancer: bal,
outbound: manager,
logger: log.NewNOPFactory().Logger(),
}
}
// storeAlive records a fresh success 1s in the past: still well inside the TTL, but
// strictly older than any failure the test triggers afterwards (Windows' coarse
// monotonic clock can otherwise produce LastOK == LastFail ties within one tick).
func storeAlive(hist *urltest.HistoryStorage, tag string, delay uint16) {
hist.StoreURLTestHistory(tag, &adapter.URLTestHistory{LastOK: time.Now().Add(-time.Second), Delay: delay})
}
// storeStale records a success far older than the TTL: verdict must degrade to untested.
func storeStale(hist *urltest.HistoryStorage, tag string, delay uint16) {
hist.StoreURLTestHistory(tag, &adapter.URLTestHistory{LastOK: time.Now().Add(-time.Hour), Delay: delay})
}
// --- healthTTL ----------------------------------------------------------------------
func TestHealthTTLFloor(t *testing.T) {
g := &URLTestGroup{interval: 3 * time.Minute}
if ttl := g.healthTTL(); ttl != 10*time.Minute {
t.Fatalf("healthTTL(3m) = %v, want the 10m floor", ttl)
}
g.interval = 5 * time.Minute
if ttl := g.healthTTL(); ttl != 15*time.Minute {
t.Fatalf("healthTTL(5m) = %v, want 3×interval = 15m", ttl)
}
}
// --- Select (least_test): alive > untested > config order ---------------------------
func TestSelectPrefersAliveOverDeadAndUntested(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b, c := &balNode{tag: "a"}, &balNode{tag: "b"}, &balNode{tag: "c"}
g := healthTestGroup(hist, nil, a, b, c)
// a: dead but with a LOWER recorded delay than b (the old "has an entry" logic
// would have picked it); b: alive; c: untested.
storeAlive(hist, "a", 10)
hist.MarkFailed("a")
storeAlive(hist, "b", 100)
selected, exists := g.Select(N.NetworkTCP)
if !exists || selected != adapter.Outbound(b) {
t.Fatalf("Select = %v (exists %v), want alive b", selected, exists)
}
}
func TestSelectRanksAliveByDelay(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
storeAlive(hist, "a", 200)
storeAlive(hist, "b", 50) // 200 > 50+tolerance(50) → b wins
selected, exists := g.Select(N.NetworkTCP)
if !exists || selected != adapter.Outbound(b) {
t.Fatalf("Select = %v (exists %v), want faster alive b", selected, exists)
}
}
func TestSelectHysteresisKeepsAliveCurrent(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
g.selectedOutboundTCP = a
storeAlive(hist, "a", 100)
storeAlive(hist, "b", 60) // within tolerance (100 ≤ 60+50) → keep a
if selected, _ := g.Select(N.NetworkTCP); selected != adapter.Outbound(a) {
t.Fatalf("Select = %v, want current a kept within tolerance", selected)
}
storeAlive(hist, "b", 40) // beyond tolerance (100 > 40+50) → switch to b
if selected, _ := g.Select(N.NetworkTCP); selected != adapter.Outbound(b) {
t.Fatalf("Select = %v, want b beyond tolerance", selected)
}
}
// A dead current selection must NOT seed the hysteresis: any alive member wins.
func TestSelectDeadCurrentLosesToAlive(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
g.selectedOutboundTCP = a
storeAlive(hist, "a", 10)
hist.MarkFailed("a")
storeAlive(hist, "b", 500)
selected, exists := g.Select(N.NetworkTCP)
if !exists || selected != adapter.Outbound(b) {
t.Fatalf("Select = %v (exists %v), want alive b over dead current a", selected, exists)
}
}
func TestSelectFallsBackToUntestedInConfigOrder(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b, c, d := &balNode{tag: "a"}, &balNode{tag: "b"}, &balNode{tag: "c"}, &balNode{tag: "d"}
g := healthTestGroup(hist, nil, a, b, c, d)
hist.MarkFailed("a")
hist.MarkFailed("b")
// c and d untested → first by config order (c), not exists.
selected, exists := g.Select(N.NetworkTCP)
if exists || selected != adapter.Outbound(c) {
t.Fatalf("Select = %v (exists %v), want first untested c", selected, exists)
}
}
func TestSelectAllDeadFallsBackToConfigOrder(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
hist.MarkFailed("a")
hist.MarkFailed("b")
selected, exists := g.Select(N.NetworkTCP)
if exists || selected != adapter.Outbound(a) {
t.Fatalf("Select = %v (exists %v), want first-by-config a", selected, exists)
}
}
func TestSelectStaleSuccessCountsAsUntested(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
storeStale(hist, "a", 20) // success older than TTL → untested tier
hist.MarkFailed("b")
selected, exists := g.Select(N.NetworkTCP)
if exists || selected != adapter.Outbound(a) {
t.Fatalf("Select = %v (exists %v), want stale a as untested over dead b", selected, exists)
}
// A fresh-alive member still beats the stale one.
c := &balNode{tag: "c"}
g.outbounds = append(g.outbounds, c)
storeAlive(hist, "c", 300)
selected, exists = g.Select(N.NetworkTCP)
if !exists || selected != adapter.Outbound(c) {
t.Fatalf("Select = %v (exists %v), want fresh alive c over stale a", selected, exists)
}
}
func TestSelectExcludingSkipsTriedMembers(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
g := healthTestGroup(hist, nil, a, b)
storeAlive(hist, "a", 10)
storeAlive(hist, "b", 20)
selected, _ := g.selectExcluding(N.NetworkTCP, map[string]bool{"a": true})
if selected != adapter.Outbound(b) {
t.Fatalf("selectExcluding = %v, want b with a excluded", selected)
}
// Every member excluded → nothing to dial.
selected, _ = g.selectExcluding(N.NetworkTCP, map[string]bool{"a": true, "b": true})
if selected != nil {
t.Fatalf("selectExcluding = %v, want nil with all excluded", selected)
}
}
// --- slot liveness reads the board verdict (RR / random / failover) -----------------
func TestSlotLivenessVerdictOverridesFlag(t *testing.T) {
hist := urltest.NewHistoryStorage()
b := rrBalancer(t, 2, []string{"none"})
b.verdict = func(tag string) urltest.HealthVerdict { return hist.Verdict(tag, 10*time.Minute) }
_, resolve := resolveFrom("a", "b")
// Both slots flagged live by the last check, but the board learned "a" died since
// (observatory or a failed dial) — pick must skip it the very next connection.
b.setSlots([]string{"a", "b"}, allLive("a", "b"))
hist.MarkFailed("a")
storeAlive(hist, "b", 30)
for range 10 {
picked := b.pick(context.Background(), destDomain("example.com"), nil, resolve)
if picked == nil || picked.Tag() != "b" {
t.Fatalf("pick = %v, want b (a is board-dead despite live flag)", picked)
}
}
}
func TestSlotLivenessVerdictAliveRevives(t *testing.T) {
hist := urltest.NewHistoryStorage()
b := rrBalancer(t, 1, []string{"none"})
b.verdict = func(tag string) urltest.HealthVerdict { return hist.Verdict(tag, 10*time.Minute) }
_, resolve := resolveFrom("a")
// Slot flagged dead by the last check, but the board holds a fresh success now:
// the verdict wins and the slot is selectable again before the next tick.
b.setSlots([]string{"a"}, nil)
storeAlive(hist, "a", 20)
picked := b.pick(context.Background(), destDomain("example.com"), nil, resolve)
if picked == nil || picked.Tag() != "a" {
t.Fatalf("pick = %v, want board-alive a despite dead flag", picked)
}
}
func TestRandomPickSkipsBoardDeadSlot(t *testing.T) {
hist := urltest.NewHistoryStorage()
b := randomBalancer(t, 3)
b.verdict = func(tag string) urltest.HealthVerdict { return hist.Verdict(tag, 10*time.Minute) }
_, resolve := resolveFrom("a", "b", "c")
b.setSlots([]string{"a", "b", "c"}, allLive("a", "b", "c"))
hist.MarkFailed("a")
for range 50 {
picked := b.pick(context.Background(), destDomain("example.com"), nil, resolve)
if picked == nil || picked.Tag() == "a" {
t.Fatalf("random pick returned board-dead a")
}
}
}
// failover (priority balancer, pool 1): the slot occupant went board-dead → the group
// falls back to Select, which routes to a live member, never the dead occupant.
func TestFailoverDeadSlotFallsBackToAliveMember(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
nodeB := &healthNode{tag: "b", dialed: &dialed}
manager := managerOf(a, nodeB)
g := healthTestGroup(hist, manager, a, nodeB)
bal := &balancer{poolSize: 1, priority: true}
s := healthURLTest(g, bal, manager)
bal.setSlots([]string{"a"}, allLive("a"))
hist.MarkFailed("a")
storeAlive(hist, "b", 40)
selected := s.dialSelect(context.Background(), N.NetworkTCP, destDomain("example.com"), nil)
if selected == nil || selected.Tag() != "b" {
t.Fatalf("dialSelect = %v, want alive fallback b for a dead failover slot", selected)
}
}
// --- dial retry: mark-fail + re-pick, ≤ 3 candidates, within deadline ---------------
func TestDialContextRetriesThroughNextAlive(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
nodeB := &healthNode{tag: "b", dialed: &dialed}
g := healthTestGroup(hist, nil, a, nodeB)
s := healthURLTest(g, nil, nil)
storeAlive(hist, "a", 10)
storeAlive(hist, "b", 100)
g.selectedOutboundTCP = a // the checker had picked a; it dies between ticks
conn, err := s.DialContext(context.Background(), N.NetworkTCP, destDomain("example.com"))
if err != nil {
t.Fatalf("DialContext failed despite live member b: %v", err)
}
_ = conn.Close()
if len(dialed) != 2 || dialed[0] != "a" || dialed[1] != "b" {
t.Fatalf("dial order = %v, want [a b]", dialed)
}
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictDead {
t.Fatalf("verdict(a) = %v, want dead after failed dial", v)
}
if entry := hist.LoadURLTestHistory("a"); entry == nil {
t.Fatalf("history entry for a was deleted; mark-fail must keep it")
}
}
func TestDialContextStopsAfterThreeCandidates(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
nodes := make([]adapter.Outbound, 0, 4)
for i, tag := range []string{"a", "b", "c", "d"} {
nodes = append(nodes, &healthNode{tag: tag, fail: true, dialed: &dialed})
storeAlive(hist, tag, uint16(10+80*i)) // spread beyond tolerance so order is a,b,c
}
g := healthTestGroup(hist, nil, nodes...)
s := healthURLTest(g, nil, nil)
_, err := s.DialContext(context.Background(), N.NetworkTCP, destDomain("example.com"))
if err == nil {
t.Fatalf("DialContext succeeded with every member failing")
}
if len(dialed) != dialAttemptsMax {
t.Fatalf("dialed %d members (%v), want at most %d", len(dialed), dialed, dialAttemptsMax)
}
}
func TestDialContextStopsWhenContextDone(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
nodeB := &healthNode{tag: "b", dialed: &dialed}
g := healthTestGroup(hist, nil, a, nodeB)
s := healthURLTest(g, nil, nil)
storeAlive(hist, "a", 10)
storeAlive(hist, "b", 100)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := s.DialContext(ctx, N.NetworkTCP, destDomain("example.com")); err == nil {
t.Fatalf("DialContext succeeded past a done context")
}
if len(dialed) != 1 {
t.Fatalf("dialed %v, want a single attempt within a done context", dialed)
}
}
func TestDialContextBalancedDemotesAndRetries(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
nodeB := &healthNode{tag: "b", dialed: &dialed}
manager := managerOf(a, nodeB)
g := healthTestGroup(hist, manager, a, nodeB)
bal := rrBalancer(t, 2, []string{"none"})
s := healthURLTest(g, bal, manager)
bal.setSlots([]string{"a", "b"}, allLive("a", "b"))
conn, err := s.DialContext(context.Background(), N.NetworkTCP, destDomain("example.com"))
if err != nil {
t.Fatalf("balanced DialContext failed despite live member b: %v", err)
}
_ = conn.Close()
if len(dialed) != 2 || dialed[0] != "a" || dialed[1] != "b" {
t.Fatalf("dial order = %v, want [a b] (a demoted, b retried)", dialed)
}
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictDead {
t.Fatalf("verdict(a) = %v, want dead after failed dial", v)
}
// The demotion is visible to the NEXT connection immediately: a is never dialed again.
conn, err = s.DialContext(context.Background(), N.NetworkTCP, destDomain("example.com"))
if err != nil {
t.Fatalf("second balanced DialContext failed: %v", err)
}
_ = conn.Close()
if dialed[len(dialed)-1] != "b" {
t.Fatalf("dial order = %v, want the second connection to go straight to b", dialed)
}
}
func TestListenPacketRetriesBeforeFirstSend(t *testing.T) {
hist := urltest.NewHistoryStorage()
dialed := []string{}
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
nodeB := &healthNode{tag: "b", dialed: &dialed}
g := healthTestGroup(hist, nil, a, nodeB)
s := healthURLTest(g, nil, nil)
storeAlive(hist, "a", 10)
storeAlive(hist, "b", 100)
g.selectedOutboundUDP = a
conn, err := s.ListenPacket(context.Background(), destDomain("example.com"))
if err != nil {
t.Fatalf("ListenPacket failed despite live member b: %v", err)
}
_ = conn.Close()
if len(dialed) != 2 || dialed[0] != "a" || dialed[1] != "b" {
t.Fatalf("listen order = %v, want [a b]", dialed)
}
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictDead {
t.Fatalf("verdict(a) = %v, want dead after failed listen", v)
}
}
// --- check failures mark the board, never delete ------------------------------------
func TestTestNodesMarksFailedKeepsEntry(t *testing.T) {
hist := urltest.NewHistoryStorage()
a := &healthNode{tag: "a", fail: true}
manager := managerOf(a)
g := healthTestGroup(hist, manager, a)
storeAlive(hist, "a", 42)
result := g.testNodes(context.Background(), []adapter.Outbound{a}, true)
if len(result) != 0 {
t.Fatalf("testNodes result = %v, want empty for a failing node", result)
}
entry := hist.LoadURLTestHistory("a")
if entry == nil {
t.Fatalf("history entry deleted on check failure; must be marked instead")
}
if entry.Delay != 42 {
t.Fatalf("entry.Delay = %d, want the last-known 42 preserved", entry.Delay)
}
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictDead {
t.Fatalf("verdict(a) = %v, want dead after failed check", v)
}
}
// --- Pool / seedPool read the verdict, not entry presence ---------------------------
func TestPoolDelayRequiresAliveVerdict(t *testing.T) {
hist := urltest.NewHistoryStorage()
a := &healthNode{tag: "a"}
manager := managerOf(a)
g := healthTestGroup(hist, manager, a)
bal := rrBalancer(t, 1, []string{"none"})
s := healthURLTest(g, bal, manager)
bal.setSlots([]string{"a"}, allLive("a"))
storeAlive(hist, "a", 0) // live sub-ms → clamped to 1
pool := s.Pool()
if len(pool) != 1 || pool[0].Delay != 1 {
t.Fatalf("Pool = %v, want alive slot a with clamped delay 1", pool)
}
hist.MarkFailed("a") // entry still present, but dead → delay must read 0
pool = s.Pool()
if len(pool) != 1 || pool[0].Delay != 0 {
t.Fatalf("Pool = %v, want dead slot a reported with delay 0", pool)
}
}
func TestSeedPoolSkipsBoardDeadNodes(t *testing.T) {
hist := urltest.NewHistoryStorage()
a, b, c := &balNode{tag: "a"}, &balNode{tag: "b"}, &balNode{tag: "c"}
g := healthTestGroup(hist, nil, a, b, c)
bal := rrBalancer(t, 2, []string{"none"})
g.balancer = bal
// a has an entry (fast, but DEAD); c is fresh-alive. The warm seed must be c, not a.
storeAlive(hist, "a", 10)
hist.MarkFailed("a")
storeAlive(hist, "c", 30)
g.seedPool()
tags := bal.poolTags()
if len(tags) != 2 || tags[0] != "c" {
t.Fatalf("seeded pool = %v, want the alive c seeded first", tags)
}
}