@@ -3,6 +3,7 @@ package discovery
33import (
44 "fmt"
55 "slices"
6+ "sync"
67 "testing"
78 "time"
89
@@ -372,6 +373,133 @@ func TestMOLSP2CLocalChoiceTopTwo(t *testing.T) {
372373 }
373374}
374375
376+ func TestMOLSP2CActiveSetMembershipChange (t * testing.T ) {
377+ now := time .Now ().UTC ()
378+
379+ // r0 is initially preferred by MOLS over r1
380+ r0 := confirmedRelayState (t , "https://relay-0.example" )
381+ r0 .DiscoveryRTT = 25 * time .Millisecond
382+ r0 .DiscoveryRTTAt = now
383+
384+ r1 := confirmedRelayState (t , "https://relay-1.example" )
385+ r1 .DiscoveryRTT = 30 * time .Millisecond
386+ r1 .DiscoveryRTTAt = now
387+
388+ // Baseline: under balanced loads, MOLS order decides the initial winner and loser
389+ relaysBaseline := []RelayState {r0 , r1 }
390+ basePicks := RankRelayPool (relaysBaseline , "client-addr" , 0 )
391+ if len (basePicks ) < 2 {
392+ t .Fatalf ("expected at least 2 ranked picks, got %d" , len (basePicks ))
393+ }
394+ initialWinner := basePicks [0 ]
395+ initialLoser := basePicks [1 ]
396+
397+ var winnerState , loserState RelayState
398+ if r0 .Descriptor .APIHTTPSAddr == initialWinner {
399+ winnerState = r0
400+ loserState = r1
401+ } else {
402+ winnerState = r1
403+ loserState = r0
404+ }
405+
406+ // Overload the initial winner with surging load and tail inflation
407+ winnerOverloaded := winnerState
408+ winnerOverloaded .LoadFactor = 0.75
409+ winnerOverloaded .EWMALoad = 0.75
410+ winnerOverloaded .LoadDelta = 0.35
411+ for i := 0 ; i < 90 ; i ++ {
412+ winnerOverloaded .RTTTracker .Add (10 * time .Millisecond )
413+ }
414+ for i := 0 ; i < 10 ; i ++ {
415+ winnerOverloaded .RTTTracker .Add (150 * time .Millisecond )
416+ }
417+
418+ loserIdle := loserState
419+ loserIdle .LoadFactor = 0.10
420+ loserIdle .EWMALoad = 0.10
421+
422+ relaysLoaded := []RelayState {winnerOverloaded , loserIdle }
423+ // Under MaxActiveRelays = 1, P2C swap MUST replace the active-set member from winner to loser
424+ newPicks := SelectPriority (relaysLoaded , RouteState {
425+ MaxActiveRelays : 1 ,
426+ LocalAddress : "client-addr" ,
427+ })
428+ if len (newPicks ) != 1 {
429+ t .Fatalf ("expected 1 pick, got %d" , len (newPicks ))
430+ }
431+ if newPicks [0 ] == initialWinner {
432+ t .Fatalf ("P2C failed to change active set membership: overloaded relay %s remained active" , initialWinner )
433+ }
434+ if newPicks [0 ] != initialLoser {
435+ t .Fatalf ("expected initial loser %s to take the active slot, got %s" , initialLoser , newPicks [0 ])
436+ }
437+ }
438+
439+ func TestMOLSConcurrentRefreshAndFailureLifecycle (t * testing.T ) {
440+ now := time .Now ().UTC ()
441+ set := NewRelaySet (nil )
442+ const numRelays = 6
443+
444+ for i := 0 ; i < numRelays ; i ++ {
445+ u := fmt .Sprintf ("https://relay-conc-%d.example" , i )
446+ st := confirmedRelayState (t , u )
447+ st .Descriptor .SupportsOverlay = true
448+ st .Descriptor .WireGuardPublicKey = fmt .Sprintf ("wg-key-%d" , i )
449+ st .Descriptor .WireGuardPort = 51820
450+ st .Descriptor .ExpiresAt = now .Add (time .Hour )
451+ st .LastSeenAt = now
452+ set .relays [u ] = st
453+ }
454+
455+ failingRelay := "https://relay-conc-0.example"
456+ var wg sync.WaitGroup
457+
458+ // Concurrently plan routes (multi-hop and single-hop)
459+ for i := 0 ; i < 5 ; i ++ {
460+ wg .Add (1 )
461+ go func (id int ) {
462+ defer wg .Done ()
463+ for j := 0 ; j < 50 ; j ++ {
464+ routes , err := set .PlanRoutes (nil , RouteState {
465+ MultiHopDepth : 2 ,
466+ MaxActiveRelays : 2 ,
467+ LocalAddress : fmt .Sprintf ("client-%d-%d" , id , j ),
468+ })
469+ if err == nil && len (routes ) > 0 {
470+ _ = routes [0 ].ListenerRelayURL ()
471+ }
472+ }
473+ }(i )
474+ }
475+
476+ // Concurrently record failures on the failing relay
477+ wg .Add (1 )
478+ go func () {
479+ defer wg .Done ()
480+ for i := 0 ; i < 10 ; i ++ {
481+ set .RecordActiveFailure (failingRelay , 0 )
482+ }
483+ }()
484+
485+ wg .Wait ()
486+
487+ // After 10 consecutive failures, failingRelay should have accumulated significant
488+ // virtual latency penalty and should be demoted, not appearing in top active routes.
489+ routes , err := set .PlanRoutes (nil , RouteState {
490+ MaxActiveRelays : 2 ,
491+ LocalAddress : "client-verify" ,
492+ })
493+ if err != nil {
494+ t .Fatalf ("PlanRoutes failed: %v" , err )
495+ }
496+ for _ , r := range routes {
497+ if r .ListenerRelayURL () == failingRelay {
498+ t .Fatalf ("failing relay %s should not be active after repeated failures" , failingRelay )
499+ }
500+ }
501+ }
502+
375503func BenchmarkMOLSRankRelayPool (b * testing.B ) {
376504 localAddr := "test-client-address"
377505 relays := make ([]RelayState , 100 )
0 commit comments