# 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:
```go
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`:
```bash
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`:
```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`:
```bash
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`:
```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`:
```bash
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`:
```go
bestExit *bestExitManager
```
In `New`, initialize it in the struct literal:
```go
bestExit: newBestExitManager(),
```
- [ ] **Step 4: Add ordering helper and render strategy mapping**
In `go-backend/internal/http/handler/mutations.go`, add this method near `buildTunnelChainConfig`:
```go
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:
```go
strategy := defaultString(strings.TrimSpace(targets[0].Strategy), "round")
```
with:
```go
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:
```go
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:
```go
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`:
```bash
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`:
```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:
```go
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`:
```bash
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`:
```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:
```go
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options)
```
Append these methods to `tunnel_quality_prober.go`:
```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`:
```go
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`:
```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`:
```bash
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`:
```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`:
```bash
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:
```go
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`:
```bash
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`:
```ts
strategy?: string; // 'fifo' | 'round' | 'rand' | 'best' - 仅转发链/多出口需要
```
- [ ] **Step 2: Add the select item**
In the exit “负载策略” selector, change the options block to:
```tsx
主备
轮询
随机
最优
```
- [ ] **Step 3: Build frontend**
Run from `vite-frontend`:
```bash
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`:
```bash
go test ./...
```
Expected: PASS. Investigate any failure before continuing.
- [ ] **Step 2: Run agent tests**
Run from `go-gost`:
```bash
go test ./...
```
Expected: PASS. Investigate any failure before continuing.
- [ ] **Step 3: Run frontend build**
Run from `vite-frontend`:
```bash
pnpm run build
```
Expected: PASS.
- [ ] **Step 4: Inspect final diff**
Run from repository root:
```bash
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.