Files
flvx/docs/superpowers/plans/2026-05-01-best-exit-selection.md
2026-05-04 17:25:58 +08:00

33 KiB

Best Exit Selection Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a best multi-exit tunnel strategy that routes new connections through the currently best end-to-end exit without interrupting existing connections.

Architecture: Store best in chain_tunnel.strategy, but render it to GOST as fifo plus panel-controlled node ordering. Extend the existing tunnel quality prober to score each chain owner node's candidate exits, debounce switch decisions, and send UpdateChains only when the best exit changes after confirmation.

Tech Stack: Go net/http, GORM, SQLite/PostgreSQL, GOST v3 fork, WebSocket node commands, Vite/React/TypeScript, existing shadcn bridge components.


File Structure

  • Create: go-backend/internal/http/handler/tunnel_best_exit.go for constants, scoring helpers, in-memory decision state, and target ordering.
  • Create: go-backend/internal/http/handler/tunnel_best_exit_test.go for scoring, debounce, cooldown, and ordering tests.
  • Modify: go-backend/internal/http/handler/handler.go to initialize the best-exit manager on Handler.
  • Modify: go-backend/internal/http/handler/mutations.go to render best as GOST fifo and apply best-exit ordering during initial tunnel runtime creation and normal tunnel updates.
  • Modify: go-backend/internal/http/handler/tunnel_quality_prober.go to evaluate all best exit candidates and apply confirmed chain updates.
  • Create: go-gost/x/socket/chain_test.go for safe UpdateChains replacement tests.
  • Modify: go-gost/x/socket/chain.go so parse failures do not unregister the currently active chain.
  • Modify: vite-frontend/src/pages/tunnel.tsx to add the 最优 strategy option and keep form comments aligned.

Implementation must not create git commits unless the user explicitly requests them. If commits are requested later, commit at task boundaries.


Task 1: Add Best-Exit Scoring And Decision State

Files:

  • Create: go-backend/internal/http/handler/tunnel_best_exit.go

  • Create: go-backend/internal/http/handler/tunnel_best_exit_test.go

  • Step 1: Write failing scoring tests

Create go-backend/internal/http/handler/tunnel_best_exit_test.go with these tests:

package handler

import (
	"testing"
	"time"
)

func TestBestExitScoreCombinesLatencyAndLoss(t *testing.T) {
	exit := chainNodeRecord{NodeID: 30, NodeName: "exit-a"}
	score := scoreBestExitCandidate(10, exit, 25, 2, 80, 3)

	if !score.Success {
		t.Fatalf("expected successful score")
	}
	if score.OwnerNodeID != 10 || score.ExitNodeID != 30 {
		t.Fatalf("unexpected owner/exit ids: %+v", score)
	}
	if score.TotalLatency != 105 {
		t.Fatalf("expected total latency 105, got %v", score.TotalLatency)
	}
	if score.TotalLoss < 4.9 || score.TotalLoss > 5.0 {
		t.Fatalf("expected combined loss about 4.94, got %v", score.TotalLoss)
	}
	if score.Score < 599 || score.Score > 600 {
		t.Fatalf("expected score about 599, got %v", score.Score)
	}
}

func TestBestExitScorePenalizesLoss(t *testing.T) {
	stable := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 80, 0, 80, 0)
	lowLatencyLossy := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 10, 5, 10, 5)

	if !bestExitScoreLess(stable, lowLatencyLossy) {
		t.Fatalf("expected stable exit to beat low-latency lossy exit: stable=%+v lossy=%+v", stable, lowLatencyLossy)
	}
}

func TestBestExitFailedCandidateSortsLast(t *testing.T) {
	failed := failedBestExitCandidate(10, chainNodeRecord{NodeID: 30}, "dial timeout")
	good := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 100, 0, 100, 0)

	scores := []bestExitCandidateScore{failed, good}
	sortBestExitScores(scores)

	if scores[0].ExitNodeID != 31 || scores[1].ExitNodeID != 30 {
		t.Fatalf("expected good score first and failed score last, got %+v", scores)
	}
}

func TestBestExitDecisionRequiresConfirmationsAndCooldown(t *testing.T) {
	m := newBestExitManager()
	key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
	now := time.Unix(100, 0)
	current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
	candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)

	m.setApplied(key, 30, now.Add(-time.Minute))

	if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now); decision.Switch {
		t.Fatalf("first observation should not switch: %+v", decision)
	}
	if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Second)); decision.Switch {
		t.Fatalf("second observation should not switch: %+v", decision)
	}
	decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(2*time.Second))
	if !decision.Switch || decision.ExitNodeID != 31 {
		t.Fatalf("third confirmed observation should switch to 31: %+v", decision)
	}

	betterAgain := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 20, 0, 20, 0)
	if decision := m.observeScores(key, []bestExitCandidateScore{betterAgain, candidate}, now.Add(3*time.Second)); decision.Switch {
		t.Fatalf("cooldown should block immediate switch back: %+v", decision)
	}
}

func TestBestExitOrderingUsesAppliedDecision(t *testing.T) {
	m := newBestExitManager()
	key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
	m.setApplied(key, 31, time.Unix(100, 0))
	targets := []tunnelRuntimeNode{
		{NodeID: 30, Strategy: tunnelStrategyBest},
		{NodeID: 31, Strategy: tunnelStrategyBest},
		{NodeID: 32, Strategy: tunnelStrategyBest},
	}

	ordered := m.orderTargets(key, targets)
	if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 || ordered[2].NodeID != 32 {
		t.Fatalf("unexpected order: %+v", ordered)
	}
	if targets[0].NodeID != 30 {
		t.Fatalf("orderTargets mutated input: %+v", targets)
	}
}
  • Step 2: Run tests to verify failure

Run from go-backend:

go test ./internal/http/handler -run 'TestBestExit' -count=1

Expected: FAIL with undefined scoreBestExitCandidate, failedBestExitCandidate, bestExitCandidateScore, sortBestExitScores, newBestExitManager, bestExitOwnerKey, and tunnelStrategyBest.

  • Step 3: Implement scoring and manager

Create go-backend/internal/http/handler/tunnel_best_exit.go:

package handler

import (
	"sort"
	"strings"
	"sync"
	"time"
)

const (
	tunnelStrategyBest               = "best"
	bestExitRuntimeStrategy          = "fifo"
	bestExitPublicTargetHost         = "www.bing.com"
	bestExitPublicTargetPort         = 443
	bestExitLossPenaltyMsPerPercent = 100.0
	bestExitConfirmationRounds       = 3
	bestExitSwitchCooldown           = 30 * time.Second
	bestExitMinLatencyAdvantageMs    = 20.0
	bestExitMinScoreAdvantageRatio   = 0.15
)

type bestExitOwnerKey struct {
	TunnelID    int64
	OwnerNodeID int64
}

type bestExitCandidateScore struct {
	OwnerNodeID int64
	ExitNodeID  int64
	ExitName    string

	OwnerToExitLatency float64
	ExitToBingLatency  float64
	OwnerToExitLoss    float64
	ExitToBingLoss     float64
	TotalLatency       float64
	TotalLoss          float64
	Score              float64
	Success            bool
	ErrorMessage       string
}

type bestExitSwitchDecision struct {
	Switch     bool
	ExitNodeID int64
	Reason     string
	Scores     []bestExitCandidateScore
}

type bestExitDecision struct {
	AppliedExitNodeID int64
	PendingExitNodeID int64
	PendingCount      int
	LastSwitchAt      time.Time
	LastReason        string
	Scores            []bestExitCandidateScore
}

type bestExitManager struct {
	mu        sync.Mutex
	decisions map[bestExitOwnerKey]*bestExitDecision
}

func newBestExitManager() *bestExitManager {
	return &bestExitManager{decisions: make(map[bestExitOwnerKey]*bestExitDecision)}
}

func isBestTunnelStrategy(strategy string) bool {
	return strings.EqualFold(strings.TrimSpace(strategy), tunnelStrategyBest)
}

func runtimeTunnelStrategy(strategy string) string {
	if isBestTunnelStrategy(strategy) {
		return bestExitRuntimeStrategy
	}
	return strategy
}

func scoreBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, ownerLatency, ownerLoss, publicLatency, publicLoss float64) bestExitCandidateScore {
	totalLatency := ownerLatency + publicLatency
	totalLoss := combineLossPercent(ownerLoss, publicLoss)
	return bestExitCandidateScore{
		OwnerNodeID:        ownerNodeID,
		ExitNodeID:         exit.NodeID,
		ExitName:           exit.NodeName,
		OwnerToExitLatency: ownerLatency,
		ExitToBingLatency:  publicLatency,
		OwnerToExitLoss:    ownerLoss,
		ExitToBingLoss:     publicLoss,
		TotalLatency:       totalLatency,
		TotalLoss:          totalLoss,
		Score:              totalLatency + totalLoss*bestExitLossPenaltyMsPerPercent,
		Success:            true,
	}
}

func failedBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, message string) bestExitCandidateScore {
	return bestExitCandidateScore{
		OwnerNodeID:  ownerNodeID,
		ExitNodeID:   exit.NodeID,
		ExitName:     exit.NodeName,
		Success:      false,
		ErrorMessage: message,
	}
}

func combineLossPercent(a, b float64) float64 {
	a = clampPercent(a)
	b = clampPercent(b)
	return (1 - (1-a/100.0)*(1-b/100.0)) * 100.0
}

func clampPercent(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 100 {
		return 100
	}
	return v
}

func sortBestExitScores(scores []bestExitCandidateScore) {
	sort.SliceStable(scores, func(i, j int) bool {
		return bestExitScoreLess(scores[i], scores[j])
	})
}

func bestExitScoreLess(a, b bestExitCandidateScore) bool {
	if a.Success != b.Success {
		return a.Success
	}
	if !a.Success && !b.Success {
		return a.ExitNodeID < b.ExitNodeID
	}
	if a.Score != b.Score {
		return a.Score < b.Score
	}
	return a.ExitNodeID < b.ExitNodeID
}

func bestExitHasMinimumAdvantage(candidate, current bestExitCandidateScore) bool {
	if !candidate.Success {
		return false
	}
	if !current.Success {
		return true
	}
	improvement := current.Score - candidate.Score
	threshold := current.Score * bestExitMinScoreAdvantageRatio
	if threshold < bestExitMinLatencyAdvantageMs {
		threshold = bestExitMinLatencyAdvantageMs
	}
	return improvement >= threshold
}

func (m *bestExitManager) setApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
	m.mu.Lock()
	defer m.mu.Unlock()
	d := m.decisionLocked(key)
	d.AppliedExitNodeID = exitNodeID
	d.PendingExitNodeID = 0
	d.PendingCount = 0
	d.LastSwitchAt = at
}

func (m *bestExitManager) observeScores(key bestExitOwnerKey, scores []bestExitCandidateScore, now time.Time) bestExitSwitchDecision {
	m.mu.Lock()
	defer m.mu.Unlock()

	ordered := append([]bestExitCandidateScore(nil), scores...)
	sortBestExitScores(ordered)
	d := m.decisionLocked(key)
	d.Scores = ordered

	if len(ordered) == 0 || !ordered[0].Success {
		d.LastReason = "all exits failed"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}

	candidate := ordered[0]
	if d.AppliedExitNodeID == 0 {
		d.AppliedExitNodeID = candidate.ExitNodeID
		d.LastSwitchAt = now
		d.LastReason = "initial best exit"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}
	if candidate.ExitNodeID == d.AppliedExitNodeID {
		d.PendingExitNodeID = 0
		d.PendingCount = 0
		d.LastReason = "current exit remains best"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}
	if now.Sub(d.LastSwitchAt) < bestExitSwitchCooldown {
		d.LastReason = "cooldown"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}

	current := findBestExitScore(ordered, d.AppliedExitNodeID)
	if !bestExitHasMinimumAdvantage(candidate, current) {
		d.PendingExitNodeID = 0
		d.PendingCount = 0
		d.LastReason = "insufficient advantage"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}

	if d.PendingExitNodeID != candidate.ExitNodeID {
		d.PendingExitNodeID = candidate.ExitNodeID
		d.PendingCount = 1
		d.LastReason = "candidate pending confirmation"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}
	d.PendingCount++
	if d.PendingCount < bestExitConfirmationRounds {
		d.LastReason = "candidate pending confirmation"
		return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered}
	}

	d.AppliedExitNodeID = candidate.ExitNodeID
	d.PendingExitNodeID = 0
	d.PendingCount = 0
	d.LastSwitchAt = now
	d.LastReason = "switch confirmed"
	return bestExitSwitchDecision{Switch: true, ExitNodeID: candidate.ExitNodeID, Reason: d.LastReason, Scores: ordered}
}

func findBestExitScore(scores []bestExitCandidateScore, exitNodeID int64) bestExitCandidateScore {
	for _, score := range scores {
		if score.ExitNodeID == exitNodeID {
			return score
		}
	}
	return failedBestExitCandidate(0, chainNodeRecord{NodeID: exitNodeID}, "current exit has no successful score")
}

func (m *bestExitManager) decisionLocked(key bestExitOwnerKey) *bestExitDecision {
	if d := m.decisions[key]; d != nil {
		return d
	}
	d := &bestExitDecision{}
	m.decisions[key] = d
	return d
}

func (m *bestExitManager) orderTargets(key bestExitOwnerKey, targets []tunnelRuntimeNode) []tunnelRuntimeNode {
	out := append([]tunnelRuntimeNode(nil), targets...)
	if m == nil || len(out) <= 1 {
		return out
	}
	m.mu.Lock()
	applied := int64(0)
	if d := m.decisions[key]; d != nil {
		applied = d.AppliedExitNodeID
	}
	m.mu.Unlock()
	if applied <= 0 {
		return out
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].NodeID == applied {
			return true
		}
		if out[j].NodeID == applied {
			return false
		}
		return false
	})
	return out
}
  • Step 4: Run tests to verify pass

Run from go-backend:

go test ./internal/http/handler -run 'TestBestExit' -count=1

Expected: PASS.


Task 2: Render best As FIFO With Applied Ordering

Files:

  • Modify: go-backend/internal/http/handler/handler.go

  • Modify: go-backend/internal/http/handler/mutations.go

  • Modify: go-backend/internal/http/handler/tunnel_best_exit_test.go

  • Step 1: Write failing runtime rendering tests

Append to go-backend/internal/http/handler/tunnel_best_exit_test.go:

func TestBuildTunnelChainConfigMapsBestStrategyToFIFO(t *testing.T) {
	nodes := map[int64]*nodeRecord{
		10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
		30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
		31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
	}
	targets := []tunnelRuntimeNode{
		{NodeID: 30, Port: 30030, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
		{NodeID: 31, Port: 30031, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
	}

	chainData, err := buildTunnelChainConfig(77, 10, targets, nodes, "")
	if err != nil {
		t.Fatalf("build chain: %v", err)
	}
	hops := chainData["hops"].([]map[string]interface{})
	selector := hops[0]["selector"].(map[string]interface{})
	if selector["strategy"] != bestExitRuntimeStrategy {
		t.Fatalf("expected best to render as fifo, got %v", selector["strategy"])
	}
}

func TestHandlerOrdersBestExitTargetsForOwner(t *testing.T) {
	h := &Handler{bestExit: newBestExitManager()}
	key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
	h.bestExit.setApplied(key, 31, time.Unix(100, 0))
	targets := []tunnelRuntimeNode{
		{NodeID: 30, Port: 30030, Strategy: tunnelStrategyBest},
		{NodeID: 31, Port: 30031, Strategy: tunnelStrategyBest},
	}

	ordered := h.orderBestExitTargets(77, 10, targets)
	if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 {
		t.Fatalf("unexpected ordered targets: %+v", ordered)
	}
}
  • Step 2: Run tests to verify failure

Run from go-backend:

go test ./internal/http/handler -run 'TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1

Expected: FAIL because best still renders as best, Handler.bestExit does not exist, and orderBestExitTargets does not exist.

  • Step 3: Initialize best-exit manager

In go-backend/internal/http/handler/handler.go, add the field to Handler:

	bestExit *bestExitManager

In New, initialize it in the struct literal:

		bestExit:                 newBestExitManager(),
  • Step 4: Add ordering helper and render strategy mapping

In go-backend/internal/http/handler/mutations.go, add this method near buildTunnelChainConfig:

func (h *Handler) orderBestExitTargets(tunnelID, ownerNodeID int64, targets []tunnelRuntimeNode) []tunnelRuntimeNode {
	if len(targets) <= 1 || !isBestTunnelStrategy(targets[0].Strategy) {
		return append([]tunnelRuntimeNode(nil), targets...)
	}
	if h == nil || h.bestExit == nil {
		return append([]tunnelRuntimeNode(nil), targets...)
	}
	return h.bestExit.orderTargets(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}, targets)
}

In buildTunnelChainConfig, replace:

	strategy := defaultString(strings.TrimSpace(targets[0].Strategy), "round")

with:

	strategy := runtimeTunnelStrategy(defaultString(strings.TrimSpace(targets[0].Strategy), "round"))

In applyTunnelRuntimeWithMode, order targets before each buildTunnelChainConfig call. For entry nodes, replace the first chain build block with:

	for _, inNode := range state.InNodes {
		targets := state.OutNodes
		if len(state.ChainHops) > 0 {
			targets = state.ChainHops[0]
		} else {
			targets = h.orderBestExitTargets(state.TunnelID, inNode.NodeID, targets)
		}
		chainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)

For middle hop nodes, replace the nextTargets selection block with:

		nextTargets := state.OutNodes
		if i+1 < len(state.ChainHops) {
			nextTargets = state.ChainHops[i+1]
		} else {
			nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
		}
  • Step 5: Run tests

Run from go-backend:

go test ./internal/http/handler -run 'TestBestExit|TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1

Expected: PASS.


Task 3: Evaluate Best Exit Candidates In The Quality Prober

Files:

  • Modify: go-backend/internal/http/handler/tunnel_best_exit.go

  • Modify: go-backend/internal/http/handler/tunnel_best_exit_test.go

  • Modify: go-backend/internal/http/handler/tunnel_quality_prober.go

  • Step 1: Write failing candidate evaluation tests

Append to go-backend/internal/http/handler/tunnel_best_exit_test.go:

func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
	owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
	exits := []chainNodeRecord{
		{NodeID: 30, NodeName: "exit-a", Port: 30030},
		{NodeID: 31, NodeName: "exit-b", Port: 30031},
	}
	nodes := map[int64]*nodeRecord{
		10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
		30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
		31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
	}
	pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
		switch {
		case nodeID == 10 && port == 30030:
			return 60, 0, nil
		case nodeID == 10 && port == 30031:
			return 20, 0, nil
		case nodeID == 30 && ip == bestExitPublicTargetHost:
			return 60, 0, nil
		case nodeID == 31 && ip == bestExitPublicTargetHost:
			return 20, 0, nil
		default:
			t.Fatalf("unexpected ping node=%d ip=%s port=%d", nodeID, ip, port)
			return 0, 100, nil
		}
	}

	scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
	if len(scores) != 2 {
		t.Fatalf("expected two scores, got %+v", scores)
	}
	if scores[0].ExitNodeID != 31 {
		t.Fatalf("expected exit-b first, got %+v", scores)
	}
}

func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) {
	owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
	exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
	nodes := map[int64]*nodeRecord{
		10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
		30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
	}
	pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
		return 0, 100, errBestExitProbeForTest
	}

	scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
	if len(scores) != 1 || scores[0].Success {
		t.Fatalf("expected failed candidate, got %+v", scores)
	}
}

Add imports to the test file:

import (
	"errors"
	"testing"
	"time"
)

var errBestExitProbeForTest = errors.New("probe failed")

If the file already has an import block from Task 1, merge errors into that import block and place var errBestExitProbeForTest after imports.

  • Step 2: Run tests to verify failure

Run from go-backend:

go test ./internal/http/handler -run 'TestEvaluateBestExitOwner' -count=1

Expected: FAIL with undefined evaluateBestExitOwner.

  • Step 3: Implement candidate evaluation helpers

Append to go-backend/internal/http/handler/tunnel_best_exit.go:

type bestExitProbeFunc func(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error)

func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, ping bestExitProbeFunc) []bestExitCandidateScore {
	scores := make([]bestExitCandidateScore, 0, len(exits))
	if owner.NodeID <= 0 || len(exits) == 0 || ping == nil {
		return scores
	}
	ownerNode := nodes[owner.NodeID]
	for _, exit := range exits {
		exitNode := nodes[exit.NodeID]
		if exitNode == nil {
			scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable"))
			continue
		}
		targetIP, targetPort, resolveErr := resolveChainProbeTarget(ownerNode, exitNode, exit.Port, ipPreference, exit.ConnectIP)
		if resolveErr != nil {
			scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, resolveErr.Error()))
			continue
		}
		ownerLatency, ownerLoss, ownerErr := ping(owner.NodeID, targetIP, targetPort, options)
		if ownerErr != nil {
			scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, ownerErr.Error()))
			continue
		}
		publicLatency, publicLoss, publicErr := ping(exit.NodeID, bestExitPublicTargetHost, bestExitPublicTargetPort, options)
		if publicErr != nil {
			scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, publicErr.Error()))
			continue
		}
		scores = append(scores, scoreBestExitCandidate(owner.NodeID, exit, ownerLatency, ownerLoss, publicLatency, publicLoss))
	}
	sortBestExitScores(scores)
	return scores
}

func bestExitChainOwners(inNodes []chainNodeRecord, chainHops [][]chainNodeRecord) []chainNodeRecord {
	if len(chainHops) == 0 {
		return inNodes
	}
	return chainHops[len(chainHops)-1]
}
  • Step 4: Wire evaluation into the quality prober

In go-backend/internal/http/handler/tunnel_quality_prober.go, after inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows), call a new method for best tunnels:

	p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options)

Append these methods to tunnel_quality_prober.go:

func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions) {
	if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
		return
	}
	if !isBestTunnelStrategy(outNodes[0].Strategy) {
		return
	}
	owners := bestExitChainOwners(inNodes, chainHops)
	if len(owners) == 0 {
		return
	}
	nodeMap := make(map[int64]*nodeRecord, len(owners)+len(outNodes))
	for _, owner := range owners {
		if node, err := p.handler.getNodeRecord(owner.NodeID); err == nil && node != nil {
			nodeMap[owner.NodeID] = node
		}
	}
	for _, exit := range outNodes {
		if node, err := p.handler.getNodeRecord(exit.NodeID); err == nil && node != nil {
			nodeMap[exit.NodeID] = node
		}
	}
	for _, owner := range owners {
		if nodeMap[owner.NodeID] == nil {
			continue
		}
		scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, p.tcpPingNode)
		decision := p.handler.bestExit.observeScores(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: owner.NodeID}, scores, time.Now())
		if decision.Switch {
			p.handler.applyBestExitChainOrder(tunnelID, owner.NodeID, outNodes, decision.Scores, ipPreference)
		}
	}
}

Append this method to go-backend/internal/http/handler/mutations.go near applyTunnelChainOnNode:

func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes []chainNodeRecord, scores []bestExitCandidateScore, ipPreference string) {
	if h == nil || tunnelID <= 0 || ownerNodeID <= 0 || len(outNodes) == 0 {
		return
	}
	targets := chainRecordsToRuntimeTargets(outNodes)
	orderedIDs := make([]int64, 0, len(scores))
	for _, score := range scores {
		if score.ExitNodeID > 0 {
			orderedIDs = append(orderedIDs, score.ExitNodeID)
		}
	}
	targets = orderRuntimeTargetsByNodeID(targets, orderedIDs)
	nodes := make(map[int64]*nodeRecord, len(targets)+1)
	if owner, err := h.getNodeRecord(ownerNodeID); err == nil && owner != nil {
		nodes[ownerNodeID] = owner
	}
	for _, target := range targets {
		if node, err := h.getNodeRecord(target.NodeID); err == nil && node != nil {
			nodes[target.NodeID] = node
		}
	}
	chainData, err := buildTunnelChainConfig(tunnelID, ownerNodeID, targets, nodes, ipPreference)
	if err != nil {
		log.Printf("best_exit: build chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err)
		return
	}
	if err := h.applyTunnelChainOnNode(ownerNodeID, chainData, true); err != nil {
		log.Printf("best_exit: update chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err)
		return
	}
	log.Printf("best_exit: updated chain tunnel=%d owner=%d best_exit=%d", tunnelID, ownerNodeID, targets[0].NodeID)
}

Add helper functions in tunnel_best_exit.go:

func chainRecordsToRuntimeTargets(rows []chainNodeRecord) []tunnelRuntimeNode {
	out := make([]tunnelRuntimeNode, 0, len(rows))
	for _, row := range rows {
		out = append(out, tunnelRuntimeNode{
			NodeID:    row.NodeID,
			Protocol:  row.Protocol,
			Strategy:  row.Strategy,
			Inx:       int(row.Inx),
			ChainType: row.ChainType,
			Port:      row.Port,
			ConnectIP: row.ConnectIP,
		})
	}
	return out
}

func orderRuntimeTargetsByNodeID(targets []tunnelRuntimeNode, orderedIDs []int64) []tunnelRuntimeNode {
	out := append([]tunnelRuntimeNode(nil), targets...)
	if len(out) <= 1 || len(orderedIDs) == 0 {
		return out
	}
	positions := make(map[int64]int, len(orderedIDs))
	for i, id := range orderedIDs {
		if _, ok := positions[id]; !ok {
			positions[id] = i
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		pi, iok := positions[out[i].NodeID]
		pj, jok := positions[out[j].NodeID]
		if iok != jok {
			return iok
		}
		if iok && jok && pi != pj {
			return pi < pj
		}
		return false
	})
	return out
}

Add log to mutations.go imports if it is not already present.

  • Step 5: Run focused tests

Run from go-backend:

go test ./internal/http/handler -run 'TestBestExit|TestEvaluateBestExitOwner|TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1

Expected: PASS.


Task 4: Preserve Existing Chain On Agent Update Parse Failure

Files:

  • Create: go-gost/x/socket/chain_test.go

  • Modify: go-gost/x/socket/chain.go

  • Step 1: Write failing agent tests

Create go-gost/x/socket/chain_test.go:

package socket

import (
	"testing"

	corelogger "github.com/go-gost/core/logger"
	"github.com/go-gost/x/config"
	xlogger "github.com/go-gost/x/logger"
	"github.com/go-gost/x/registry"
)

func TestUpdateChainParseFailureKeepsExistingChainRegistered(t *testing.T) {
	corelogger.SetDefault(xlogger.Nop())

	name := "chain_update_parse_failure_tdd"
	originalConfig := config.Global()
	defer config.Set(originalConfig)
	registry.ChainRegistry().Unregister(name)
	defer registry.ChainRegistry().Unregister(name)
	config.Set(&config.Config{})

	valid := config.ChainConfig{
		Name: name,
		Hops: []*config.HopConfig{{
			Name: "hop-valid",
			Nodes: []*config.NodeConfig{{
				Name:      "node-valid",
				Addr:      "127.0.0.1:443",
				Connector: &config.ConnectorConfig{Type: "relay"},
				Dialer:    &config.DialerConfig{Type: "tcp"},
			}},
		}},
	}
	if err := createChain(createChainRequest{Data: valid}); err != nil {
		t.Fatalf("create valid chain: %v", err)
	}
	before := registry.ChainRegistry().Get(name)
	if before == nil {
		t.Fatalf("expected chain registered before update")
	}

	invalid := config.ChainConfig{
		Hops: []*config.HopConfig{{
			Name: "hop-invalid",
			Nodes: []*config.NodeConfig{{
				Name:      "node-invalid",
				Addr:      "127.0.0.1:443",
				Connector: &config.ConnectorConfig{Type: "connector-does-not-exist"},
				Dialer:    &config.DialerConfig{Type: "tcp"},
			}},
		}},
	}
	err := updateChain(updateChainRequest{Chain: name, Data: invalid})
	if err == nil {
		t.Fatalf("expected invalid chain update to fail")
	}
	after := registry.ChainRegistry().Get(name)
	if after == nil {
		t.Fatalf("expected old chain to remain registered after failed update")
	}
}
  • Step 2: Run test to verify failure

Run from go-gost:

go test ./x/socket -run TestUpdateChainParseFailureKeepsExistingChainRegistered -count=1

Expected: FAIL because the old chain is unregistered before parsing the invalid replacement.

  • Step 3: Parse before unregistering

In go-gost/x/socket/chain.go, replace updateChain with:

func updateChain(req updateChainRequest) error {
	name := strings.TrimSpace(req.Chain)
	if name == "" {
		name = strings.TrimSpace(req.Data.Name)
	}
	if name == "" {
		return errors.New("chain name is required")
	}

	req.Data.Name = name
	v, err := parser.ParseChain(&req.Data, logger.Default())
	if err != nil {
		return errors.New("create chain " + name + " failed: " + err.Error())
	}

	if registry.ChainRegistry().IsRegistered(name) {
		registry.ChainRegistry().Unregister(name)
	}
	if err := registry.ChainRegistry().Register(name, v); err != nil {
		return errors.New("chain " + name + " already exists")
	}

	return config.OnUpdate(func(c *config.Config) error {
		found := false
		for i := range c.Chains {
			if c.Chains[i].Name == name {
				c.Chains[i] = &req.Data
				found = true
				break
			}
		}
		if !found {
			c.Chains = append(c.Chains, &req.Data)
		}
		return nil
	})
}
  • Step 4: Run socket tests

Run from go-gost:

go test ./x/socket -run 'TestUpdateChainParseFailureKeepsExistingChainRegistered|TestUpdateServicesSkipsUnchangedServiceWithoutRestart|TestCreateConnLimiterUpdatesGlobalConfig' -count=1

Expected: PASS.


Task 5: Add Frontend 最优 Strategy Option

Files:

  • Modify: vite-frontend/src/pages/tunnel.tsx

  • Step 1: Update tunnel strategy comment

In vite-frontend/src/pages/tunnel.tsx, change the ChainTunnel.strategy comment to include best:

  strategy?: string; // 'fifo' | 'round' | 'rand' | 'best' - 仅转发链/多出口需要
  • Step 2: Add the select item

In the exit “负载策略” selector, change the options block to:

                                <SelectItem key="fifo">主备</SelectItem>
                                <SelectItem key="round">轮询</SelectItem>
                                <SelectItem key="rand">随机</SelectItem>
                                <SelectItem key="best">最优</SelectItem>
  • Step 3: Build frontend

Run from vite-frontend:

pnpm run build

Expected: PASS with tsc && vite build completing successfully.


Task 6: Full Verification

Files:

  • Verify only.

  • Step 1: Run backend tests

Run from go-backend:

go test ./...

Expected: PASS. Investigate any failure before continuing.

  • Step 2: Run agent tests

Run from go-gost:

go test ./...

Expected: PASS. Investigate any failure before continuing.

  • Step 3: Run frontend build

Run from vite-frontend:

pnpm run build

Expected: PASS.

  • Step 4: Inspect final diff

Run from repository root:

git diff -- go-backend/internal/http/handler/tunnel_best_exit.go go-backend/internal/http/handler/tunnel_best_exit_test.go go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/tunnel_quality_prober.go go-gost/x/socket/chain.go go-gost/x/socket/chain_test.go vite-frontend/src/pages/tunnel.tsx

Expected: Diff shows only the best strategy, quality decision, safe chain update, and UI option changes described in this plan.


Self-Review

  • Spec coverage: best UI option is Task 5; DB persistence is covered by reusing existing strategy handling and verified through runtime tests; runtime fifo mapping and ordering are Task 2; scoring, confirmation, cooldown, and all-failed behavior are Task 1; prober candidate evaluation and UpdateChains are Task 3; agent parse-failure safety is Task 4; verification is Task 6.
  • Placeholder scan: The plan contains concrete file paths, constants, function names, code blocks, commands, and expected outcomes.
  • Type consistency: bestExitOwnerKey, bestExitCandidateScore, bestExitManager, runtimeTunnelStrategy, and evaluateBestExitOwner are defined before they are used by later tasks.