@@ -366,74 +366,146 @@ func TestMOLSP2CLocalChoiceTopTwo(t *testing.T) {
366366 if len (ranked ) != 3 {
367367 t .Fatalf ("expected 3 ranked relays, got %d" , len (ranked ))
368368 }
369- // Pressure difference between r0 and r1 triggers local P2C swap of index 0 and 1
370- // Verify that the result contains all 3 and preserves valid pool ordering
369+ // Pressure difference between r0 and r1 triggers local P2C demotion of overloaded candidate 0
371370 if ranked [0 ] == "https://relay-0.example" && r0 .Pressure ()- r1 .Pressure () > molsP2CPressureDelta {
372- t .Fatalf ("relay-0 should have been swapped with relay-1 due to P2C local choice" )
371+ t .Fatalf ("relay-0 should have yielded its top slot due to P2C local choice" )
373372 }
374373}
375374
376- func TestMOLSP2CActiveSetMembershipChange (t * testing.T ) {
375+ func TestMOLSP2CActiveSetMembershipChangeDefaultQuota (t * testing.T ) {
377376 now := time .Now ().UTC ()
377+ const numRelays = 4 // 3 active + 1 reserve under default MaxActiveRelays = 3
378+ relays := make ([]RelayState , numRelays )
379+ for i := 0 ; i < numRelays ; i ++ {
380+ st := confirmedRelayState (t , fmt .Sprintf ("https://relay-quota-%d.example" , i ))
381+ st .DiscoveryRTT = 25 * time .Millisecond
382+ st .DiscoveryRTTAt = now
383+ st .LoadFactor = 0.10
384+ st .EWMALoad = 0.10
385+ relays [i ] = st
386+ }
387+
388+ // Baseline: under balanced loads, MOLS determines initial order
389+ clientAddr := "client-quota-test"
390+ basePicks := SelectPriority (relays , RouteState {
391+ MaxActiveRelays : defaultMaxActiveRelays , // 3
392+ LocalAddress : clientAddr ,
393+ })
394+ if len (basePicks ) != defaultMaxActiveRelays {
395+ t .Fatalf ("expected %d base picks, got %d" , defaultMaxActiveRelays , len (basePicks ))
396+ }
397+
398+ rankedBase := RankRelayPool (relays , clientAddr , 0 )
399+ topCandidateURL := rankedBase [0 ]
400+ reserveCandidateURL := rankedBase [3 ] // 4th candidate (reserve slot)
401+
402+ // Overload the top candidate with surging load and tail inflation
403+ loadedRelays := make ([]RelayState , len (relays ))
404+ for i , r := range relays {
405+ loadedRelays [i ] = r
406+ if r .Descriptor .APIHTTPSAddr == topCandidateURL {
407+ loadedRelays [i ].LoadFactor = 0.75
408+ loadedRelays [i ].EWMALoad = 0.75
409+ loadedRelays [i ].LoadDelta = 0.35
410+ for j := 0 ; j < 90 ; j ++ {
411+ loadedRelays [i ].RTTTracker .Add (10 * time .Millisecond )
412+ }
413+ for j := 0 ; j < 10 ; j ++ {
414+ loadedRelays [i ].RTTTracker .Add (150 * time .Millisecond )
415+ }
416+ }
417+ }
378418
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
419+ // Under default MaxActiveRelays = 3, overloaded top candidate MUST be evicted from active set
420+ newPicks := SelectPriority (loadedRelays , RouteState {
421+ MaxActiveRelays : defaultMaxActiveRelays , // 3
422+ LocalAddress : clientAddr ,
423+ })
424+ if len (newPicks ) != defaultMaxActiveRelays {
425+ t .Fatalf ("expected %d new picks, got %d" , defaultMaxActiveRelays , len (newPicks ))
426+ }
383427
384- r1 := confirmedRelayState (t , "https://relay-1.example" )
385- r1 .DiscoveryRTT = 30 * time .Millisecond
386- r1 .DiscoveryRTTAt = now
428+ // Invariant 1: Overloaded relay is evicted outside the active listener quota
429+ if slices .Contains (newPicks , topCandidateURL ) {
430+ t .Fatalf ("overloaded relay %s was NOT evicted from active set: %v" , topCandidateURL , newPicks )
431+ }
387432
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 )
433+ // Invariant 2: Reserve candidate steps into the active listener set
434+ if ! slices .Contains (newPicks , reserveCandidateURL ) {
435+ t .Fatalf ("reserve relay %s did not enter active set: %v" , reserveCandidateURL , newPicks )
413436 }
414- for i := 0 ; i < 10 ; i ++ {
415- winnerOverloaded .RTTTracker .Add (150 * time .Millisecond )
437+ }
438+
439+ func TestMOLSAntiCascadeDispersion (t * testing.T ) {
440+ testDispersion := func (t * testing.T , numRelays int ) {
441+ now := time .Now ().UTC ()
442+ relays := make ([]RelayState , numRelays )
443+ for i := 0 ; i < numRelays ; i ++ {
444+ relays [i ] = confirmedRelayState (t , fmt .Sprintf ("https://relay-%d-%d.example" , numRelays , i ))
445+ relays [i ].DiscoveryRTT = 20 * time .Millisecond
446+ relays [i ].DiscoveryRTTAt = now
447+ }
448+
449+ const numClients = 700
450+ clientsByPrimary := make (map [string ][]string )
451+ for i := 0 ; i < numClients ; i ++ {
452+ clientAddr := fmt .Sprintf ("client-%d-%d.example" , numRelays , i )
453+ ranked := RankRelayPool (relays , clientAddr , 0 )
454+ primary := ranked [0 ]
455+ clientsByPrimary [primary ] = append (clientsByPrimary [primary ], clientAddr )
456+ }
457+
458+ // Find the primary relay serving the largest group of clients
459+ var targetPrimary string
460+ var targetClients []string
461+ for p , cs := range clientsByPrimary {
462+ if len (cs ) > len (targetClients ) {
463+ targetPrimary = p
464+ targetClients = cs
465+ }
466+ }
467+
468+ // Simulate primary relay failure (drop from candidate pool)
469+ survivingRelays := make ([]RelayState , 0 , numRelays - 1 )
470+ for _ , r := range relays {
471+ if r .Descriptor .APIHTTPSAddr != targetPrimary {
472+ survivingRelays = append (survivingRelays , r )
473+ }
474+ }
475+
476+ // Measure replacement distribution for displaced clients
477+ replacementCounts := make (map [string ]int )
478+ for _ , clientAddr := range targetClients {
479+ ranked := RankRelayPool (survivingRelays , clientAddr , 0 )
480+ replacement := ranked [0 ]
481+ replacementCounts [replacement ]++
482+ }
483+
484+ // Anti-cascade Invariant 1: Displaced clients MUST NOT collapse onto a single secondary
485+ if len (replacementCounts ) <= 1 {
486+ t .Fatalf ("N=%d: All displaced clients collapsed onto a single replacement: %v" , numRelays , replacementCounts )
487+ }
488+
489+ // Anti-cascade Invariant 2: No single surviving relay should absorb an overwhelming monopoly (>50%)
490+ for repl , count := range replacementCounts {
491+ fraction := float64 (count ) / float64 (len (targetClients ))
492+ if fraction > 0.50 {
493+ t .Fatalf ("N=%d: Replacement relay %s absorbed %.1f%% (>50%%) of displaced traffic: %v" , numRelays , repl , fraction * 100 , replacementCounts )
494+ }
495+ }
416496 }
417497
418- loserIdle := loserState
419- loserIdle .LoadFactor = 0.10
420- loserIdle .EWMALoad = 0.10
498+ t .Run ("PrimeOrder_N7" , func (t * testing.T ) {
499+ testDispersion (t , 7 )
500+ })
501+
502+ t .Run ("EvenOrder_N8" , func (t * testing.T ) {
503+ testDispersion (t , 8 )
504+ })
421505
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" ,
506+ t .Run ("EvenOrder_N6" , func (t * testing.T ) {
507+ testDispersion (t , 6 )
427508 })
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- }
437509}
438510
439511func TestMOLSConcurrentRefreshAndFailureLifecycle (t * testing.T ) {
0 commit comments