@onekeyfe/react-native-sni-connect 3.0.85 → 3.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ package com.sniconnect
2
+
3
+ import java.util.ArrayDeque
4
+ import java.util.Locale
5
+ import java.util.concurrent.ScheduledExecutorService
6
+ import java.util.concurrent.ScheduledFuture
7
+ import java.util.concurrent.ScheduledThreadPoolExecutor
8
+ import java.util.concurrent.TimeUnit
9
+
10
+ internal data class SniConnectAdmissionSnapshot(
11
+ val activeRequests: Int,
12
+ val activeRequestsForPair: Int,
13
+ val pendingRequests: Int,
14
+ val pendingRequestsForPair: Int,
15
+ val activeRequestIdsForPair: List<String> = emptyList(),
16
+ val pendingRequestIdsForPair: List<String> = emptyList(),
17
+ )
18
+
19
+ internal class SniConnectRequestAdmission(
20
+ private val maxActiveRequests: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS,
21
+ private val maxActiveRequestsPerPair: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS_PER_PAIR,
22
+ private val maxPendingRequests: Int = SniConnectValidation.MAX_PENDING_REQUESTS,
23
+ private val scheduler: ScheduledExecutorService = createAdmissionScheduler(),
24
+ private val nanoTime: () -> Long = System::nanoTime,
25
+ ) {
26
+ internal data class PairKey(
27
+ val hostname: String,
28
+ val ip: String,
29
+ )
30
+
31
+ internal enum class State {
32
+ CREATED,
33
+ PENDING,
34
+ ACTIVE,
35
+ TERMINAL,
36
+ }
37
+
38
+ private data class Dispatch(
39
+ val ticket: Ticket,
40
+ val remainingTimeoutMillis: Long?,
41
+ )
42
+
43
+ inner class Ticket internal constructor(
44
+ internal val pair: PairKey,
45
+ internal val requestId: String?,
46
+ internal val deadlineNanos: Long,
47
+ internal val onAdmitted: (remainingTimeoutMillis: Long) -> Unit,
48
+ internal val onPendingFailure: (code: String, message: String) -> Unit,
49
+ ) {
50
+ internal var state = State.CREATED
51
+ internal var timeoutFuture: ScheduledFuture<*>? = null
52
+
53
+ fun submit() {
54
+ this@SniConnectRequestAdmission.submit(this)
55
+ }
56
+
57
+ fun cancelPending(): Boolean =
58
+ this@SniConnectRequestAdmission.cancelPending(this)
59
+
60
+ fun release() {
61
+ this@SniConnectRequestAdmission.release(this)
62
+ }
63
+ }
64
+
65
+ private val lock = Any()
66
+ private var activeRequests = 0
67
+ private val activeRequestsByPair = mutableMapOf<PairKey, Int>()
68
+ private val activeTickets = mutableSetOf<Ticket>()
69
+ private val pendingRequests = ArrayDeque<Ticket>()
70
+
71
+ fun createTicket(
72
+ hostname: String,
73
+ ip: String,
74
+ requestId: String? = null,
75
+ timeoutMillis: Long,
76
+ onAdmitted: (remainingTimeoutMillis: Long) -> Unit,
77
+ onPendingFailure: (code: String, message: String) -> Unit,
78
+ ): Ticket = Ticket(
79
+ pair = pairKey(hostname, ip),
80
+ requestId = requestId?.takeIf { it.isNotEmpty() },
81
+ deadlineNanos = nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis),
82
+ onAdmitted = onAdmitted,
83
+ onPendingFailure = onPendingFailure,
84
+ )
85
+
86
+ fun snapshot(hostname: String, ip: String): SniConnectAdmissionSnapshot {
87
+ val pair = pairKey(hostname, ip)
88
+ return synchronized(lock) {
89
+ val activeForPair = activeTickets.filter { ticket -> ticket.pair == pair }
90
+ val pendingForPair = pendingRequests.filter { ticket -> ticket.pair == pair }
91
+ SniConnectAdmissionSnapshot(
92
+ activeRequests = activeRequests,
93
+ activeRequestsForPair = activeForPair.size,
94
+ pendingRequests = pendingRequests.size,
95
+ pendingRequestsForPair = pendingForPair.size,
96
+ activeRequestIdsForPair = activeForPair.mapNotNull { ticket -> ticket.requestId }.sorted(),
97
+ pendingRequestIdsForPair = pendingForPair.mapNotNull { ticket -> ticket.requestId }.sorted(),
98
+ )
99
+ }
100
+ }
101
+
102
+ internal fun shutdownForTests() {
103
+ scheduler.shutdownNow()
104
+ }
105
+
106
+ private fun submit(ticket: Ticket) {
107
+ var remainingTimeoutMillis: Long? = null
108
+ var timedOut = false
109
+ synchronized(lock) {
110
+ // A runtime can cancel immediately after registering the handle but
111
+ // before submit() reaches this lock. Cancellation already settled the
112
+ // ticket, so submission becomes an idempotent no-op.
113
+ if (ticket.state == State.TERMINAL) return
114
+ check(ticket.state == State.CREATED) { "Admission ticket already submitted" }
115
+ if (ticket.deadlineNanos <= nanoTime()) {
116
+ ticket.state = State.TERMINAL
117
+ timedOut = true
118
+ } else if (canActivateLocked(ticket.pair)) {
119
+ activateLocked(ticket)
120
+ remainingTimeoutMillis = remainingMillis(ticket.deadlineNanos)
121
+ } else {
122
+ if (pendingRequests.size >= maxPendingRequests) {
123
+ ticket.state = State.TERMINAL
124
+ throw SniConnectValidation.ValidationException("Too many pending SNI requests")
125
+ }
126
+ ticket.state = State.PENDING
127
+ pendingRequests.addLast(ticket)
128
+ val delayNanos = (ticket.deadlineNanos - nanoTime()).coerceAtLeast(0L)
129
+ ticket.timeoutFuture = scheduler.schedule(
130
+ { timeoutPending(ticket) },
131
+ delayNanos,
132
+ TimeUnit.NANOSECONDS,
133
+ )
134
+ }
135
+ }
136
+
137
+ if (timedOut) {
138
+ ticket.onPendingFailure(
139
+ "SNI_REQUEST_TIMEOUT",
140
+ "Request timed out while waiting for admission",
141
+ )
142
+ } else {
143
+ remainingTimeoutMillis?.let(ticket.onAdmitted)
144
+ }
145
+ }
146
+
147
+ private fun cancelPending(ticket: Ticket): Boolean {
148
+ val cancelled = synchronized(lock) {
149
+ when (ticket.state) {
150
+ State.CREATED -> {
151
+ ticket.state = State.TERMINAL
152
+ true
153
+ }
154
+ State.PENDING -> {
155
+ pendingRequests.remove(ticket)
156
+ ticket.timeoutFuture?.cancel(false)
157
+ ticket.timeoutFuture = null
158
+ ticket.state = State.TERMINAL
159
+ true
160
+ }
161
+ State.ACTIVE, State.TERMINAL -> false
162
+ }
163
+ }
164
+ if (cancelled) {
165
+ ticket.onPendingFailure("SNI_CANCELLED", "Request cancelled")
166
+ }
167
+ return cancelled
168
+ }
169
+
170
+ private fun timeoutPending(ticket: Ticket) {
171
+ val timedOut = synchronized(lock) {
172
+ if (ticket.state != State.PENDING) {
173
+ false
174
+ } else {
175
+ pendingRequests.remove(ticket)
176
+ ticket.timeoutFuture = null
177
+ ticket.state = State.TERMINAL
178
+ true
179
+ }
180
+ }
181
+ if (timedOut) {
182
+ ticket.onPendingFailure(
183
+ "SNI_REQUEST_TIMEOUT",
184
+ "Request timed out while waiting for admission",
185
+ )
186
+ }
187
+ }
188
+
189
+ private fun release(ticket: Ticket) {
190
+ val admissions = synchronized(lock) {
191
+ if (ticket.state != State.ACTIVE) return
192
+ ticket.state = State.TERMINAL
193
+ activeTickets.remove(ticket)
194
+ activeRequests -= 1
195
+ decrementPairLocked(ticket.pair)
196
+ collectAdmissionsLocked()
197
+ }
198
+ admissions.forEach { dispatch ->
199
+ val timeoutMillis = dispatch.remainingTimeoutMillis
200
+ if (timeoutMillis == null) {
201
+ dispatch.ticket.onPendingFailure(
202
+ "SNI_REQUEST_TIMEOUT",
203
+ "Request timed out while waiting for admission",
204
+ )
205
+ } else {
206
+ dispatch.ticket.onAdmitted(timeoutMillis)
207
+ }
208
+ }
209
+ }
210
+
211
+ private fun collectAdmissionsLocked(): List<Dispatch> {
212
+ val admissions = mutableListOf<Dispatch>()
213
+ while (activeRequests < maxActiveRequests) {
214
+ val iterator = pendingRequests.iterator()
215
+ var next: Ticket? = null
216
+ while (iterator.hasNext()) {
217
+ val candidate = iterator.next()
218
+ if (canActivateLocked(candidate.pair)) {
219
+ iterator.remove()
220
+ next = candidate
221
+ break
222
+ }
223
+ }
224
+ val ticket = next ?: break
225
+ ticket.timeoutFuture?.cancel(false)
226
+ ticket.timeoutFuture = null
227
+ if (ticket.deadlineNanos <= nanoTime()) {
228
+ ticket.state = State.TERMINAL
229
+ admissions += Dispatch(ticket, null)
230
+ } else {
231
+ activateLocked(ticket)
232
+ admissions += Dispatch(ticket, remainingMillis(ticket.deadlineNanos))
233
+ }
234
+ }
235
+ return admissions
236
+ }
237
+
238
+ private fun canActivateLocked(pair: PairKey): Boolean =
239
+ activeRequests < maxActiveRequests &&
240
+ (activeRequestsByPair[pair] ?: 0) < maxActiveRequestsPerPair
241
+
242
+ private fun pairKey(hostname: String, ip: String): PairKey = PairKey(
243
+ hostname = hostname.lowercase(Locale.US),
244
+ ip = SniConnectValidation.canonicalizePublicIp(ip),
245
+ )
246
+
247
+ private fun activateLocked(ticket: Ticket) {
248
+ ticket.state = State.ACTIVE
249
+ activeTickets.add(ticket)
250
+ activeRequests += 1
251
+ activeRequestsByPair[ticket.pair] = (activeRequestsByPair[ticket.pair] ?: 0) + 1
252
+ }
253
+
254
+ private fun decrementPairLocked(pair: PairKey) {
255
+ val pairCount = activeRequestsByPair[pair] ?: return
256
+ if (pairCount == 1) {
257
+ activeRequestsByPair.remove(pair)
258
+ } else {
259
+ activeRequestsByPair[pair] = pairCount - 1
260
+ }
261
+ }
262
+
263
+ private fun remainingMillis(deadlineNanos: Long): Long {
264
+ val remainingNanos = (deadlineNanos - nanoTime()).coerceAtLeast(1L)
265
+ return ((remainingNanos + 999_999L) / 1_000_000L).coerceAtLeast(1L)
266
+ }
267
+
268
+ private companion object {
269
+ fun createAdmissionScheduler(): ScheduledExecutorService =
270
+ ScheduledThreadPoolExecutor(1) { runnable ->
271
+ Thread(runnable, "SniConnectAdmission").apply { isDaemon = true }
272
+ }.apply {
273
+ removeOnCancelPolicy = true
274
+ }
275
+ }
276
+ }
@@ -4,7 +4,6 @@ import java.net.Inet6Address
4
4
  import java.net.InetAddress
5
5
  import java.nio.charset.StandardCharsets
6
6
  import java.util.Locale
7
- import java.util.concurrent.atomic.AtomicBoolean
8
7
 
9
8
  /**
10
9
  * Boundary validation/normalization for SNI request inputs.
@@ -29,6 +28,7 @@ internal object SniConnectValidation {
29
28
  const val MAX_TOTAL_HEADER_BYTES = 32 * 1024
30
29
  const val MAX_ACTIVE_REQUESTS = 64
31
30
  const val MAX_ACTIVE_REQUESTS_PER_PAIR = 16
31
+ const val MAX_PENDING_REQUESTS = 256
32
32
 
33
33
  private val ALLOWED_METHODS =
34
34
  setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
@@ -205,8 +205,12 @@ internal object SniConnectValidation {
205
205
  ) {
206
206
  throw ValidationException("Invalid IP: $ip")
207
207
  }
208
- val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() }
209
- if (octets != null) {
208
+ val octetStrings = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)
209
+ if (octetStrings != null) {
210
+ if (octetStrings.any { it.length > 1 && it.startsWith('0') }) {
211
+ throw ValidationException("Invalid IP: $ip")
212
+ }
213
+ val octets = octetStrings.map { it.toInt() }
210
214
  if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip")
211
215
  if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip")
212
216
  return
@@ -225,6 +229,11 @@ internal object SniConnectValidation {
225
229
  throw ValidationException("Invalid IP: $ip")
226
230
  }
227
231
 
232
+ fun canonicalizePublicIp(ip: String): String {
233
+ validatePublicIp(ip)
234
+ return literalToInetAddress(ip).hostAddress
235
+ }
236
+
228
237
  private fun isForbiddenIpv4(o: List<Int>): Boolean {
229
238
  val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3]
230
239
  return when {
@@ -313,78 +322,3 @@ internal object SniConnectValidation {
313
322
  return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
314
323
  }
315
324
  }
316
-
317
- internal class SniConnectRequestLimiter(
318
- private val maxActiveRequests: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS,
319
- private val maxActiveRequestsPerPair: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS_PER_PAIR,
320
- ) {
321
- private val lock = Any()
322
- private var activeRequests = 0
323
- private val activeRequestsByPair = mutableMapOf<String, Int>()
324
-
325
- fun acquire(hostname: String, ip: String): Token {
326
- val key = pairKey(hostname, ip)
327
- synchronized(lock) {
328
- if (activeRequests >= maxActiveRequests) {
329
- SniConnectLogger.warn(
330
- SniConnectLogger.event(
331
- "sni_resource_limit",
332
- "activeCount" to activeRequests,
333
- "pairCount" to (activeRequestsByPair[key] ?: 0),
334
- "limit" to maxActiveRequests,
335
- "reason" to "max_active_requests",
336
- "hostname" to hostname.lowercase(Locale.US),
337
- "ipHash" to SniConnectLogger.shortHash(ip),
338
- ),
339
- )
340
- throw SniConnectValidation.ValidationException("Too many active SNI requests")
341
- }
342
- val pairCount = activeRequestsByPair[key] ?: 0
343
- if (pairCount >= maxActiveRequestsPerPair) {
344
- SniConnectLogger.warn(
345
- SniConnectLogger.event(
346
- "sni_resource_limit",
347
- "activeCount" to activeRequests,
348
- "pairCount" to pairCount,
349
- "limit" to maxActiveRequestsPerPair,
350
- "reason" to "max_active_requests_per_pair",
351
- "hostname" to hostname.lowercase(Locale.US),
352
- "ipHash" to SniConnectLogger.shortHash(ip),
353
- ),
354
- )
355
- throw SniConnectValidation.ValidationException("Too many active SNI requests for destination")
356
- }
357
- activeRequests += 1
358
- activeRequestsByPair[key] = pairCount + 1
359
- }
360
- return Token(this, key)
361
- }
362
-
363
- private fun release(key: String) {
364
- synchronized(lock) {
365
- activeRequests = (activeRequests - 1).coerceAtLeast(0)
366
- val pairCount = activeRequestsByPair[key] ?: return
367
- if (pairCount <= 1) {
368
- activeRequestsByPair.remove(key)
369
- } else {
370
- activeRequestsByPair[key] = pairCount - 1
371
- }
372
- }
373
- }
374
-
375
- private fun pairKey(hostname: String, ip: String): String =
376
- "${hostname.lowercase(Locale.US)}|$ip"
377
-
378
- class Token internal constructor(
379
- private val limiter: SniConnectRequestLimiter,
380
- private val key: String,
381
- ) {
382
- private val released = AtomicBoolean(false)
383
-
384
- fun release() {
385
- if (released.compareAndSet(false, true)) {
386
- limiter.release(key)
387
- }
388
- }
389
- }
390
- }
@@ -0,0 +1,309 @@
1
+ package com.sniconnect
2
+
3
+ import java.util.Collections
4
+ import java.util.concurrent.CountDownLatch
5
+ import java.util.concurrent.TimeUnit
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+
11
+ class SniConnectRequestAdmissionTest {
12
+ @Test
13
+ fun twentySamePairRequestsProduceSixteenActiveAndFourPending() {
14
+ val admission = SniConnectRequestAdmission()
15
+ val admitted = mutableListOf<String>()
16
+ val failures = mutableListOf<Pair<String, String>>()
17
+ try {
18
+ val tickets = (0 until 20).map { index ->
19
+ val requestId = "req-${index.toString().padStart(2, '0')}"
20
+ admission.createTicket(
21
+ hostname = "Example.com",
22
+ ip = if (index % 2 == 0) {
23
+ "2001:4860:4860::8888"
24
+ } else {
25
+ "2001:4860:4860:0:0:0:0:8888"
26
+ },
27
+ requestId = requestId,
28
+ timeoutMillis = 10_000,
29
+ onAdmitted = { admitted += requestId },
30
+ onPendingFailure = { code, message -> failures += code to message },
31
+ ).also { it.submit() }
32
+ }
33
+
34
+ assertEquals((0 until 16).map { "req-${it.toString().padStart(2, '0')}" }, admitted)
35
+ assertEquals(
36
+ SniConnectAdmissionSnapshot(
37
+ activeRequests = 16,
38
+ activeRequestsForPair = 16,
39
+ pendingRequests = 4,
40
+ pendingRequestsForPair = 4,
41
+ activeRequestIdsForPair = (0 until 16).map { "req-${it.toString().padStart(2, '0')}" },
42
+ pendingRequestIdsForPair = (16 until 20).map { "req-${it.toString().padStart(2, '0')}" },
43
+ ),
44
+ admission.snapshot("EXAMPLE.COM", "2001:4860:4860::8888"),
45
+ )
46
+
47
+ tickets.drop(16).forEach { pending -> assertTrue(pending.cancelPending()) }
48
+ assertEquals(4, failures.size)
49
+ assertTrue(failures.all { it.first == "SNI_CANCELLED" })
50
+ assertEquals(
51
+ 0,
52
+ admission.snapshot("example.com", "2001:4860:4860:0:0:0:0:8888").pendingRequests,
53
+ )
54
+
55
+ tickets.take(16).forEach { active -> active.release() }
56
+ assertEquals(
57
+ SniConnectAdmissionSnapshot(0, 0, 0, 0),
58
+ admission.snapshot("example.com", "2001:4860:4860::8888"),
59
+ )
60
+
61
+ val recovery = ticket(admission, "example.com", "2001:4860:4860::8888")
62
+ recovery.submit()
63
+ assertEquals(
64
+ 1,
65
+ admission.snapshot("example.com", "2001:4860:4860::8888").activeRequests,
66
+ )
67
+ recovery.release()
68
+ } finally {
69
+ admission.shutdownForTests()
70
+ }
71
+ }
72
+
73
+ @Test
74
+ fun queuesAtGlobalLimitAndDispatchesAfterRelease() {
75
+ val admission = admission(maxActive = 2, maxPerPair = 2)
76
+ val admitted = mutableListOf<String>()
77
+ try {
78
+ val first = submit(admission, "first", "one.example", "93.184.216.34", admitted)
79
+ val second = submit(admission, "second", "two.example", "93.184.216.35", admitted)
80
+ val pending = submit(admission, "pending", "three.example", "93.184.216.36", admitted)
81
+
82
+ assertEquals(listOf("first", "second"), admitted)
83
+ assertEquals(2, admission.snapshot("three.example", "93.184.216.36").activeRequests)
84
+ assertEquals(1, admission.snapshot("three.example", "93.184.216.36").pendingRequests)
85
+
86
+ first.release()
87
+ assertEquals(listOf("first", "second", "pending"), admitted)
88
+ first.release()
89
+ second.release()
90
+ pending.release()
91
+ assertEquals(0, admission.snapshot("three.example", "93.184.216.36").activeRequests)
92
+ } finally {
93
+ admission.shutdownForTests()
94
+ }
95
+ }
96
+
97
+ @Test
98
+ fun sameHostnameDifferentIpsHaveIndependentPairLimits() {
99
+ val admission = admission(maxActive = 4, maxPerPair = 2)
100
+ val admitted = mutableListOf<String>()
101
+ try {
102
+ val firstA = submit(admission, "a1", "Example.com", "93.184.216.34", admitted)
103
+ val secondA = submit(admission, "a2", "example.com", "93.184.216.34", admitted)
104
+ val pendingA = submit(admission, "a3", "example.com", "93.184.216.34", admitted)
105
+ val firstB = submit(admission, "b1", "example.com", "93.184.216.35", admitted)
106
+ val secondB = submit(admission, "b2", "example.com", "93.184.216.35", admitted)
107
+
108
+ assertEquals(listOf("a1", "a2", "b1", "b2"), admitted)
109
+ assertEquals(
110
+ SniConnectAdmissionSnapshot(
111
+ activeRequests = 4,
112
+ activeRequestsForPair = 2,
113
+ pendingRequests = 1,
114
+ pendingRequestsForPair = 1,
115
+ activeRequestIdsForPair = listOf("a1", "a2"),
116
+ pendingRequestIdsForPair = listOf("a3"),
117
+ ),
118
+ admission.snapshot("EXAMPLE.COM", "93.184.216.34"),
119
+ )
120
+
121
+ firstA.release()
122
+ assertEquals(listOf("a1", "a2", "b1", "b2", "a3"), admitted)
123
+ secondA.release()
124
+ pendingA.release()
125
+ firstB.release()
126
+ secondB.release()
127
+ assertEquals(
128
+ SniConnectAdmissionSnapshot(0, 0, 0, 0),
129
+ admission.snapshot("example.com", "93.184.216.34"),
130
+ )
131
+ } finally {
132
+ admission.shutdownForTests()
133
+ }
134
+ }
135
+
136
+ @Test
137
+ fun rejectsTheTwoHundredFiftySeventhPendingRequest() {
138
+ val admission = admission(maxActive = 1, maxPerPair = 1, maxPending = 256)
139
+ try {
140
+ val active = ticket(admission, "example.com", "93.184.216.34").also { it.submit() }
141
+ val pending = (0 until 256).map {
142
+ ticket(admission, "example.com", "93.184.216.34").also { request -> request.submit() }
143
+ }
144
+ val overflow = ticket(admission, "example.com", "93.184.216.34")
145
+
146
+ assertValidationFails { overflow.submit() }
147
+ assertEquals(256, admission.snapshot("example.com", "93.184.216.34").pendingRequests)
148
+
149
+ pending.forEach { request -> assertTrue(request.cancelPending()) }
150
+ active.release()
151
+ assertEquals(
152
+ SniConnectAdmissionSnapshot(0, 0, 0, 0),
153
+ admission.snapshot("example.com", "93.184.216.34"),
154
+ )
155
+ } finally {
156
+ admission.shutdownForTests()
157
+ }
158
+ }
159
+
160
+ @Test
161
+ fun cancellingPendingRequestRemovesItAndSettlesImmediately() {
162
+ val admission = admission(maxActive = 1, maxPerPair = 1)
163
+ val failures = mutableListOf<Pair<String, String>>()
164
+ try {
165
+ val active = ticket(admission, "example.com", "93.184.216.34").also { it.submit() }
166
+ val pending = admission.createTicket(
167
+ hostname = "example.com",
168
+ ip = "93.184.216.34",
169
+ timeoutMillis = 10_000,
170
+ onAdmitted = { throw AssertionError("Cancelled request must not be admitted") },
171
+ onPendingFailure = { code, message -> failures += code to message },
172
+ ).also { it.submit() }
173
+
174
+ assertTrue(pending.cancelPending())
175
+ assertEquals(listOf("SNI_CANCELLED" to "Request cancelled"), failures)
176
+ assertEquals(0, admission.snapshot("example.com", "93.184.216.34").pendingRequests)
177
+ assertFalse(pending.cancelPending())
178
+ active.release()
179
+ } finally {
180
+ admission.shutdownForTests()
181
+ }
182
+ }
183
+
184
+ @Test
185
+ fun cancellationBeforeSubmitSettlesOnceAndSubmitBecomesNoOp() {
186
+ val admission = admission(maxActive = 1, maxPerPair = 1)
187
+ val failures = mutableListOf<String>()
188
+ try {
189
+ val request = admission.createTicket(
190
+ hostname = "example.com",
191
+ ip = "93.184.216.34",
192
+ timeoutMillis = 10_000,
193
+ onAdmitted = { throw AssertionError("Cancelled request must not be admitted") },
194
+ onPendingFailure = { code, _ -> failures += code },
195
+ )
196
+
197
+ assertTrue(request.cancelPending())
198
+ request.submit()
199
+ assertEquals(listOf("SNI_CANCELLED"), failures)
200
+ assertEquals(
201
+ SniConnectAdmissionSnapshot(0, 0, 0, 0),
202
+ admission.snapshot("example.com", "93.184.216.34"),
203
+ )
204
+ } finally {
205
+ admission.shutdownForTests()
206
+ }
207
+ }
208
+
209
+ @Test
210
+ fun timeoutIncludesTimeSpentWaitingForAdmission() {
211
+ val admission = admission(maxActive = 1, maxPerPair = 1)
212
+ val timedOut = CountDownLatch(1)
213
+ val failureCodes = Collections.synchronizedList(mutableListOf<String>())
214
+ try {
215
+ val active = ticket(admission, "example.com", "93.184.216.34").also { it.submit() }
216
+ admission.createTicket(
217
+ hostname = "example.com",
218
+ ip = "93.184.216.34",
219
+ timeoutMillis = 40,
220
+ onAdmitted = { throw AssertionError("Timed-out request must not be admitted") },
221
+ onPendingFailure = { code, _ ->
222
+ failureCodes += code
223
+ timedOut.countDown()
224
+ },
225
+ ).submit()
226
+
227
+ assertTrue(timedOut.await(2, TimeUnit.SECONDS))
228
+ assertEquals(listOf("SNI_REQUEST_TIMEOUT"), failureCodes.toList())
229
+ assertEquals(0, admission.snapshot("example.com", "93.184.216.34").pendingRequests)
230
+ active.release()
231
+ } finally {
232
+ admission.shutdownForTests()
233
+ }
234
+ }
235
+
236
+ @Test
237
+ fun admittedRequestReceivesOnlyTheRemainingTimeout() {
238
+ var nowNanos = 0L
239
+ val admission = admission(
240
+ maxActive = 1,
241
+ maxPerPair = 1,
242
+ nanoTime = { nowNanos },
243
+ )
244
+ val remainingTimeouts = mutableListOf<Long>()
245
+ try {
246
+ val active = ticket(admission, "example.com", "93.184.216.34").also { it.submit() }
247
+ val pending = admission.createTicket(
248
+ hostname = "example.com",
249
+ ip = "93.184.216.34",
250
+ timeoutMillis = 1_000,
251
+ onAdmitted = { remainingTimeout -> remainingTimeouts += remainingTimeout },
252
+ onPendingFailure = { code, _ -> throw AssertionError("Unexpected failure: $code") },
253
+ ).also { it.submit() }
254
+
255
+ nowNanos = TimeUnit.MILLISECONDS.toNanos(250)
256
+ active.release()
257
+
258
+ assertEquals(listOf(750L), remainingTimeouts)
259
+ pending.release()
260
+ } finally {
261
+ admission.shutdownForTests()
262
+ }
263
+ }
264
+
265
+ private fun admission(
266
+ maxActive: Int,
267
+ maxPerPair: Int,
268
+ maxPending: Int = 10,
269
+ nanoTime: () -> Long = System::nanoTime,
270
+ ) = SniConnectRequestAdmission(
271
+ maxActiveRequests = maxActive,
272
+ maxActiveRequestsPerPair = maxPerPair,
273
+ maxPendingRequests = maxPending,
274
+ nanoTime = nanoTime,
275
+ )
276
+
277
+ private fun submit(
278
+ admission: SniConnectRequestAdmission,
279
+ id: String,
280
+ hostname: String,
281
+ ip: String,
282
+ admitted: MutableList<String>,
283
+ ): SniConnectRequestAdmission.Ticket =
284
+ ticket(admission, hostname, ip, requestId = id, onAdmitted = { admitted += id }).also { it.submit() }
285
+
286
+ private fun ticket(
287
+ admission: SniConnectRequestAdmission,
288
+ hostname: String,
289
+ ip: String,
290
+ requestId: String? = null,
291
+ onAdmitted: (Long) -> Unit = {},
292
+ ): SniConnectRequestAdmission.Ticket = admission.createTicket(
293
+ hostname = hostname,
294
+ ip = ip,
295
+ requestId = requestId,
296
+ timeoutMillis = 60_000,
297
+ onAdmitted = onAdmitted,
298
+ onPendingFailure = { _, _ -> },
299
+ )
300
+
301
+ private fun assertValidationFails(block: () -> Unit) {
302
+ try {
303
+ block()
304
+ } catch (_: SniConnectValidation.ValidationException) {
305
+ return
306
+ }
307
+ throw AssertionError("Expected SNI validation failure")
308
+ }
309
+ }