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()