- 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
174 lines
6.5 KiB
Go
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
|