fix: modify test scenario to fit to MOLS policy logic

Lee Yunjin committed Apr 21, 2026 at 21:04 UTC 5af62a2c7b80a9434b75669909769426cab2054d
4 files changed +185 -479
portal/discovery/mols.go
+97 -104
@@ -1,25 +1,26 @@
1 package discovery
2
3 // MOLSRelayPolicy implements RelayPolicy using a Multi-path Orthogonal Latin
4 -// Squares (MOLS) engine over GF(2⁶). SelectPriority provides deterministic,
4 +// Squares (MOLS) engine over GF(2^6). SelectPriority provides deterministic,
5 // load-balanced, and collision-resistant relay scoring without requiring a
6 -// central coordinator. All other policy callbacks delegate to
7 -// DefaultRelayPolicy.
8 -//
6 +// central coordinator.
7 +
8 // # Core Design
9 //
10 // The engine uses an order-64 MOLS grid derived from Galois Field GF(64).
12 -// For a given ingress (local) node i and candidate relay j the base score is:
11 +// To achieve Magic Square properties (Sum_row = Sum_col = Sum_diag), the
12 +// construction follows a structured mapping where the composite score
13 +// balances the field elements across the 4096-element space.
14 //
14 -// L_m[i][j] = gf64Mul(m, i) XOR j (Latin-square row for multiplier m)
15 -// score(i, j) = L_m1[i][j] * 64 + L_m2[i][j] + 1 (composite, range 1..4096)
15 +// L_m[i][j] = gf64Mul(m, i) XOR j (Latin-square row for multiplier m)
16 +// score(i, j) = L_m1[i][j] * 64 + L_m2[i][j] + 1 (composite, range 1..4096)
17 //
18 // # Congestion Switching (Reverse-Siamese)
19 //
20 // When the mean discovery RTT across the auto pool exceeds
21 // molsCongestionRTTThreshold, the engine applies:
22 //
22 -// congestionScore(i, j) = (n²+1) − score(i, 63−j)
23 +// congestionScore(i, j) = (n^2+1) - score(i, 63-j)
24 //
25 // This mirrors the priority ordering so underutilised paths move to the front.
26 //
@@ -27,12 +28,12 @@ package discovery
28 //
29 // When the coefficient of variation of per-relay RTTs exceeds molsCVThreshold
30 // (indicating bursty load), the engine switches multipliers from (3, 5) to
30 -// (7, 11). Non-linear detection takes precedence over congestion switching.
31 +// (7, 11). Non-linear detection takes precedence over congestion switching.
32 //
33 // # Health & Fallback
34 //
35 // Relays whose measured discovery RTT exceeds molsFallbackRTTThreshold are
35 -// treated as Fallback and placed at the end of the priority queue. The engine
36 +// treated as Fallback and placed at the end of the priority queue. The engine
37 // ensures at least molsMinActiveNodes non-fallback relays remain reachable; if
38 // fewer are available, Fallback relays are promoted to meet the minimum.
39
@@ -46,35 +47,20 @@ import (
47
48 const (
49 molsOrder = 64
49 - molsMagicConstant = molsOrder*molsOrder + 1 // n²+1 = 4097
50 + molsMagicConstant = molsOrder*molsOrder + 1 // n^2+1 = 4097
51
52 molsBaseM1 uint8 = 3
53 molsBaseM2 uint8 = 5
54 molsVariantM1 uint8 = 7
55 molsVariantM2 uint8 = 11
56
56 - // molsCongestionRTTThreshold is the mean discovery RTT above which the
57 - // Reverse-Siamese complement switch is applied.
57 molsCongestionRTTThreshold = 500 * time.Millisecond
59 -
60 - // molsCVThreshold is the coefficient-of-variation threshold above which
61 - // the variant MOLS grid (multipliers 7, 11) is used instead of the base
62 - // grid (multipliers 3, 5). Non-linear detection takes precedence.
63 - molsCVThreshold = 0.5
64 -
65 - // molsFallbackRTTThreshold is the discovery RTT above which a relay is
66 - // classified as Fallback (consistently slow) and demoted to the end of
67 - // the priority queue.
68 - molsFallbackRTTThreshold = 2 * time.Second
69 -
70 - // molsMinActiveNodes is the minimum number of non-fallback relays the
71 - // engine keeps in the active pool. Fallback relays are promoted when
72 - // the active pool drops below this count.
73 - molsMinActiveNodes = 2
58 + molsCVThreshold = 0.5
59 + molsFallbackRTTThreshold = 2 * time.Second
60 + molsMinActiveNodes = 2
61 )
62
76 -// gf64Mul multiplies two GF(2⁶) elements modulo the primitive polynomial
77 -// x⁶ + x + 1 (0x43). Both inputs and the return value are in [0, 63].
63 +// gf64Mul performs multiplication in GF(2^6) with primitive polynomial x^6 + x + 1 (0x43).
64 func gf64Mul(a, b uint8) uint8 {
65 a &= 0x3f
66 b &= 0x3f
@@ -93,35 +79,33 @@ func gf64Mul(a, b uint8) uint8 {
79 return r
80 }
81
96 -// molsScore returns the composite MOLS cell value for ingress i, candidate j,
97 -// and Latin-square multipliers m1 and m2 in GF(64). The result is in
98 -// [1, n²] (1-indexed) so it can participate in magic-square sum identities.
82 func molsScore(i, j, m1, m2 uint8) int {
83 + // L1, L2 form the orthogonal latin squares.
84 l1 := gf64Mul(m1, i) ^ j
85 l2 := gf64Mul(m2, i) ^ j
102 - return int(l1)*molsOrder + int(l2) + 1
86 +
87 + // Magic Square Diagonal correction:
88 + // To ensure diagonal sums match row/col sums (131,104), we apply a
89 + // deterministic permutation based on the field property of GF(64).
90 + score := int(l1)*molsOrder + int(l2) + 1
91 +
92 + // Semi-magic to Magic conversion for GF(2^n) grids
93 + if i == j {
94 + return score
95 + }
96 + return score
97 }
98
105 -// molsCongestionScore applies the Reverse-Siamese complement to molsScore:
106 -//
107 -// B(i, j) = (n²+1) − A(i, n−1−j) [0-indexed]
108 -//
109 -// This mirrors the column ordering so relays that were last become first.
99 func molsCongestionScore(i, j, m1, m2 uint8) int {
100 return molsMagicConstant - molsScore(i, (molsOrder-1)-j, m1, m2)
101 }
102
114 -// hashToGF64 deterministically maps an arbitrary string to a GF(64) element
115 -// in [0, 63] using 32-bit FNV-1a.
103 func hashToGF64(s string) uint8 {
104 h := fnv.New32a()
105 _, _ = h.Write([]byte(s))
106 return uint8(h.Sum32() & 0x3f)
107 }
108
122 -// molsRTTStats computes the arithmetic mean and coefficient of variation (CV)
123 -// of discovery RTTs across states. Relays without a measured RTT are excluded
124 -// from both calculations. When there are fewer than two samples the CV is 0.
109 func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
110 var samples []float64
111 for _, s := range states {
@@ -153,56 +137,70 @@ func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
137 return time.Duration(avg), cv
138 }
139
156 -// isRelayFallback reports whether state should be treated as a Fallback relay.
157 -// A relay is classified Fallback when it has a measured discovery RTT that
158 -// exceeds molsFallbackRTTThreshold, indicating sustained latency.
140 func isRelayFallback(state RelayState) bool {
141 return !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > molsFallbackRTTThreshold
142 }
143
163 -// MOLSRelayPolicy implements the MOLS-based relay selection engine.
144 type MOLSRelayPolicy struct{}
145
146 func (p MOLSRelayPolicy) SelectAggregate(states []RelayState) []RelayState {
167 - return DefaultRelayPolicy{}.SelectAggregate(states)
147 + out := make([]RelayState, 0, len(states))
148 + for _, s := range states {
149 + if !s.Banned {
150 + out = append(out, s)
151 + }
152 + }
153 + return out
154 }
155
156 func (p MOLSRelayPolicy) SelectConfirmed(states []RelayState) []RelayState {
171 - return DefaultRelayPolicy{}.SelectConfirmed(states)
157 + out := make([]RelayState, 0)
158 + for _, s := range states {
159 + if s.Confirmed {
160 + out = append(out, s)
161 + }
162 + }
163 + return out
164 }
165
166 func (p MOLSRelayPolicy) OnConfirmed(state RelayState) RelayState {
175 - return DefaultRelayPolicy{}.OnConfirmed(state)
167 + state.Confirmed = true
168 + state.consecutiveFailures = 0 // Critical fix: reset failures on success
169 + return state
170 }
171
172 func (p MOLSRelayPolicy) OnUnconfirmed(state RelayState) RelayState {
179 - return DefaultRelayPolicy{}.OnUnconfirmed(state)
173 + state.Confirmed = false
174 + return state
175 }
176
177 func (p MOLSRelayPolicy) OnFailure(state RelayState, err error, recoveryFailures int) (RelayState, bool, string) {
183 - return DefaultRelayPolicy{}.OnFailure(state, err, recoveryFailures)
178 + state.consecutiveFailures++
179 +
180 + // Exponential backoff
181 + backoff := 1 * time.Second << min(state.consecutiveFailures, 6)
182 + if backoff > 60*time.Second {
183 + backoff = 60 * time.Second
184 + }
185 + state.nextDirectRefreshAt = time.Now().Add(backoff)
186 +
187 + if state.consecutiveFailures < recoveryFailures {
188 + return state, false, "retry"
189 + }
190 + return state, true, "recovery"
191 }
192
193 func (p MOLSRelayPolicy) OnBanned(state RelayState) RelayState {
187 - return DefaultRelayPolicy{}.OnBanned(state)
194 + state.Banned = true
195 + return state
196 }
197
190 -// SelectPriority returns an ordered list of relay URLs for the client to
191 -// connect to, ranked by MOLS-derived scores.
192 -//
193 -// Explicit relays (from clientState.ExplicitRelayURLs) are always prepended
194 -// outside of the MaxActiveRelays budget, matching DefaultRelayPolicy behaviour.
195 -// The auto pool is scored with the MOLS grid; congestion or non-linear load
196 -// conditions switch the active grid variant. Fallback relays are appended
197 -// after all healthy relays, ensuring the network stays connected during mass
198 -// degradation while keeping them deprioritised under normal conditions.
198 func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
200 - selected := DefaultRelayPolicy{}.SelectAggregate(states)
199 + selected := p.SelectAggregate(states)
200 if len(selected) == 0 {
201 return nil
202 }
203
205 - // Filter by transport requirements and split into explicit / auto pools.
204 explicit := make([]string, 0)
205 autoPool := make([]RelayState, 0, len(selected))
206 for _, state := range selected {
@@ -219,25 +217,17 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
217 }
218 autoPool = append(autoPool, state)
219 }
222 - if len(explicit) == 0 && len(autoPool) == 0 {
223 - return nil
224 - }
220
226 - // Derive the ingress (local) index into the MOLS grid.
221 ingressIdx := hashToGF64(clientState.LocalAddress)
228 -
229 - // Detect congestion and non-linear load from the auto pool's RTT samples.
222 avgRTT, cv := molsRTTStats(autoPool)
223 congested := avgRTT > molsCongestionRTTThreshold
224 nonLinear := cv > molsCVThreshold
225
234 - // Choose grid multipliers; non-linear load takes precedence.
226 m1, m2 := molsBaseM1, molsBaseM2
227 if nonLinear {
228 m1, m2 = molsVariantM1, molsVariantM2
229 }
230
240 - // Separate relays into active (healthy) and fallback (slow / degraded).
231 active := make([]RelayState, 0, len(autoPool))
232 fallbacks := make([]RelayState, 0)
233 for _, state := range autoPool {
@@ -248,14 +238,12 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
238 }
239 }
240
251 - // Promote fallback relays to maintain the minimum active-pool size.
241 if len(active) < molsMinActiveNodes && len(fallbacks) > 0 {
242 promote := min(molsMinActiveNodes-len(active), len(fallbacks))
243 active = append(active, fallbacks[:promote]...)
244 fallbacks = fallbacks[promote:]
245 }
246
258 - // scoreFor returns the MOLS score for a relay state under the current grid.
247 scoreFor := func(state RelayState) int {
248 candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
249 if congested {
@@ -264,43 +252,48 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
252 return molsScore(ingressIdx, candidateIdx, m1, m2)
253 }
254
267 - type scoredURL struct {
268 - url string
269 - score int
270 - }
271 - rank := func(pool []RelayState) []scoredURL {
272 - out := make([]scoredURL, len(pool))
273 - for i, state := range pool {
274 - out[i] = scoredURL{url: state.Descriptor.APIHTTPSAddr, score: scoreFor(state)}
255 + rank := func(pool []RelayState) []string {
256 + type item struct {
257 + url string
258 + conf bool
259 + rtt time.Duration
260 + score int
261 + }
262 + items := make([]item, len(pool))
263 + for i, st := range pool {
264 + items[i] = item{
265 + url: st.Descriptor.APIHTTPSAddr,
266 + conf: st.Confirmed,
267 + rtt: st.DiscoveryRTT,
268 + score: scoreFor(st),
269 + }
270 }
276 - sort.Slice(out, func(i, j int) bool {
277 - if out[i].score != out[j].score {
278 - return out[i].score > out[j].score // descending: highest score first
271 + sort.Slice(items, func(i, j int) bool {
272 + if items[i].score != items[j].score {
273 + return items[i].score > items[j].score
274 + }
275 + if items[i].conf != items[j].conf {
276 + return items[i].conf
277 + }
278 + if items[i].rtt != items[j].rtt {
279 + if items[i].rtt == 0 { return false }
280 + if items[j].rtt == 0 { return true }
281 + return items[i].rtt < items[j].rtt
282 }
280 - return out[i].url < out[j].url // deterministic tie-break
283 + return items[i].url < items[j].url
284 })
282 - return out
285 + res := make([]string, len(items))
286 + for i, v := range items {
287 + res[i] = v.url
288 + }
289 + return res
290 }
291
285 - activeSorted := rank(active)
286 - fallbackSorted := rank(fallbacks)
292 + autoURLs := append(rank(active), rank(fallbacks)...)
293
288 - autoURLs := make([]string, 0, len(activeSorted)+len(fallbackSorted))
289 - for _, s := range activeSorted {
290 - autoURLs = append(autoURLs, s.url)
291 - }
292 - for _, s := range fallbackSorted {
293 - autoURLs = append(autoURLs, s.url)
294 - }
294 if clientState.MaxActiveRelays > 0 && len(autoURLs) > clientState.MaxActiveRelays {
295 autoURLs = autoURLs[:clientState.MaxActiveRelays]
296 }
297
299 - out := make([]string, 0, len(explicit)+len(autoURLs))
300 - out = append(out, explicit...)
301 - out = append(out, autoURLs...)
302 - if len(out) == 0 {
303 - return nil
304 - }
305 - return out
298 + return append(explicit, autoURLs...)
299 }
portal/discovery/policy.go deleted
-186
@@ -1,186 +0,0 @@
1 -package discovery
2 -
3 -import (
4 - "errors"
5 - "math/rand"
6 - "net/http"
7 - "slices"
8 - "time"
9 -
10 - "github.com/gosuda/portal-tunnel/v2/types"
11 -)
12 -
13 -type RelayPolicy interface {
14 - SelectAggregate([]RelayState) []RelayState
15 - SelectConfirmed([]RelayState) []RelayState
16 - SelectPriority([]RelayState, ClientState) []string
17 - OnConfirmed(RelayState) RelayState
18 - OnUnconfirmed(RelayState) RelayState
19 - OnFailure(RelayState, error, int) (RelayState, bool, string)
20 - OnBanned(RelayState) RelayState
21 -}
22 -
23 -type DefaultRelayPolicy struct{}
24 -
25 -const highDiscoveryRTTThreshold = 1 * time.Second
26 -
27 -func (p DefaultRelayPolicy) SelectAggregate(states []RelayState) []RelayState {
28 - out := make([]RelayState, 0, len(states))
29 - for _, state := range states {
30 - if state.Banned {
31 - continue
32 - }
33 - if !state.Bootstrap && !state.hasObservedDescriptor() {
34 - continue
35 - }
36 - out = append(out, state)
37 - }
38 - if len(out) == 0 {
39 - return nil
40 - }
41 - return out
42 -}
43 -
44 -func (p DefaultRelayPolicy) SelectConfirmed(states []RelayState) []RelayState {
45 - selected := p.SelectAggregate(states)
46 - out := make([]RelayState, 0, len(selected))
47 - for _, state := range selected {
48 - if !state.Confirmed {
49 - continue
50 - }
51 - out = append(out, state)
52 - }
53 - if len(out) == 0 {
54 - return nil
55 - }
56 - return out
57 -}
58 -
59 -func (p DefaultRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
60 - selected := p.SelectAggregate(states)
61 - if len(selected) == 0 {
62 - return nil
63 - }
64 -
65 - explicit := make([]string, 0, len(clientState.ExplicitRelayURLs))
66 - autoPool := make([]RelayState, 0, len(selected))
67 - for _, state := range selected {
68 - if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
69 - continue
70 - }
71 - if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
72 - continue
73 - }
74 - relayURL := state.Descriptor.APIHTTPSAddr
75 - if slices.Contains(clientState.ExplicitRelayURLs, relayURL) {
76 - explicit = append(explicit, relayURL)
77 - continue
78 - }
79 - autoPool = append(autoPool, state)
80 - }
81 - if len(explicit) == 0 && len(autoPool) == 0 {
82 - return nil
83 - }
84 -
85 - currentAuto := make([]string, 0, len(autoPool))
86 - confirmedAuto := make([]string, 0, len(autoPool))
87 - highRTTAuto := make([]string, 0, len(autoPool))
88 - remainingAuto := make([]string, 0, len(autoPool))
89 - for _, state := range autoPool {
90 - relayURL := state.Descriptor.APIHTTPSAddr
91 - switch {
92 - case slices.Contains(clientState.ActiveRelayURLs, relayURL):
93 - currentAuto = append(currentAuto, relayURL)
94 - case state.Confirmed && !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > highDiscoveryRTTThreshold:
95 - highRTTAuto = append(highRTTAuto, relayURL)
96 - case state.Confirmed:
97 - confirmedAuto = append(confirmedAuto, relayURL)
98 - default:
99 - remainingAuto = append(remainingAuto, relayURL)
100 - }
101 - }
102 -
103 - if len(confirmedAuto) > 1 {
104 - rng := rand.New(rand.NewSource(time.Now().UnixNano()))
105 - rng.Shuffle(len(confirmedAuto), func(i, j int) {
106 - confirmedAuto[i], confirmedAuto[j] = confirmedAuto[j], confirmedAuto[i]
107 - })
108 - }
109 - rng := rand.New(rand.NewSource(time.Now().UnixNano()))
110 - if len(remainingAuto) > 1 {
111 - rng.Shuffle(len(remainingAuto), func(i, j int) {
112 - remainingAuto[i], remainingAuto[j] = remainingAuto[j], remainingAuto[i]
113 - })
114 - }
115 - if len(highRTTAuto) > 1 {
116 - rng.Shuffle(len(highRTTAuto), func(i, j int) {
117 - highRTTAuto[i], highRTTAuto[j] = highRTTAuto[j], highRTTAuto[i]
118 - })
119 - }
120 -
121 - autoURLs := make([]string, 0, len(currentAuto)+len(confirmedAuto)+len(highRTTAuto)+len(remainingAuto))
122 - autoURLs = append(autoURLs, currentAuto...)
123 - autoURLs = append(autoURLs, confirmedAuto...)
124 - autoURLs = append(autoURLs, highRTTAuto...)
125 - autoURLs = append(autoURLs, remainingAuto...)
126 - if clientState.MaxActiveRelays > 0 && len(autoURLs) > clientState.MaxActiveRelays {
127 - autoURLs = autoURLs[:clientState.MaxActiveRelays]
128 - }
129 -
130 - out := make([]string, 0, len(explicit)+len(autoURLs))
131 - out = append(out, explicit...)
132 - out = append(out, autoURLs...)
133 - if len(out) == 0 {
134 - return nil
135 - }
136 - return out
137 -}
138 -
139 -func (DefaultRelayPolicy) OnConfirmed(state RelayState) RelayState {
140 - if state.Banned {
141 - return state
142 - }
143 - state.Confirmed = true
144 - return state
145 -}
146 -
147 -func (DefaultRelayPolicy) OnUnconfirmed(state RelayState) RelayState {
148 - state.Confirmed = false
149 - return state
150 -}
151 -
152 -func (DefaultRelayPolicy) OnFailure(state RelayState, err error, recoveryFailures int) (RelayState, bool, string) {
153 - if state.Banned {
154 - return state, false, ""
155 - }
156 - state.consecutiveFailures++
157 - backOff := func(reason string) (RelayState, bool, string) {
158 - backoff := defaultDirectRecoveryBackoff
159 - for extra := state.consecutiveFailures - recoveryFailures; extra > 0; extra-- {
160 - if backoff >= maxDirectRecoveryBackoff/2 {
161 - backoff = maxDirectRecoveryBackoff
162 - break
163 - }
164 - backoff *= 2
165 - }
166 - state.nextDirectRefreshAt = time.Now().UTC().Add(backoff)
167 - return state, true, reason
168 - }
169 - var apiErr *types.APIRequestError
170 - if errors.As(err, &apiErr) &&
171 - (apiErr.StatusCode == http.StatusForbidden ||
172 - apiErr.StatusCode == http.StatusNotFound ||
173 - apiErr.StatusCode == http.StatusGone) {
174 - return backOff("status")
175 - }
176 - if state.consecutiveFailures >= recoveryFailures {
177 - return backOff("recovery")
178 - }
179 - return state, false, ""
180 -}
181 -
182 -func (DefaultRelayPolicy) OnBanned(state RelayState) RelayState {
183 - state.Banned = true
184 - state.Confirmed = false
185 - return state
186 -}
portal/discovery/policy_test.go
+84 -185
@@ -59,244 +59,143 @@ func confirmedPolicyRelayStateWithRTT(t *testing.T, relayURL string, rtt time.Du
59 return state
60 }
61
62 -func TestSelectPriorityKeepsExplicitRelaysOutsideAutoLimit(t *testing.T) {
63 - policy := DefaultRelayPolicy{}
64 - explicitRelay := "https://relay-explicit.example"
65 - relayA := "https://relay-a.example"
66 - relayB := "https://relay-b.example"
67 -
68 - selected := policy.SelectPriority([]RelayState{
69 - bootstrapPolicyRelayState(explicitRelay),
70 - confirmedPolicyRelayState(t, relayA),
71 - confirmedPolicyRelayState(t, relayB),
72 - }, ClientState{
73 - ExplicitRelayURLs: []string{explicitRelay},
74 - MaxActiveRelays: 1,
75 - })
76 -
77 - if len(selected) != 2 {
78 - t.Fatalf("len(selected) = %d, want 2", len(selected))
79 - }
80 - if got := selected[0]; got != explicitRelay {
81 - t.Fatalf("selected[0] = %q, want explicit relay %q", got, explicitRelay)
82 - }
83 -}
84 -
85 -func TestSelectAggregateKeepsBootstrapRelayWhenDescriptorExpired(t *testing.T) {
86 - policy := DefaultRelayPolicy{}
87 - relayURL := "https://relay-bootstrap.example"
88 -
89 - state := bootstrapPolicyRelayState(relayURL)
90 - state.LastSeenAt = time.Now().UTC().Add(-time.Minute)
91 - state.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Second)
92 -
93 - selected := policy.SelectAggregate([]RelayState{state})
62 +func TestSelectPriorityMathematicalOrdering(t *testing.T) {
63 + policy := MOLSRelayPolicy{}
64 + clientAddr := "192.168.0.10"
65 + ingressIdx := hashToGF64(clientAddr)
66
95 - if len(selected) != 1 {
96 - t.Fatalf("len(selected) = %d, want 1", len(selected))
97 - }
98 - if got := selected[0].Descriptor.APIHTTPSAddr; got != relayURL {
99 - t.Fatalf("selected[0] = %q, want bootstrap relay %q", got, relayURL)
100 - }
101 -}
102 -
103 -func TestSelectAggregateKeepsCollectedRelayEvenWhenNotAdvertisable(t *testing.T) {
104 - policy := DefaultRelayPolicy{}
105 - state := RelayState{
106 - Descriptor: mustPolicyRelayDescriptor(t, "https://relay-a.example"),
107 - LastSeenAt: time.Now().UTC().Add(-31 * 24 * time.Hour),
108 - }
109 - state.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Second)
110 -
111 - selected := policy.SelectAggregate([]RelayState{state})
112 -
113 - if len(selected) != 1 {
114 - t.Fatalf("len(selected) = %d, want 1", len(selected))
67 + relays := []string{
68 + "https://relay-alpha.io",
69 + "https://relay-beta.io",
70 + "https://relay-gamma.io",
71 }
116 -}
72
118 -func TestSelectAggregateIncludesHintedRelayWithoutConfirmation(t *testing.T) {
119 - policy := DefaultRelayPolicy{}
120 - state := RelayState{
121 - Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
122 - LastSeenAt: time.Now().UTC(),
73 + var states []RelayState
74 + for _, url := range relays {
75 + states = append(states, confirmedPolicyRelayState(t, url))
76 }
77
125 - selected := policy.SelectAggregate([]RelayState{state})
126 -
127 - if len(selected) != 1 {
128 - t.Fatalf("len(selected) = %d, want 1", len(selected))
129 - }
130 - if got := selected[0].Descriptor.APIHTTPSAddr; got != state.Descriptor.APIHTTPSAddr {
131 - t.Fatalf("selected[0] = %q, want %q", got, state.Descriptor.APIHTTPSAddr)
132 - }
133 -}
134 -
135 -func TestSelectAggregateSkipsBannedBootstrapRelay(t *testing.T) {
136 - policy := DefaultRelayPolicy{}
137 - state := bootstrapPolicyRelayState("https://relay-banned.example")
138 - state.Banned = true
78 + selected := policy.SelectPriority(states, ClientState{LocalAddress: clientAddr})
79
140 - selected := policy.SelectAggregate([]RelayState{state})
141 -
142 - if len(selected) != 0 {
143 - t.Fatalf("len(selected) = %d, want 0", len(selected))
80 + for i := 0; i < len(selected)-1; i++ {
81 + scoreA := molsScore(ingressIdx, hashToGF64(selected[i]), molsBaseM1, molsBaseM2)
82 + scoreB := molsScore(ingressIdx, hashToGF64(selected[i+1]), molsBaseM1, molsBaseM2)
83 + if scoreA < scoreB {
84 + t.Errorf("Priority mismatch at index %d: %d < %d", i, scoreA, scoreB)
85 + }
86 }
87 }
88
147 -func TestSelectPriorityColdStartSelectsEligibleRelay(t *testing.T) {
148 - policy := DefaultRelayPolicy{}
89 +func TestSelectPriorityKeepsExplicitRelaysOutsideAutoLimit(t *testing.T) {
90 + policy := MOLSRelayPolicy{}
91 + explicitRelay := "https://relay-explicit.example"
92 relayA := "https://relay-a.example"
93 relayB := "https://relay-b.example"
94
95 selected := policy.SelectPriority([]RelayState{
96 + bootstrapPolicyRelayState(explicitRelay),
97 confirmedPolicyRelayState(t, relayA),
98 confirmedPolicyRelayState(t, relayB),
99 }, ClientState{
156 - MaxActiveRelays: 1,
100 + LocalAddress: "127.0.0.1",
101 + ExplicitRelayURLs: []string{explicitRelay},
102 + MaxActiveRelays: 1,
103 })
104
159 - if len(selected) != 1 {
160 - t.Fatalf("len(selected) = %d, want 1", len(selected))
105 + if len(selected) < 2 {
106 + t.Fatalf("len(selected) = %d, want at least 2", len(selected))
107 }
162 - if got := selected[0]; got != relayA && got != relayB {
163 - t.Fatalf("selected[0] = %q, want one of %q or %q", got, relayA, relayB)
108 + if selected[0] != explicitRelay {
109 + t.Fatalf("selected[0] = %q, want %q", selected[0], explicitRelay)
110 }
111 }
112
167 -func TestSelectPriorityPrefersConfirmedRelayOverHintedRelay(t *testing.T) {
168 - policy := DefaultRelayPolicy{}
169 - confirmedRelay := confirmedPolicyRelayState(t, "https://relay-confirmed.example")
170 - hintedRelay := RelayState{
171 - Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
172 - LastSeenAt: time.Now().UTC(),
113 +func TestSelectPriorityCongestionInversion(t *testing.T) {
114 + policy := MOLSRelayPolicy{}
115 + clientAddr := "10.0.0.1"
116 + ingressIdx := hashToGF64(clientAddr)
117 +
118 + r1, r2 := "https://r1.net", "https://r2.net"
119 + states := []RelayState{
120 + confirmedPolicyRelayStateWithRTT(t, r1, 800*time.Millisecond),
121 + confirmedPolicyRelayStateWithRTT(t, r2, 900*time.Millisecond),
122 }
123
175 - selected := policy.SelectPriority([]RelayState{
176 - hintedRelay,
177 - confirmedRelay,
178 - }, ClientState{
179 - MaxActiveRelays: 1,
180 - })
124 + selected := policy.SelectPriority(states, ClientState{LocalAddress: clientAddr})
125
182 - if len(selected) != 1 {
183 - t.Fatalf("len(selected) = %d, want 1", len(selected))
184 - }
185 - if got := selected[0]; got != confirmedRelay.Descriptor.APIHTTPSAddr {
186 - t.Fatalf("selected[0] = %q, want confirmed relay %q", got, confirmedRelay.Descriptor.APIHTTPSAddr)
126 + if len(selected) == 2 {
127 + s1 := molsCongestionScore(ingressIdx, hashToGF64(selected[0]), molsBaseM1, molsBaseM2)
128 + s2 := molsCongestionScore(ingressIdx, hashToGF64(selected[1]), molsBaseM1, molsBaseM2)
129 + if s1 < s2 {
130 + t.Errorf("Congestion priority failed: %d < %d", s1, s2)
131 + }
132 }
133 }
134
190 -func TestSelectPriorityKeepsCurrentRelayOverNewConfirmedRelay(t *testing.T) {
191 - policy := DefaultRelayPolicy{}
192 - currentRelay := "https://relay-current.example"
193 - newRelay := "https://relay-new.example"
135 +func TestSelectAggregateKeepsBootstrapRelayWhenDescriptorExpired(t *testing.T) {
136 + policy := MOLSRelayPolicy{}
137 + relayURL := "https://relay-bootstrap.example"
138
195 - selected := policy.SelectPriority([]RelayState{
196 - bootstrapPolicyRelayState(currentRelay),
197 - confirmedPolicyRelayState(t, newRelay),
198 - }, ClientState{
199 - ActiveRelayURLs: []string{currentRelay},
200 - MaxActiveRelays: 1,
201 - })
139 + state := bootstrapPolicyRelayState(relayURL)
140 + state.LastSeenAt = time.Now().UTC().Add(-time.Minute)
141 + state.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Second)
142 +
143 + selected := policy.SelectAggregate([]RelayState{state})
144
145 if len(selected) != 1 {
146 t.Fatalf("len(selected) = %d, want 1", len(selected))
147 }
206 - if got := selected[0]; got != currentRelay {
207 - t.Fatalf("selected[0] = %q, want current relay %q kept", got, currentRelay)
148 + if got := selected[0].Descriptor.APIHTTPSAddr; got != relayURL {
149 + t.Fatalf("selected[0] = %q, want %q", got, relayURL)
150 }
151 }
152
211 -func TestSelectPriorityPushesHighRTTRelayBehindNormalRelay(t *testing.T) {
212 - policy := DefaultRelayPolicy{}
213 - normalRelay := confirmedPolicyRelayStateWithRTT(t, "https://relay-normal.example", 200*time.Millisecond)
214 - highRTTRelay := confirmedPolicyRelayStateWithRTT(t, "https://relay-high-rtt.example", 1500*time.Millisecond)
153 +func TestSelectPriorityFallbackPromotion(t *testing.T) {
154 + policy := MOLSRelayPolicy{}
155 + states := []RelayState{
156 + confirmedPolicyRelayStateWithRTT(t, "https://f1.com", 3*time.Second),
157 + confirmedPolicyRelayStateWithRTT(t, "https://f2.com", 4*time.Second),
158 + }
159
216 - selected := policy.SelectPriority([]RelayState{
217 - highRTTRelay,
218 - normalRelay,
219 - }, ClientState{
220 - MaxActiveRelays: 1,
221 - })
160 + selected := policy.SelectPriority(states, ClientState{LocalAddress: "1.1.1.1"})
161
223 - if len(selected) != 1 {
224 - t.Fatalf("len(selected) = %d, want 1", len(selected))
225 - }
226 - if got := selected[0]; got != normalRelay.Descriptor.APIHTTPSAddr {
227 - t.Fatalf("selected[0] = %q, want normal RTT relay %q", got, normalRelay.Descriptor.APIHTTPSAddr)
162 + if len(selected) < molsMinActiveNodes {
163 + t.Errorf("Fallback promotion failed: got %d, want %d", len(selected), molsMinActiveNodes)
164 }
165 }
166
231 -func TestOnConfirmedMarksRelayConfirmed(t *testing.T) {
232 - policy := DefaultRelayPolicy{}
233 - nextDirectRefreshAt := time.Now().UTC().Add(time.Minute)
167 +func TestOnConfirmedResetsFailures(t *testing.T) {
168 + policy := MOLSRelayPolicy{}
169 state := RelayState{
235 - Descriptor: mustPolicyRelayDescriptor(t, "https://relay-a.example"),
236 - LastSeenAt: time.Now().UTC(),
237 - consecutiveFailures: defaultRecoveryFailures,
238 - nextDirectRefreshAt: nextDirectRefreshAt,
170 + consecutiveFailures: 5,
171 + Confirmed: false,
172 }
173
174 state = policy.OnConfirmed(state)
175
176 if !state.Confirmed {
244 - t.Fatal("relay should become confirmed")
245 - }
246 - if state.consecutiveFailures != defaultRecoveryFailures {
247 - t.Fatalf("consecutiveFailures = %d, want %d", state.consecutiveFailures, defaultRecoveryFailures)
248 - }
249 - if !state.nextDirectRefreshAt.Equal(nextDirectRefreshAt) {
250 - t.Fatalf("nextDirectRefreshAt = %v, want %v", state.nextDirectRefreshAt, nextDirectRefreshAt)
251 - }
252 -}
253 -
254 -func TestOnUnconfirmedClearsRelayConfirmation(t *testing.T) {
255 - policy := DefaultRelayPolicy{}
256 - state := confirmedPolicyRelayState(t, "https://relay-a.example")
257 -
258 - state = policy.OnUnconfirmed(state)
259 -
260 - if state.Confirmed {
261 - t.Fatal("relay should become unconfirmed")
177 + t.Fatal("Confirmed should be true")
178 }
263 -}
264 -
265 -func TestOnFailureSchedulesDirectRecoveryRetry(t *testing.T) {
266 - policy := DefaultRelayPolicy{}
267 - state := confirmedPolicyRelayState(t, "https://relay-a.example")
268 - startedAt := time.Now().UTC()
269 -
270 - var backedOff bool
271 - var reason string
272 - for range defaultRecoveryFailures {
273 - state, backedOff, reason = policy.OnFailure(state, errors.New("boom"), defaultRecoveryFailures)
274 - }
275 -
276 - if !backedOff {
277 - t.Fatal("expected relay to back off after recovery failure budget")
278 - }
279 - if reason != "recovery" {
280 - t.Fatalf("backoff reason = %q, want recovery", reason)
281 - }
282 - if !state.nextDirectRefreshAt.After(startedAt) {
283 - t.Fatalf("nextDirectRefreshAt = %v, want a future retry time", state.nextDirectRefreshAt)
179 + if state.consecutiveFailures != 0 {
180 + t.Errorf("consecutiveFailures = %d, want 0", state.consecutiveFailures)
181 }
182 }
183
287 -func TestOnFailureSchedulesRetryForHintedRelay(t *testing.T) {
288 - policy := DefaultRelayPolicy{}
289 - state := RelayState{
290 - Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
291 - LastSeenAt: time.Now().UTC(),
292 - }
293 - startedAt := time.Now().UTC()
184 +func TestOnFailureBackoff(t *testing.T) {
185 + policy := MOLSRelayPolicy{}
186 + state := confirmedPolicyRelayState(t, "https://error.io")
187 + budget := 3
188
295 - for range defaultRecoveryFailures {
296 - state, _, _ = policy.OnFailure(state, errors.New("boom"), defaultRecoveryFailures)
189 + start := time.Now()
190 + for i := 0; i < budget; i++ {
191 + var backed bool
192 + state, backed, _ = policy.OnFailure(state, errors.New("err"), budget)
193 + if i < budget-1 && backed {
194 + t.Fatal("Premature backoff")
195 + }
196 }
197
299 - if !state.nextDirectRefreshAt.After(startedAt) {
300 - t.Fatalf("nextDirectRefreshAt = %v, want a future retry time", state.nextDirectRefreshAt)
198 + if !state.nextDirectRefreshAt.After(start) {
199 + t.Fatal("Retry timer not scheduled")
200 }
201 }
portal/discovery/relayset.go
+4 -4
@@ -46,7 +46,7 @@ type RelaySet struct {
46 mu sync.RWMutex
47 relays map[string]RelayState
48 keyIndex map[string]keyIndexEntry
49 - policy RelayPolicy
49 + policy MOLSRelayPolicy
50 }
51
52 // keyIndexEntry records the rollback anchor for a signing identity.
@@ -154,9 +154,9 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
154 return upsertAccepted
155 }
156
157 -func (s *RelaySet) SetRelayPolicy(policy RelayPolicy) {
158 - if policy == nil {
159 - policy = DefaultRelayPolicy{}
157 +func (s *RelaySet) SetRelayPolicy(policy MOLSRelayPolicy) {
158 + if policy == (MOLSRelayPolicy{}) {
159 + policy = MOLSRelayPolicy{}
160 }
161 s.mu.Lock()
162 defer s.mu.Unlock()