Files
shater/protocol/group/urltest_health_lx.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

174 lines
6.5 KiB
Go

// lx:begin health-board
// Health-board driven selection and dial retry for the urltest group (plan §5.B).
//
// The group used to equate "has a history entry" with "alive": failures deleted the
// entry, so a dead member was indistinguishable from a never-measured one (plan §2 Д5)
// and a stale success counted as alive forever (Д2). With failures now recorded on the
// board (common/urltest MarkFailed), liveness is a read-time verdict with a TTL derived
// from the group's check interval. Selection prefers fresh-alive members ranked by
// delay, falls back to untested members in config order, and only then to the first
// member by config; a failed user dial marks the member dead on the board and retries
// the connection through the next candidate instead of failing outright (Д1).
package group
import (
"context"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/urltest"
"github.com/sagernet/sing/common"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
// dialAttemptsMax bounds how many distinct members one connection may try: the first
// pick plus up to two re-picks after a mark-fail. The context deadline still applies to
// every attempt, so a short dial timeout cuts the sequence earlier.
const dialAttemptsMax = 3
// healthTTLFloor keeps verdicts meaningful for groups with short check intervals: a
// single missed tick must not flip a member to untested.
const healthTTLFloor = 10 * time.Minute
// healthTTL is the freshness window for board verdicts: three check intervals (a member
// that missed several consecutive checks is stale), never below healthTTLFloor.
func (g *URLTestGroup) healthTTL() time.Duration {
ttl := 3 * g.interval
if ttl < healthTTLFloor {
ttl = healthTTLFloor
}
return ttl
}
// markFailedLogged records a failure for realTag on the board (the entry is kept — plan
// §2 Д5) and logs the alive → dead verdict flip with its cause (probe or dial), the
// single diagnostic trail for locating dead members and spotting flapping.
func (g *URLTestGroup) markFailedLogged(realTag string, displayTag string, reason string, cause error) {
previous := g.history.Verdict(realTag, g.healthTTL())
g.history.MarkFailed(realTag)
if previous == urltest.VerdictAlive {
g.logger.Info("outbound ", displayTag, " flipped alive -> dead (", reason, ": ", cause, ")")
}
}
// slotVerdict reports the board verdict for a balancer slot tag. Slots hold member tags
// as configured while the board is keyed by RealTag (a nested group's live leaf), so
// resolve through the outbound manager first — same discipline as Pool().
func (g *URLTestGroup) slotVerdict(tag string) urltest.HealthVerdict {
historyTag := tag
if node, loaded := g.outbound.Outbound(tag); loaded {
historyTag = RealTag(node)
}
return g.history.Verdict(historyTag, g.healthTTL())
}
// selectExcluding is Select with an exclusion set (RealTag keys) for the dial-retry
// path: a member that just failed a dial is marked dead on the board AND excluded here,
// so even the last-resort config-order fallback cannot re-pick it.
func (g *URLTestGroup) selectExcluding(network string, exclude map[string]bool) (adapter.Outbound, bool) {
ttl := g.healthTTL()
var minDelay uint16
var minOutbound adapter.Outbound
// Keep the upstream hysteresis: the currently selected outbound only yields to a
// member faster by more than tolerance — but only while it is still alive itself.
var current adapter.Outbound
switch network {
case N.NetworkTCP:
current = g.selectedOutboundTCP
case N.NetworkUDP:
current = g.selectedOutboundUDP
}
if current != nil {
currentTag := RealTag(current)
if !exclude[currentTag] && g.history.Verdict(currentTag, ttl) == urltest.VerdictAlive {
if history := g.history.LoadURLTestHistory(currentTag); history != nil {
minOutbound = current
minDelay = history.Delay
}
}
}
var firstUntested adapter.Outbound
for _, detour := range g.outbounds {
if !common.Contains(detour.Network(), network) {
continue
}
realTag := RealTag(detour)
if exclude[realTag] {
continue
}
switch g.history.Verdict(realTag, ttl) {
case urltest.VerdictAlive:
history := g.history.LoadURLTestHistory(realTag)
if history == nil {
continue
}
if minDelay == 0 || minDelay > history.Delay+g.tolerance {
minDelay = history.Delay
minOutbound = detour
}
case urltest.VerdictUntested:
if firstUntested == nil {
firstUntested = detour
}
}
}
if minOutbound != nil {
return minOutbound, true
}
// No fresh-alive member: an untested one (config order) is a better bet than a
// known-dead one. When every member is dead, fall back to config order so the group
// still dials something — the retry loop walks further members on failure.
if firstUntested != nil {
return firstUntested, false
}
for _, detour := range g.outbounds {
if !common.Contains(detour.Network(), network) {
continue
}
if exclude[RealTag(detour)] {
continue
}
return detour, false
}
return nil, false
}
// dialSelect picks the member for one dial attempt, skipping members this connection has
// already tried (and marked dead). In least_test mode the cached selection is used only
// while its verdict is not dead — a member the board knows to be down is re-selected
// around immediately instead of waiting for the next checker tick.
func (s *URLTest) dialSelect(ctx context.Context, network string, destination M.Socksaddr, tried map[string]bool) adapter.Outbound {
if s.balancer != nil {
return s.selectBalanced(ctx, network, destination, tried)
}
var outbound adapter.Outbound
switch N.NetworkName(network) {
case N.NetworkTCP:
outbound = s.group.selectedOutboundTCP
case N.NetworkUDP:
outbound = s.group.selectedOutboundUDP
}
if outbound != nil {
realTag := RealTag(outbound)
if !tried[realTag] && s.group.history.Verdict(realTag, s.group.healthTTL()) != urltest.VerdictDead {
return outbound
}
}
outbound, _ = s.group.selectExcluding(network, tried)
return outbound
}
// markDialFailure records a failed dial: the member is marked dead on the board — every
// reader (this group's next pick, the balancer slots via slotVerdict, the panel) sees it
// immediately — and added to the connection's tried set so the retry never re-picks it.
func (s *URLTest) markDialFailure(outbound adapter.Outbound, tried map[string]bool, err error) {
realTag := RealTag(outbound)
s.group.markFailedLogged(realTag, outbound.Tag(), "dial", err)
tried[realTag] = true
}
// lx:end health-board