diff --git a/internal/client/async_runtime.go b/internal/client/async_runtime.go index e27fbf12..e94caf26 100644 --- a/internal/client/async_runtime.go +++ b/internal/client/async_runtime.go @@ -544,6 +544,21 @@ func (c *Client) asyncPlanEncodeWorker(ctx context.Context, id int) { if targetCount < 1 { targetCount = 1 } + // Series B / U1.5: apply adaptive per-resolver duplication. The + // balancer returns `targetCount` unchanged for setup-class and + // non-stream-data traffic, so SetupPacketDuplicationCount keeps + // flooring those types. For data-like stream traffic the count + // is clamped to [MIN_DUP, MAX_DUP] based on the preferred + // resolver's LossScore (sustained-healthy window enforces + // conservatism on decreases). Wire site is the planner — the + // dispatcher continues to only forward dupCount. + targetCount = c.balancer.AdaptiveDuplicationCount( + task.opts.PacketType, + task.opts.StreamID, + targetCount, + c.cfg.MinDup, + c.cfg.MaxDup, + ) var ( conns []Connection diff --git a/internal/client/balancer.go b/internal/client/balancer.go index 5070f759..81edd4cb 100644 --- a/internal/client/balancer.go +++ b/internal/client/balancer.go @@ -11,6 +11,7 @@ package client import ( "encoding/binary" + "math" "net" "sync" "sync/atomic" @@ -51,6 +52,17 @@ type balancerStreamRouteState struct { PreferredResolverKey string ResendStreak int LastFailoverAt time.Time + + // Adaptive duplication state (Series B / U1.5). AdaptiveLastDup is the + // last effective dup count returned by AdaptiveDuplicationCount; + // AdaptiveHealthySinceAt is the wall-clock instant since which the + // preferred resolver's lossPermille has stayed below + // adaptiveDupHealthyPermille. Zero AdaptiveLastDup means the stream + // has not been sampled yet; zero AdaptiveHealthySinceAt means the + // healthy window has been reset (loss was observed). Both are mutated + // under mu while the balancer's outer mu.RLock is held by the caller. + AdaptiveLastDup int + AdaptiveHealthySinceAt time.Time } type balancerResolverSampleKey struct { @@ -105,8 +117,24 @@ type Balancer struct { autoDisableTimeoutWindow time.Duration onResolverDisabled func(*Connection, string) confirmResolverDown func(*Connection, time.Duration) bool + + // nowFunc abstracts time.Now for AdaptiveDuplicationCount so tests can + // drive the sustained-healthy window deterministically. Defaults to + // time.Now in NewBalancer. + nowFunc func() time.Time } +// Adaptive-duplication tunables (Series B / U1.5). The sustained-healthy +// window is intentionally LONGER than the default streamFailoverCooldown +// (1 second, see NewBalancer) so the two hysteresis mechanisms don't +// double-damp the same loss signal: a stream failover may already have +// rotated the preferred resolver before the adaptive guard decides it is +// safe to drop the dup count back down. +const ( + adaptiveDupHealthyPermille uint64 = 50 // ≤ 5% loss is "healthy" + adaptiveDupHealthyWindow time.Duration = 3 * time.Second // > default streamFailoverCooldown +) + type connectionStats struct { sent atomic.Uint64 acked atomic.Uint64 @@ -129,6 +157,7 @@ func NewBalancer(strategy int, log *logger.Logger) *Balancer { streamRoutes: make(map[uint16]*balancerStreamRouteState), streamFailoverThreshold: 1, streamFailoverCooldown: time.Second, + nowFunc: time.Now, } for i := range b.pendingShards { b.pendingShards[i].pending = make(map[balancerResolverSampleKey]balancerResolverSample) @@ -137,6 +166,28 @@ func NewBalancer(strategy int, log *logger.Logger) *Balancer { return b } +// SetNowFunc swaps the balancer's clock for adaptive-duplication tests. Pass +// nil to restore time.Now. Not safe to call concurrently with planner traffic; +// it is intended for test setup only. +func (b *Balancer) SetNowFunc(fn func() time.Time) { + if b == nil { + return + } + if fn == nil { + fn = time.Now + } + b.mu.Lock() + b.nowFunc = fn + b.mu.Unlock() +} + +func (b *Balancer) now() time.Time { + if b == nil || b.nowFunc == nil { + return time.Now() + } + return b.nowFunc() +} + func (b *Balancer) SetStreamFailoverConfig(threshold int, cooldown time.Duration) { if b == nil { return @@ -2044,6 +2095,153 @@ func (b *Balancer) lossScoreLocked(idx int) uint64 { return (lost * 1000) / sent } +// LossScore returns the per-resolver loss score in permille (parts per +// thousand) for the resolver identified by serverKey. The returned shape is a +// uint64 permille value in the range [0, 1000]: 0 means no loss observed, +// higher values mean more loss. The neutral / initial-probation value is 200, +// returned when the resolver is unknown or has fewer than 5 sent samples +// (matches the existing locked helper). +// +// This exposes lossScoreLocked through a key-based public API so that callers +// outside balancer.go (e.g. the planner in async_runtime.go) can read the loss +// signal without taking the balancer's lock manually. Adaptive duplication +// (see B3) consumes this score as the lossRate input via the conversion +// +// lossRate := float64(LossScore(key)) / 1000.0 +// +// B1 and B3 must use this identifier; do not introduce a parallel "LossRate" +// shape. +func (b *Balancer) LossScore(serverKey string) uint64 { + if b == nil { + return 200 + } + b.mu.RLock() + defer b.mu.RUnlock() + idx, ok := b.indexByKey[serverKey] + if !ok { + return 200 + } + return b.lossScoreLocked(idx) +} + +// AdaptiveDuplicationCount is the planner-side helper that converts a baseline +// duplication count into the adaptive per-resolver value before it is handed +// to SelectTargets. It implements Series B / U1.5. +// +// Setup-class boundary: setup-class packets (PACKET_STREAM_SYN, +// PACKET_PACKED_CONTROL_BLOCKS, PACKET_SOCKS5_SYN, PACKET_STREAM_CLOSE_READ, +// PACKET_STREAM_CLOSE_WRITE) and any non-stream-data-like packet bypass this +// function entirely — they keep `base`, which the caller already set via +// runtimePacketDuplicationCount() so SetupPacketDuplicationCount continues to +// floor the count. Adaptive duplication applies only to data-like stream +// traffic (PACKET_STREAM_DATA, PACKET_STREAM_RESEND) on a non-zero stream. +// +// Formula (B1 + B2): +// +// target = clamp(round(MIN_DUP + (MAX_DUP - MIN_DUP) * lossRate), +// MIN_DUP, MAX_DUP) +// +// where lossRate = float64(LossScore(preferredResolver)) / 1000.0 — the same +// LossScore identifier introduced in B1. +// +// Conservatism: dup counts are only allowed to DECREASE after the preferred +// resolver's lossPermille has stayed at or below adaptiveDupHealthyPermille +// for at least adaptiveDupHealthyWindow of wall-clock time. Increases apply +// immediately (duplication-for-survivability under bursty loss). The window +// is longer than the default streamFailoverCooldown so adaptive duplication +// and the existing stream-failover hysteresis do not double-damp the same +// signal. +func (b *Balancer) AdaptiveDuplicationCount(packetType uint8, streamID uint16, base, minDup, maxDup int) int { + if b == nil { + return base + } + if streamID == 0 || !isBalancerStreamDataLike(packetType) { + return base + } + if base < 1 { + base = 1 + } + if minDup < 1 { + minDup = 1 + } + if maxDup < minDup { + maxDup = minDup + } + + b.mu.RLock() + state := b.streamRoutes[streamID] + var preferredKey string + if state != nil { + state.mu.Lock() + preferredKey = state.PreferredResolverKey + state.mu.Unlock() + } + var lossPermille uint64 = 200 + if preferredKey != "" { + if idx, ok := b.indexByKey[preferredKey]; ok { + lossPermille = b.lossScoreLocked(idx) + } + } + b.mu.RUnlock() + + span := float64(maxDup - minDup) + lossRate := float64(lossPermille) / 1000.0 + if lossRate > 1 { + lossRate = 1 + } + target := int(math.Round(float64(minDup) + span*lossRate)) + if target < minDup { + target = minDup + } else if target > maxDup { + target = maxDup + } + + if minDup == maxDup { + return target + } + if state == nil { + return target + } + + state.mu.Lock() + defer state.mu.Unlock() + + now := b.now() + isHealthy := lossPermille <= adaptiveDupHealthyPermille + + if state.AdaptiveLastDup == 0 { + state.AdaptiveLastDup = target + if isHealthy { + state.AdaptiveHealthySinceAt = now + } else { + state.AdaptiveHealthySinceAt = time.Time{} + } + return state.AdaptiveLastDup + } + + if isHealthy { + if state.AdaptiveHealthySinceAt.IsZero() { + state.AdaptiveHealthySinceAt = now + } + } else { + state.AdaptiveHealthySinceAt = time.Time{} + } + + if target > state.AdaptiveLastDup { + state.AdaptiveLastDup = target + return state.AdaptiveLastDup + } + if target < state.AdaptiveLastDup { + if !state.AdaptiveHealthySinceAt.IsZero() && now.Sub(state.AdaptiveHealthySinceAt) >= adaptiveDupHealthyWindow { + state.AdaptiveLastDup = target + // Reset the window so we don't continue dropping on every + // subsequent sample without re-validating sustained health. + state.AdaptiveHealthySinceAt = now + } + } + return state.AdaptiveLastDup +} + func (b *Balancer) latencyScoreLocked(idx int) uint64 { if idx < 0 || idx >= len(b.stats) || b.stats[idx] == nil { return 999000 diff --git a/internal/client/balancer_adaptive_dup_test.go b/internal/client/balancer_adaptive_dup_test.go new file mode 100644 index 00000000..330cfcda --- /dev/null +++ b/internal/client/balancer_adaptive_dup_test.go @@ -0,0 +1,320 @@ +package client + +import ( + "testing" + "time" + + Enums "masterdnsvpn-go/internal/enums" +) + +// newAdaptiveTestBalancer wires up a two-resolver balancer with both +// connections marked valid, ensures a stream-route entry exists for streamID +// 7 with "a" as the preferred resolver, and lets the caller poke the loss +// counters directly via the returned stats pointers. +func newAdaptiveTestBalancer(t *testing.T) (*Balancer, *connectionStats) { + t.Helper() + b := NewBalancer(BalancingLeastLoss, nil) + b.SetConnections([]*Connection{ + {Key: "a", IsValid: true}, + {Key: "b", IsValid: true}, + }) + _ = b.SetConnectionValidity("a", true) + _ = b.SetConnectionValidity("b", true) + b.EnsureStream(7) + + b.mu.RLock() + state := b.streamRoutes[7] + b.mu.RUnlock() + if state == nil { + t.Fatalf("expected stream route state for stream 7") + } + state.mu.Lock() + state.PreferredResolverKey = "a" + state.mu.Unlock() + + stats := b.statsForKey("a") + if stats == nil { + t.Fatalf("expected stats for resolver a") + } + return b, stats +} + +func TestAdaptiveDuplicationBypassesSetupClassPackets(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(100) + stats.lost.Store(100) // 100% loss → would saturate to MAX_DUP if applied + + setupTypes := []uint8{ + Enums.PACKET_STREAM_SYN, + Enums.PACKET_PACKED_CONTROL_BLOCKS, + Enums.PACKET_SOCKS5_SYN, + Enums.PACKET_STREAM_CLOSE_READ, + Enums.PACKET_STREAM_CLOSE_WRITE, + } + const base = 4 + for _, pt := range setupTypes { + got := b.AdaptiveDuplicationCount(pt, 7, base, 1, 6) + if got != base { + t.Fatalf("setup packet type %d should bypass adaptive clamp (expected %d, got %d)", pt, base, got) + } + } +} + +func TestAdaptiveDuplicationBypassesNonStreamTraffic(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(100) + stats.lost.Store(100) + + // streamID == 0 → bypass + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 0, 3, 1, 6); got != 3 { + t.Fatalf("streamID=0 should bypass adaptive clamp, got %d", got) + } + // Non-stream-data-like packet on a stream → bypass + if got := b.AdaptiveDuplicationCount(Enums.PACKET_PING, 7, 3, 1, 6); got != 3 { + t.Fatalf("non-stream-data packet should bypass adaptive clamp, got %d", got) + } +} + +func TestAdaptiveDuplicationClampsAtMinWhenLossRateZero(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(100) + stats.lost.Store(0) // lossRate = 0 → target = MIN_DUP + + got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4) + if got != 1 { + t.Fatalf("lossRate=0 should clamp to MIN_DUP=1, got %d", got) + } +} + +func TestAdaptiveDuplicationClampsAtMaxWhenLossRateOne(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(100) + stats.lost.Store(100) // lossRate = 1.0 → target = MAX_DUP + + got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4) + if got != 4 { + t.Fatalf("lossRate=1 should clamp to MAX_DUP=4, got %d", got) + } +} + +func TestAdaptiveDuplicationMidRangeAtFixedPairs(t *testing.T) { + // Formula: target = round(MIN + (MAX-MIN) * lossRate) + // With MIN=1, MAX=5, span=4: + // sent=100 lost=25 → permille=250 → lossRate=0.250 → 1 + 4*0.25 = 2.0 → 2 + // sent=100 lost=50 → permille=500 → lossRate=0.500 → 1 + 4*0.5 = 3.0 → 3 + // sent=100 lost=75 → permille=750 → lossRate=0.750 → 1 + 4*0.75 = 4.0 → 4 + cases := []struct { + name string + sent uint64 + lost uint64 + want int + }{ + {"lossRate_0.25", 100, 25, 2}, + {"lossRate_0.50", 100, 50, 3}, + {"lossRate_0.75", 100, 75, 4}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(tc.sent) + stats.lost.Store(tc.lost) + // Use fresh stream per case via EnsureStream re-use is fine: the + // adaptive state is per-stream and starts unsampled. We only need + // to assert the *first-sample* target here, before the + // sustained-healthy window plays any role. + got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 5) + if got != tc.want { + t.Fatalf("sent=%d lost=%d: expected %d, got %d", tc.sent, tc.lost, tc.want, got) + } + }) + } +} + +func TestAdaptiveDuplicationDegenerateRangePreservesMin(t *testing.T) { + // MIN==MAX → adaptive is effectively disabled and returns MIN regardless + // of loss signal. This is the default-config case (operator did not opt + // in). + b, stats := newAdaptiveTestBalancer(t) + stats.sent.Store(100) + stats.lost.Store(100) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 3, 3); got != 3 { + t.Fatalf("MIN==MAX==3 must always return 3, got %d", got) + } +} + +func TestAdaptiveDuplicationIncreasesImmediately(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + + // Sample 1: clean traffic → MIN_DUP=1. + stats.sent.Store(100) + stats.lost.Store(0) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 1 { + t.Fatalf("first clean sample should clamp to MIN=1, got %d", got) + } + + // Sample 2: loss spikes to 100% → MAX_DUP=4 should apply immediately + // (increases are not gated by the sustained-healthy window). + stats.sent.Store(200) + stats.lost.Store(200) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("loss spike should bump immediately to MAX=4, got %d", got) + } +} + +func TestAdaptiveDuplicationSustainedHealthyWindowGuardsDecrease(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + + // Drive the clock manually so the sustained-healthy window is + // deterministic. The adaptive window is 3 seconds, longer than the + // default 1-second streamFailoverCooldown — see the constant block in + // balancer.go. + t0 := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + current := t0 + b.SetNowFunc(func() time.Time { return current }) + t.Cleanup(func() { b.SetNowFunc(nil) }) + + // Sample 1: high loss → MAX_DUP=4 (initial latched value). + stats.sent.Store(100) + stats.lost.Store(100) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("initial high-loss sample should land at MAX=4, got %d", got) + } + + // Sample 2: traffic recovers (clean), but no time has passed → must + // HOLD the dup count at 4 (single low-loss sample is not enough). + stats.sent.Store(200) + stats.lost.Store(100) // unchanged lost → permille=500 still. Reset cleanly: + stats.sent.Store(200) + stats.lost.Store(0) // permille=0, healthy + current = current.Add(500 * time.Millisecond) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("single low-loss sample inside the window should HOLD at 4, got %d", got) + } + + // Sample 3: still healthy, still inside the window. + current = current.Add(1 * time.Second) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("low-loss sample at +1.5s should still HOLD at 4, got %d", got) + } + + // Sample 4: past the sustained-healthy window (3s) → decrease applies. + current = current.Add(2 * time.Second) // total +3.5s since first healthy sample + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 1 { + t.Fatalf("after sustained-healthy window the dup count should drop to MIN=1, got %d", got) + } +} + +func TestAdaptiveDuplicationLossResetsHealthyWindow(t *testing.T) { + b, stats := newAdaptiveTestBalancer(t) + + t0 := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + current := t0 + b.SetNowFunc(func() time.Time { return current }) + t.Cleanup(func() { b.SetNowFunc(nil) }) + + // Sample 1: high loss → MAX_DUP=4. + stats.sent.Store(100) + stats.lost.Store(100) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("expected MAX=4 after high loss, got %d", got) + } + + // Sample 2: healthy. + stats.sent.Store(200) + stats.lost.Store(0) + current = current.Add(1 * time.Second) + _ = b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4) + + // Sample 3: loss returns → window must reset. + stats.sent.Store(300) + stats.lost.Store(150) // permille = 500 + current = current.Add(1 * time.Second) + _ = b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4) + + // Sample 4: healthy again but only briefly. Even 5s later we should NOT + // have decreased yet, because the window restarts from sample 4. + stats.sent.Store(400) + stats.lost.Store(150) + current = current.Add(500 * time.Millisecond) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got != 4 { + t.Fatalf("first healthy sample after a reset should HOLD at 4, got %d", got) + } + + // Sample 5: window elapses since sample 4 → decrease. + current = current.Add(3 * time.Second) + // Use a fresh stat snapshot that's still healthy. + stats.sent.Store(500) + stats.lost.Store(150) // permille=300 — NOT healthy (>50) + if got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 7, 3, 1, 4); got == 1 { + t.Fatalf("unhealthy sample should not allow a decrease, got %d", got) + } +} + +func TestAdaptiveDuplicationNoStreamStateReturnsTarget(t *testing.T) { + b := NewBalancer(BalancingLeastLoss, nil) + b.SetConnections([]*Connection{ + {Key: "a", IsValid: true}, + }) + _ = b.SetConnectionValidity("a", true) + // streamID 99 has no route state yet — but since the packet IS stream- + // data-like and streamID is non-zero, we still compute a target. With + // no preferred resolver, the loss signal defaults to the 200 permille + // probation value, so lossRate=0.2 and target=clamp(round(1+3*0.2),1,4)=2. + got := b.AdaptiveDuplicationCount(Enums.PACKET_STREAM_DATA, 99, 3, 1, 4) + if got != 2 { + t.Fatalf("no stream state with probation lossRate=0.2 should land at 2, got %d", got) + } +} + +func TestAdaptiveDuplicationDoesNotBreakFailoverStreakGuard(t *testing.T) { + // Sanity check that the existing per-stream failover hysteresis still + // fires when streams are unhealthy. We exercise SelectTargets directly + // to confirm that the adaptive code path (which only mutates the + // AdaptiveLastDup / AdaptiveHealthySinceAt fields) does not interfere + // with ResendStreak / LastFailoverAt. + b := NewBalancer(BalancingLeastLoss, nil) + b.SetConnections([]*Connection{ + {Key: "a", IsValid: true}, + {Key: "b", IsValid: true}, + {Key: "c", IsValid: true}, + }) + _ = b.SetConnectionValidity("a", true) + _ = b.SetConnectionValidity("b", true) + _ = b.SetConnectionValidity("c", true) + b.SetStreamFailoverConfig(1, 0) // threshold=1, cooldown=0 to fire fast + b.EnsureStream(11) + + // Pin "a" as preferred so the first SelectTargets returns it. + b.mu.RLock() + state := b.streamRoutes[11] + b.mu.RUnlock() + state.mu.Lock() + state.PreferredResolverKey = "a" + state.mu.Unlock() + + // First STREAM_DATA pick: should return preferred "a" at the head. + conns, err := b.SelectTargets(Enums.PACKET_STREAM_DATA, 11, 2) + if err != nil { + t.Fatalf("first SelectTargets returned error: %v", err) + } + if len(conns) == 0 || conns[0].Key != "a" { + t.Fatalf("expected preferred=a, got %+v", conns) + } + + // Now a STREAM_RESEND should trigger the failover-streak guard. With + // threshold=1 and cooldown=0 the streak fires immediately and rotates + // to a different preferred resolver. + conns, err = b.SelectTargets(Enums.PACKET_STREAM_RESEND, 11, 2) + if err != nil { + t.Fatalf("resend SelectTargets returned error: %v", err) + } + if len(conns) == 0 { + t.Fatalf("resend returned no connections") + } + state.mu.Lock() + preferredAfter := state.PreferredResolverKey + state.mu.Unlock() + if preferredAfter == "a" { + t.Fatalf("failover-streak guard did not rotate the preferred resolver away from 'a' (still %q)", preferredAfter) + } +} diff --git a/internal/client/balancer_lossscore_test.go b/internal/client/balancer_lossscore_test.go new file mode 100644 index 00000000..980a0770 --- /dev/null +++ b/internal/client/balancer_lossscore_test.go @@ -0,0 +1,107 @@ +package client + +import "testing" + +// statsForLossScoreTest looks up the underlying connectionStats for serverKey +// so individual tests can poke (sent, lost) directly without going through +// ReportSend / ReportTimeout (the latter has side effects that may auto-disable +// the resolver when the active set is small). +func statsForLossScoreTest(t *testing.T, b *Balancer, serverKey string) *connectionStats { + t.Helper() + stats := b.statsForKey(serverKey) + if stats == nil { + t.Fatalf("expected stats for resolver %q", serverKey) + } + return stats +} + +func newLossScoreTestBalancer(t *testing.T) *Balancer { + t.Helper() + b := NewBalancer(BalancingLeastLoss, nil) + b.SetConnections([]*Connection{ + {Key: "a", IsValid: true}, + {Key: "b", IsValid: true}, + }) + _ = b.SetConnectionValidity("a", true) + _ = b.SetConnectionValidity("b", true) + return b +} + +func TestLossScoreInitialProbationForUnknownKey(t *testing.T) { + b := NewBalancer(BalancingLeastLoss, nil) + if got := b.LossScore("nonexistent"); got != 200 { + t.Fatalf("expected 200 for unknown resolver, got %d", got) + } +} + +func TestLossScoreInitialProbationWhenSentBelowFive(t *testing.T) { + b := newLossScoreTestBalancer(t) + stats := statsForLossScoreTest(t, b, "a") + stats.sent.Store(4) + stats.lost.Store(0) + if got := b.LossScore("a"); got != 200 { + t.Fatalf("expected probation value 200 when sent<5, got %d", got) + } +} + +func TestLossScoreCleanTrafficReturnsZero(t *testing.T) { + b := newLossScoreTestBalancer(t) + stats := statsForLossScoreTest(t, b, "a") + stats.sent.Store(100) + stats.lost.Store(0) + if got := b.LossScore("a"); got != 0 { + t.Fatalf("expected 0 for clean traffic, got %d", got) + } +} + +func TestLossScoreExactRatioAtFixedPairs(t *testing.T) { + cases := []struct { + name string + sent uint64 + lost uint64 + want uint64 // (lost * 1000) / sent + }{ + {"5_sent_1_lost", 5, 1, 200}, + {"10_sent_1_lost", 10, 1, 100}, + {"100_sent_5_lost", 100, 5, 50}, + {"100_sent_25_lost", 100, 25, 250}, + {"100_sent_50_lost", 100, 50, 500}, + {"200_sent_50_lost", 200, 50, 250}, + {"1000_sent_750_lost", 1000, 750, 750}, + {"1000_sent_1000_lost", 1000, 1000, 1000}, + } + + // Per the plan: assert exact ratios at fixed (sent, lost) pairs rather + // than pure monotonicity under increasing loss, because lossScoreLocked is + // a ratio over a growing `sent` denominator — the rate can drop while + // losses accumulate as sent grows. + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := newLossScoreTestBalancer(t) + stats := statsForLossScoreTest(t, b, "a") + stats.sent.Store(tc.sent) + stats.lost.Store(tc.lost) + got := b.LossScore("a") + if got != tc.want { + t.Fatalf("LossScore(a) with sent=%d lost=%d: got %d, want %d", tc.sent, tc.lost, got, tc.want) + } + }) + } +} + +func TestLossScoreIsPerResolver(t *testing.T) { + b := newLossScoreTestBalancer(t) + statsA := statsForLossScoreTest(t, b, "a") + statsA.sent.Store(100) + statsA.lost.Store(0) + statsB := statsForLossScoreTest(t, b, "b") + statsB.sent.Store(100) + statsB.lost.Store(50) + + if got := b.LossScore("a"); got != 0 { + t.Fatalf("LossScore(a) clean: got %d, want 0", got) + } + if got := b.LossScore("b"); got != 500 { + t.Fatalf("LossScore(b) at 50%% loss: got %d, want 500", got) + } +} diff --git a/internal/config/client.go b/internal/config/client.go index 7f96791a..2a250b0b 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -47,6 +47,13 @@ type ClientConfig struct { ResolverBalancingStrategy int `toml:"RESOLVER_BALANCING_STRATEGY"` PacketDuplicationCount int `toml:"PACKET_DUPLICATION_COUNT"` SetupPacketDuplicationCount int `toml:"SETUP_PACKET_DUPLICATION_COUNT"` + // MinDup / MaxDup bound the adaptive per-resolver duplication count + // applied by the planner to stream data traffic. Both are clamped to + // [1, 10]. When either is left unset (zero), it defaults to the + // post-finalize PacketDuplicationCount, which keeps adaptive + // duplication a no-op until the operator opts in by widening the range. + MinDup int `toml:"MIN_DUP"` + MaxDup int `toml:"MAX_DUP"` StreamResolverFailoverResendThreshold int `toml:"STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD"` StreamResolverFailoverCooldownSec float64 `toml:"STREAM_RESOLVER_FAILOVER_COOLDOWN"` RecheckInactiveServersEnabled bool `toml:"RECHECK_INACTIVE_SERVERS_ENABLED"` @@ -392,6 +399,26 @@ func finalizeClientConfig(cfg ClientConfig) (ClientConfig, error) { cfg.PacketDuplicationCount = clampInt(defaultIntBelow(cfg.PacketDuplicationCount, 1, 2), 1, 10) cfg.SetupPacketDuplicationCount = clampInt(defaultIntBelow(cfg.SetupPacketDuplicationCount, 1, max(2, cfg.PacketDuplicationCount)), cfg.PacketDuplicationCount, 12) + // MIN_DUP / MAX_DUP default to the effective PacketDuplicationCount so + // the adaptive clamp degenerates to the existing fixed-count behavior + // until an operator explicitly widens the range. Bounds are [1, 10] to + // match the existing PacketDuplicationCount clamp. "Unset" means + // exactly zero (TOML default for missing keys). Negative or out-of-range + // values are clamped via clampInt rather than treated as unset. If + // MIN_DUP > MAX_DUP after clamping (operator misconfiguration), fall + // back to the effective PacketDuplicationCount for both. + if cfg.MinDup == 0 { + cfg.MinDup = cfg.PacketDuplicationCount + } + if cfg.MaxDup == 0 { + cfg.MaxDup = cfg.PacketDuplicationCount + } + cfg.MinDup = clampInt(cfg.MinDup, 1, 10) + cfg.MaxDup = clampInt(cfg.MaxDup, 1, 10) + if cfg.MinDup > cfg.MaxDup { + cfg.MinDup = cfg.PacketDuplicationCount + cfg.MaxDup = cfg.PacketDuplicationCount + } cfg.StreamResolverFailoverResendThreshold = clampInt(defaultIntBelow(cfg.StreamResolverFailoverResendThreshold, 1, 2), 1, 256) cfg.StreamResolverFailoverCooldownSec = clampFloat(defaultFloatAtMostZero(cfg.StreamResolverFailoverCooldownSec, 2.5), 0.1, 120.0) cfg.AutoDisableTimeoutWindowSeconds = clampFloat(defaultFloatAtMostZero(cfg.AutoDisableTimeoutWindowSeconds, 30.0), 1.0, 86400.0) diff --git a/internal/config/client_min_max_dup_test.go b/internal/config/client_min_max_dup_test.go new file mode 100644 index 00000000..0ede0454 --- /dev/null +++ b/internal/config/client_min_max_dup_test.go @@ -0,0 +1,135 @@ +// ============================================================================== +// MasterDnsVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// finalizeMinMaxDup is a small shorthand that funnels (rawDup, minDup, maxDup) +// through finalizeClientConfig and returns the resolved (effectiveDup, min, +// max) triple so tests can assert behavior without hand-building a full +// ClientConfig each time. +func finalizeMinMaxDup(t *testing.T, rawDup, rawMin, rawMax int) (int, int, int) { + t.Helper() + dir := t.TempDir() + resolversPath := filepath.Join(dir, "client_resolvers.txt") + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + cfg := defaultClientConfig() + cfg.EncryptionKey = "secret" + cfg.Domains = []string{"v.domain.com"} + cfg.ConfigDir = dir + cfg.ResolversFilePath = resolversPath + cfg.PacketDuplicationCount = rawDup + cfg.MinDup = rawMin + cfg.MaxDup = rawMax + out, err := finalizeClientConfig(cfg) + if err != nil { + t.Fatalf("finalizeClientConfig returned error: %v", err) + } + return out.PacketDuplicationCount, out.MinDup, out.MaxDup +} + +func TestMinMaxDupDefaultToEffectivePacketDuplicationWhenUnset(t *testing.T) { + dup, mn, mx := finalizeMinMaxDup(t, 3, 0, 0) + if dup != 3 { + t.Fatalf("expected effective PacketDuplicationCount=3, got %d", dup) + } + if mn != dup || mx != dup { + t.Fatalf("expected MIN_DUP==MAX_DUP==effective dup (%d), got min=%d max=%d", dup, mn, mx) + } +} + +func TestMinMaxDupDefaultsHonorPacketDuplicationClamp(t *testing.T) { + // Raw 99 clamps to 10; unset MIN/MAX should follow the clamped value. + dup, mn, mx := finalizeMinMaxDup(t, 99, 0, 0) + if dup != 10 { + t.Fatalf("expected PacketDuplicationCount clamped to 10, got %d", dup) + } + if mn != 10 || mx != 10 { + t.Fatalf("expected MIN/MAX to follow clamped dup=10, got min=%d max=%d", mn, mx) + } +} + +func TestMinMaxDupPreservesValidRange(t *testing.T) { + dup, mn, mx := finalizeMinMaxDup(t, 3, 1, 5) + if dup != 3 { + t.Fatalf("expected dup=3, got %d", dup) + } + if mn != 1 || mx != 5 { + t.Fatalf("expected MIN=1 MAX=5 to be preserved, got min=%d max=%d", mn, mx) + } +} + +func TestMinMaxDupClampsOutOfRangeBounds(t *testing.T) { + // MIN below 1 → 1; MAX above 10 → 10. + _, mn, mx := finalizeMinMaxDup(t, 3, -4, 50) + if mn != 1 { + t.Fatalf("expected MIN clamped to 1, got %d", mn) + } + if mx != 10 { + t.Fatalf("expected MAX clamped to 10, got %d", mx) + } +} + +func TestMinMaxDupInvertedRangeFallsBackToEffectiveDup(t *testing.T) { + // MIN > MAX after clamp should fall back to MIN=MAX=effective dup. + dup, mn, mx := finalizeMinMaxDup(t, 3, 7, 2) + if dup != 3 { + t.Fatalf("expected effective dup=3, got %d", dup) + } + if mn != dup || mx != dup { + t.Fatalf("inverted MIN>MAX should fall back to MIN=MAX=%d, got min=%d max=%d", dup, mn, mx) + } +} + +func TestMinMaxDupBoundsToTen(t *testing.T) { + // MIN=MAX=10 is the upper inclusive bound and should be preserved as-is. + _, mn, mx := finalizeMinMaxDup(t, 3, 10, 10) + if mn != 10 || mx != 10 { + t.Fatalf("expected MIN=MAX=10 to be preserved, got min=%d max=%d", mn, mx) + } +} + +func TestMinMaxDupReadsTOMLKeys(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "client_config.toml") + resolversPath := filepath.Join(dir, "client_resolvers.txt") + + if err := os.WriteFile(configPath, []byte(` +PROTOCOL_TYPE = "socks5" +DOMAINS = ["v.domain.com"] +ENCRYPTION_KEY = "secret" +PACKET_DUPLICATION_COUNT = 3 +MIN_DUP = 1 +MAX_DUP = 5 +`), 0o644); err != nil { + t.Fatalf("WriteFile config failed: %v", err) + } + if err := os.WriteFile(resolversPath, []byte("8.8.8.8\n"), 0o644); err != nil { + t.Fatalf("WriteFile resolvers failed: %v", err) + } + + cfg, err := LoadClientConfig(configPath) + if err != nil { + t.Fatalf("LoadClientConfig returned error: %v", err) + } + if cfg.MinDup != 1 { + t.Fatalf("expected MIN_DUP=1 from TOML, got %d", cfg.MinDup) + } + if cfg.MaxDup != 5 { + t.Fatalf("expected MAX_DUP=5 from TOML, got %d", cfg.MaxDup) + } + if cfg.PacketDuplicationCount != 3 { + t.Fatalf("expected PACKET_DUPLICATION_COUNT=3, got %d", cfg.PacketDuplicationCount) + } +}