diff --git a/internal/client/balancer.go b/internal/client/balancer.go index 5070f759..b3079b1c 100644 --- a/internal/client/balancer.go +++ b/internal/client/balancer.go @@ -1805,7 +1805,7 @@ func (b *Balancer) hasLossSignalLocked() bool { continue } sent, _, _, _, _ := stats.snapshot() - if sent >= 5 { + if sent > 0 { return true } } @@ -1819,7 +1819,7 @@ func (b *Balancer) hasLatencySignalLocked() bool { continue } _, _, _, _, count := stats.snapshot() - if count >= 5 { + if count > 0 { return true } } @@ -1908,6 +1908,7 @@ func (b *Balancer) lossThenLatencyCandidatesLocked(excludeKey string) []Connecti idx int loss uint64 latency uint64 + hasRTT bool } if !b.hasHybridSignalLocked() || len(b.activeIDs) == 0 { @@ -1922,10 +1923,20 @@ func (b *Balancer) lossThenLatencyCandidatesLocked(excludeKey string) []Connecti } loss := b.lossScoreLocked(idx) latency := b.hybridLatencyPenaltyLocked(idx) + hasRTT := false + if idx >= 0 && idx < len(b.stats) && b.stats[idx] != nil { + _, _, _, _, count := b.stats[idx].snapshot() + hasRTT = count > 0 + } if loss < bestLoss { bestLoss = loss } - candidates = append(candidates, candidate{idx: idx, loss: loss, latency: latency}) + candidates = append(candidates, candidate{ + idx: idx, + loss: loss, + latency: latency, + hasRTT: hasRTT, + }) } if len(candidates) == 0 { return nil @@ -1944,7 +1955,7 @@ func (b *Balancer) lossThenLatencyCandidatesLocked(excludeKey string) []Connecti continue } lossShortlist = append(lossShortlist, cand) - if cand.latency < bestLatency { + if cand.hasRTT && cand.latency < bestLatency { bestLatency = cand.latency } } @@ -1952,15 +1963,22 @@ func (b *Balancer) lossThenLatencyCandidatesLocked(excludeKey string) []Connecti return nil } + if bestLatency == ^uint64(0) { + selected := make([]Connection, 0, len(lossShortlist)) + for _, cand := range lossShortlist { + selected = append(selected, b.connections[cand.idx]) + } + return selected + } + latencyTolerance := latencyToleranceForTier(bestLatency) latencyCutoff := bestLatency + latencyTolerance selected := make([]Connection, 0, len(lossShortlist)) for _, cand := range lossShortlist { - if cand.latency > latencyCutoff { - continue + if !cand.hasRTT || cand.latency <= latencyCutoff { + selected = append(selected, b.connections[cand.idx]) } - selected = append(selected, b.connections[cand.idx]) } if len(selected) > 0 { return selected @@ -2032,13 +2050,10 @@ func (b *Balancer) leastLossTopTierCandidatesLocked(excludeKey string) []Connect func (b *Balancer) lossScoreLocked(idx int) uint64 { if idx < 0 || idx >= len(b.stats) || b.stats[idx] == nil { - return 200 // Use a more neutral default for unknown + return 0 } sent, _, lost, _, _ := b.stats[idx].snapshot() - if sent < 5 { - return 200 // Initial probation - } - if lost == 0 { + if sent == 0 || lost == 0 { return 0 } return (lost * 1000) / sent @@ -2049,7 +2064,7 @@ func (b *Balancer) latencyScoreLocked(idx int) uint64 { return 999000 } _, _, _, sum, count := b.stats[idx].snapshot() - if count < 5 { + if count == 0 { return 999000 } return sum / count diff --git a/internal/client/balancer_test.go b/internal/client/balancer_test.go index 0c3d97ad..a4361bd0 100644 --- a/internal/client/balancer_test.go +++ b/internal/client/balancer_test.go @@ -462,3 +462,88 @@ func TestBalancerSetConnectionMTUUpdatesBalancerOnly(t *testing.T) { t.Fatalf("expected snapshot MTUs to update, got up=%d chars=%d down=%d", got.UploadMTUBytes, got.UploadMTUChars, got.DownloadMTUBytes) } } + +func TestBalancerLossThenLatency_NoProbationStarvation(t *testing.T) { + b := NewBalancer(BalancingLossThenLatency, nil) + connections := []*Connection{ + {Key: "slow-established", IsValid: true}, + {Key: "fast-new", IsValid: true}, + } + b.SetConnections(connections) + _ = b.SetConnectionValidity("slow-established", true) + _ = b.SetConnectionValidity("fast-new", true) + + // "slow-established" has 10 packets, 0 loss, 600ms latency + for i := 0; i < 10; i++ { + b.ReportSend("slow-established") + b.ReportSuccess("slow-established", 600*time.Millisecond) + } + + // "fast-new" has 2 packets, 0 loss, 45ms latency (previously trapped under sent < 5 probation) + for i := 0; i < 2; i++ { + b.ReportSend("fast-new") + b.ReportSuccess("fast-new", 45*time.Millisecond) + } + + best, ok := b.GetBestConnection() + if !ok { + t.Fatal("expected a valid connection") + } + if best.Key != "fast-new" { + t.Fatalf("expected fast newly reactivated resolver to be picked, got %q", best.Key) + } +} + +func TestBalancerLossThenLatency_SeededReactivationExploration(t *testing.T) { + b := NewBalancer(BalancingLossThenLatency, nil) + connections := []*Connection{ + {Key: "fast-established", IsValid: true}, + {Key: "reactivated-seeded", IsValid: true}, + } + b.SetConnections(connections) + _ = b.SetConnectionValidity("fast-established", true) + _ = b.SetConnectionValidity("reactivated-seeded", true) + + // "fast-established" has 10 packets, 0 loss, 40ms latency + for i := 0; i < 10; i++ { + b.ReportSend("fast-established") + b.ReportSuccess("fast-established", 40*time.Millisecond) + } + + // "reactivated-seeded" has conservative stats seeded (sent=10, acked=8, lost=0, rttCount=0) + b.SeedConservativeStats("reactivated-seeded") + + // Verify that the candidate pool includes both resolvers so zero-RTT candidate gets explored + pool := b.GetUniqueConnections(2) + if len(pool) != 2 { + t.Fatalf("expected both resolvers in candidate pool for exploration, got %d", len(pool)) + } + + hasReactivated := false + for i := 0; i < 50; i++ { + best, ok := b.GetBestConnection() + if !ok { + t.Fatal("expected a valid connection") + } + if best.Key == "reactivated-seeded" { + hasReactivated = true + break + } + } + if !hasReactivated { + t.Fatal("expected zero-RTT reactivated resolver to be selected for exploration") + } + + // Once explored and reporting 20ms RTT, it should consistently win over 40ms established + b.ReportSend("reactivated-seeded") + b.ReportSuccess("reactivated-seeded", 20*time.Millisecond) + + best, ok := b.GetBestConnection() + if !ok { + t.Fatal("expected a valid connection") + } + if best.Key != "reactivated-seeded" { + t.Fatalf("expected measured 20ms reactivated resolver to win over 40ms, got %q", best.Key) + } +} +