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

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.
package/README.md CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  cancelRequest,
39
39
  cancelAllRequests,
40
40
  clearDNSCache,
41
+ getDebugSnapshot,
41
42
  isProxyActiveForUrl,
42
43
  } from '@onekeyfe/react-native-sni-connect';
43
44
 
@@ -66,6 +67,12 @@ await cancelAllRequests();
66
67
 
67
68
  // Drop pinned-IP connections / cached clients
68
69
  await clearDNSCache();
70
+
71
+ // Diagnostics for one validated hostname/IP pair
72
+ const snapshot = await getDebugSnapshot({
73
+ hostname: 'example.com',
74
+ ip: '93.184.216.34',
75
+ });
69
76
  ```
70
77
 
71
78
  `multiValueHeaders` preserves repeated response headers when the native transport
@@ -57,6 +57,15 @@ internal fun classifySniFailureCode(error: Throwable): String {
57
57
  return "SNI_REQUEST_FAILED"
58
58
  }
59
59
 
60
+ internal fun classifySniResponseFailureCode(
61
+ error: Throwable,
62
+ explicitlyCancelled: Boolean,
63
+ ): String = when {
64
+ explicitlyCancelled -> "SNI_CANCELLED"
65
+ hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
66
+ else -> "SNI_RESPONSE_FAILED"
67
+ }
68
+
60
69
  private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
61
70
  var current: Throwable? = error
62
71
  while (current != null) {
@@ -77,12 +86,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
77
86
  // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
78
87
  private const val MAX_CLIENTS = 32
79
88
 
80
- // A single dispatcher + connection pool shared across all cached clients so we
81
- // don't spawn a thread pool / connection pool per (hostname, ip) pair.
89
+ // Native resources are process-shared across the main and background RN runtimes.
82
90
  private val sharedDispatcher = Dispatcher().apply {
83
- maxRequests = 64
84
- maxRequestsPerHost = 64
91
+ maxRequests = SniConnectValidation.MAX_ACTIVE_REQUESTS
92
+ maxRequestsPerHost = SniConnectValidation.MAX_ACTIVE_REQUESTS
85
93
  }
94
+ private val sharedAdmission = SniConnectRequestAdmission()
86
95
  private val sharedConnectionPool = ConnectionPool()
87
96
  }
88
97
 
@@ -120,13 +129,50 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
120
129
  }
121
130
  }
122
131
 
123
- private val activeCalls = ConcurrentHashMap<String, Call>()
124
- private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap<Call, Boolean>())
132
+ private class ManagedRequest(
133
+ val call: Call,
134
+ val settled: AtomicBoolean,
135
+ ) {
136
+ lateinit var admissionTicket: SniConnectRequestAdmission.Ticket
137
+ private val explicitlyCancelled = AtomicBoolean(false)
138
+
139
+ fun cancel() {
140
+ explicitlyCancelled.set(true)
141
+ if (!admissionTicket.cancelPending()) {
142
+ call.cancel()
143
+ }
144
+ }
145
+
146
+ fun release() {
147
+ admissionTicket.release()
148
+ }
149
+
150
+ fun wasExplicitlyCancelled(): Boolean = explicitlyCancelled.get()
151
+ }
152
+
153
+ // Cancellation ownership is per module, so cancelAllRequests only affects its RN runtime.
154
+ private val activeCalls = ConcurrentHashMap<String, ManagedRequest>()
155
+ private val allActiveCalls = Collections.newSetFromMap(
156
+ ConcurrentHashMap<ManagedRequest, Boolean>(),
157
+ )
125
158
  private val activeCallsLock = Any()
126
- private val requestLimiter = SniConnectRequestLimiter()
159
+ private var invalidated = false
127
160
 
128
161
  override fun getName(): String = NAME
129
162
 
163
+ override fun invalidate() {
164
+ val cancelledCount = cancelOwnedRequests(markInvalidated = true)
165
+ SniConnectLogger.info(
166
+ SniConnectLogger.event(
167
+ "sni_lifecycle",
168
+ "action" to "runtime_invalidate",
169
+ "cancelledCount" to cancelledCount,
170
+ "success" to true,
171
+ ),
172
+ )
173
+ super.invalidate()
174
+ }
175
+
130
176
  override fun request(config: ReadableMap, promise: Promise) {
131
177
  try {
132
178
  val requestConfig = config.toRequestConfig()
@@ -147,11 +193,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
147
193
 
148
194
  @ReactMethod
149
195
  override fun cancelRequest(requestId: String, promise: Promise) {
150
- val call = synchronized(activeCallsLock) {
196
+ val request = synchronized(activeCallsLock) {
151
197
  activeCalls.remove(requestId)
152
198
  }
153
- if (call != null) {
154
- call.cancel()
199
+ if (request != null) {
200
+ request.cancel()
155
201
  SniConnectLogger.info(
156
202
  SniConnectLogger.event(
157
203
  "sni_cancel",
@@ -174,17 +220,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
174
220
 
175
221
  @ReactMethod
176
222
  override fun cancelAllRequests(promise: Promise) {
177
- val calls = synchronized(activeCallsLock) {
178
- val snapshot = allActiveCalls.toList()
179
- activeCalls.clear()
180
- allActiveCalls.clear()
181
- snapshot
182
- }
183
- calls.forEach { call -> call.cancel() }
223
+ val cancelledCount = cancelOwnedRequests()
184
224
  SniConnectLogger.info(
185
225
  SniConnectLogger.event(
186
226
  "sni_cancel_all",
187
- "cancelledCount" to calls.size,
227
+ "cancelledCount" to cancelledCount,
188
228
  "success" to true,
189
229
  ),
190
230
  )
@@ -208,6 +248,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
208
248
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
209
249
  }
210
250
 
251
+ @ReactMethod
252
+ override fun getDebugSnapshot(target: ReadableMap, promise: Promise) {
253
+ try {
254
+ val ip = target.getString("ip") ?: throw IllegalArgumentException("ip is required")
255
+ val hostname = target.getString("hostname")
256
+ ?: throw IllegalArgumentException("hostname is required")
257
+ val canonicalIp = SniConnectValidation.canonicalizePublicIp(ip)
258
+ SniConnectValidation.validateHostname(hostname)
259
+ val snapshot = sharedAdmission.snapshot(hostname, canonicalIp)
260
+
261
+ promise.resolve(Arguments.createMap().apply {
262
+ putInt("activeRequests", snapshot.activeRequests)
263
+ putInt("activeRequestsForPair", snapshot.activeRequestsForPair)
264
+ putInt("pendingRequests", snapshot.pendingRequests)
265
+ putInt("pendingRequestsForPair", snapshot.pendingRequestsForPair)
266
+ putArray("activeRequestIdsForPair", snapshot.activeRequestIdsForPair.toWritableArray())
267
+ putArray("pendingRequestIdsForPair", snapshot.pendingRequestIdsForPair.toWritableArray())
268
+ })
269
+ } catch (error: Exception) {
270
+ promise.reject("SNI_INVALID_CONFIG", error.message, error)
271
+ }
272
+ }
273
+
211
274
  @ReactMethod
212
275
  override fun isProxyActiveForUrl(url: String, promise: Promise) {
213
276
  try {
@@ -219,19 +282,14 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
219
282
 
220
283
  private fun performRequest(config: RequestConfig, promise: Promise) {
221
284
  val startedAtMs = android.os.SystemClock.elapsedRealtime()
222
- var requestSlot: SniConnectRequestLimiter.Token? = null
223
- var registeredCall: Call? = null
285
+ val startedAtNanos = System.nanoTime()
286
+ var registeredRequest: ManagedRequest? = null
224
287
  try {
225
- requestSlot = requestLimiter.acquire(config.hostname, config.ip)
226
288
  val client = getOrCreateClient(config)
227
289
  val request = buildRequest(config)
228
290
  val call = client.newCall(request)
229
-
230
- // Apply per-request timeout
231
- call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
232
-
233
- registerCall(config.requestId, call)
234
- registeredCall = call
291
+ val settled = AtomicBoolean(false)
292
+ lateinit var managedRequest: ManagedRequest
235
293
 
236
294
  SniConnectLogger.info(
237
295
  SniConnectLogger.event(
@@ -247,16 +305,13 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
247
305
  ),
248
306
  )
249
307
 
250
- // Guard against double-settling the promise (RN hard-crashes otherwise).
251
- val settled = AtomicBoolean(false)
252
-
253
- call.enqueue(object : Callback {
308
+ val callback = object : Callback {
254
309
  override fun onFailure(call: Call, e: IOException) {
255
- unregisterCall(config.requestId, call)
256
- requestSlot?.release()
310
+ unregisterRequest(config.requestId, managedRequest)
311
+ managedRequest.release()
257
312
  if (!settled.compareAndSet(false, true)) return
258
313
 
259
- if (call.isCanceled()) {
314
+ if (managedRequest.wasExplicitlyCancelled()) {
260
315
  promise.reject("SNI_CANCELLED", "Request cancelled", null)
261
316
  } else {
262
317
  val code = classifySniFailureCode(e)
@@ -310,17 +365,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
310
365
  SniConnectLogger.info(resultLog)
311
366
  }
312
367
  if (settled.compareAndSet(false, true)) {
313
- promise.resolve(result)
368
+ if (managedRequest.wasExplicitlyCancelled()) {
369
+ promise.reject("SNI_CANCELLED", "Request cancelled", null)
370
+ } else {
371
+ promise.resolve(result)
372
+ }
314
373
  }
315
374
  }
316
375
  }
317
376
  } catch (error: Exception) {
318
377
  if (!settled.compareAndSet(false, true)) return
378
+ val code = classifySniResponseFailureCode(
379
+ error,
380
+ managedRequest.wasExplicitlyCancelled(),
381
+ )
382
+ if (code == "SNI_CANCELLED") {
383
+ promise.reject(code, "Request cancelled", null)
384
+ return
385
+ }
319
386
  SniConnectLogger.error(
320
387
  SniConnectLogger.event(
321
388
  "sni_request_result",
322
389
  "result" to "error",
323
- "code" to "SNI_RESPONSE_FAILED",
390
+ "code" to code,
324
391
  "nativeErrorClass" to error.javaClass.simpleName,
325
392
  "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
326
393
  "hostname" to config.hostname.lowercase(Locale.US),
@@ -331,22 +398,57 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
331
398
  "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
332
399
  ),
333
400
  )
334
- promise.reject("SNI_RESPONSE_FAILED", error.message, error)
401
+ promise.reject(code, error.message, error)
335
402
  } finally {
336
- unregisterCall(config.requestId, call)
337
- requestSlot?.release()
403
+ unregisterRequest(config.requestId, managedRequest)
404
+ managedRequest.release()
338
405
  }
339
406
  }
340
- })
407
+ }
408
+
409
+ val timeoutBeforeAdmission = remainingTimeoutMillis(
410
+ config.timeoutMillis,
411
+ startedAtNanos,
412
+ )
413
+ val admissionTicket = sharedAdmission.createTicket(
414
+ hostname = config.hostname,
415
+ ip = config.ip,
416
+ requestId = config.requestId,
417
+ timeoutMillis = timeoutBeforeAdmission,
418
+ onAdmitted = { remainingTimeoutMillis ->
419
+ try {
420
+ call.timeout().timeout(remainingTimeoutMillis, TimeUnit.MILLISECONDS)
421
+ call.enqueue(callback)
422
+ } catch (error: Exception) {
423
+ unregisterRequest(config.requestId, managedRequest)
424
+ managedRequest.release()
425
+ if (settled.compareAndSet(false, true)) {
426
+ promise.reject("SNI_REQUEST_FAILED", error.message, error)
427
+ }
428
+ }
429
+ },
430
+ onPendingFailure = { code, message ->
431
+ unregisterRequest(config.requestId, managedRequest)
432
+ if (settled.compareAndSet(false, true)) {
433
+ promise.reject(code, message, null)
434
+ }
435
+ },
436
+ )
437
+ managedRequest = ManagedRequest(call, settled).apply {
438
+ this.admissionTicket = admissionTicket
439
+ }
440
+ registerRequest(config.requestId, managedRequest)
441
+ registeredRequest = managedRequest
442
+ admissionTicket.submit()
341
443
  } catch (error: Exception) {
342
- registeredCall?.let { call ->
343
- unregisterCall(config.requestId, call)
444
+ registeredRequest?.let { request ->
445
+ unregisterRequest(config.requestId, request)
446
+ request.release()
344
447
  }
345
- requestSlot?.release()
346
- val code = if (error is SniConnectValidation.ValidationException) {
347
- "SNI_RESOURCE_LIMIT"
348
- } else {
349
- "SNI_REQUEST_FAILED"
448
+ val code = when {
449
+ error is SniConnectValidation.ValidationException -> "SNI_RESOURCE_LIMIT"
450
+ hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
451
+ else -> "SNI_REQUEST_FAILED"
350
452
  }
351
453
  SniConnectLogger.error(
352
454
  SniConnectLogger.event(
@@ -363,18 +465,30 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
363
465
  "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
364
466
  ),
365
467
  )
366
- promise.reject(code, error.message, error)
468
+ val shouldReject = registeredRequest?.settled?.compareAndSet(false, true) ?: true
469
+ if (shouldReject) {
470
+ promise.reject(code, error.message, error)
471
+ }
367
472
  }
368
473
  }
369
474
 
370
- private fun registerCall(requestId: String?, call: Call) {
371
- val previousCall = synchronized(activeCallsLock) {
372
- val previous = requestId?.let { activeCalls.put(it, call) }
373
- allActiveCalls.add(call)
475
+ private fun registerRequest(requestId: String?, request: ManagedRequest) {
476
+ var shouldCancel = false
477
+ val previousRequest = synchronized(activeCallsLock) {
478
+ if (invalidated) {
479
+ shouldCancel = true
480
+ return@synchronized null
481
+ }
482
+ val previous = requestId?.let { activeCalls.put(it, request) }
483
+ allActiveCalls.add(request)
374
484
  previous
375
485
  }
376
- if (previousCall != null && previousCall != call) {
377
- previousCall.cancel()
486
+ if (shouldCancel) {
487
+ request.cancel()
488
+ return
489
+ }
490
+ if (previousRequest != null && previousRequest != request) {
491
+ previousRequest.cancel()
378
492
  SniConnectLogger.warn(
379
493
  SniConnectLogger.event(
380
494
  "sni_duplicate_request_id",
@@ -385,18 +499,44 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
385
499
  }
386
500
  }
387
501
 
388
- private fun unregisterCall(requestId: String?, call: Call) {
502
+ private fun unregisterRequest(requestId: String?, request: ManagedRequest) {
389
503
  synchronized(activeCallsLock) {
390
504
  if (requestId != null) {
391
- activeCalls.remove(requestId, call)
505
+ activeCalls.remove(requestId, request)
392
506
  }
393
- allActiveCalls.remove(call)
507
+ allActiveCalls.remove(request)
394
508
  }
395
509
  }
396
510
 
511
+ private fun cancelOwnedRequests(markInvalidated: Boolean = false): Int {
512
+ val requests = synchronized(activeCallsLock) {
513
+ if (markInvalidated) {
514
+ invalidated = true
515
+ }
516
+ val snapshot = allActiveCalls.toList()
517
+ activeCalls.clear()
518
+ allActiveCalls.clear()
519
+ snapshot
520
+ }
521
+ requests.forEach { request -> request.cancel() }
522
+ return requests.size
523
+ }
524
+
525
+ private fun remainingTimeoutMillis(totalTimeoutMillis: Long, startedAtNanos: Long): Long {
526
+ val elapsedNanos = (System.nanoTime() - startedAtNanos).coerceAtLeast(0L)
527
+ val remainingNanos = TimeUnit.MILLISECONDS.toNanos(totalTimeoutMillis) - elapsedNanos
528
+ if (remainingNanos <= 0L) {
529
+ throw java.net.SocketTimeoutException("Request timed out before admission")
530
+ }
531
+ return ((remainingNanos + 999_999L) / 1_000_000L).coerceAtLeast(1L)
532
+ }
533
+
397
534
  private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
398
535
  val normalizedHost = config.hostname.lowercase(Locale.US)
399
- val key = ClientKey(normalizedHost, config.ip)
536
+ val key = ClientKey(
537
+ normalizedHost,
538
+ SniConnectValidation.canonicalizePublicIp(config.ip),
539
+ )
400
540
 
401
541
  synchronized(clientCache) {
402
542
  clientCache[key]?.let { return it }
@@ -523,6 +663,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
523
663
  }
524
664
  }
525
665
 
666
+ private fun List<String>.toWritableArray() = Arguments.createArray().apply {
667
+ forEach { value -> pushString(value) }
668
+ }
669
+
526
670
  private fun ReadableMap.toRequestConfig(): RequestConfig {
527
671
  val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
528
672
  getMap("headers")?.toHashMap()
@@ -540,7 +684,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
540
684
 
541
685
  val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
542
686
 
543
- val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
687
+ val rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
544
688
  val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
545
689
  val method = getString("method") ?: "GET"
546
690
  val path = getString("path") ?: "/"
@@ -548,7 +692,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
548
692
 
549
693
  // Validate every caller-controlled field at the boundary.
550
694
  SniConnectValidation.validateRequestId(requestId)
551
- SniConnectValidation.validatePublicIp(ip)
695
+ val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
552
696
  SniConnectValidation.validateHostname(hostname)
553
697
  val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
554
698
  val normalizedMethod = SniConnectValidation.normalizeMethod(method)