@onekeyfe/react-native-sni-connect 3.0.71 → 3.0.73

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.
@@ -1,27 +1,178 @@
1
1
  import Foundation
2
+ import ObjectiveC
2
3
  import UIKit
3
4
  import EMASCurl
4
5
 
6
+ @objc(SniConnectPinnedDNSResolverBase)
7
+ private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver {
8
+ @objc class func resolveDomain(_ domain: String) -> String? {
9
+ PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self)
10
+ }
11
+ }
12
+
13
+ private enum PinnedDNSResolverFactory {
14
+ private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers")
15
+ private static var nextClassID = 0
16
+ private static let registry = SniConnectPinnedResolverRegistry()
17
+
18
+ static func resolverClass(hostname: String, ip: String) throws -> EMASCurlProtocolDNSResolver.Type {
19
+ return try queue.sync {
20
+ let resolverClass = try registry.resolverClass(
21
+ hostname: hostname,
22
+ ip: ip,
23
+ allocateClass: allocateResolverClass
24
+ )
25
+ return resolverClass as! EMASCurlProtocolDNSResolver.Type
26
+ }
27
+ }
28
+
29
+ static func resolve(domain: String, resolverClass: AnyClass) -> String? {
30
+ return queue.sync {
31
+ registry.resolve(domain: domain, resolverClass: resolverClass)
32
+ }
33
+ }
34
+
35
+ static func remove(hostname: String, ip: String) {
36
+ queue.sync {
37
+ registry.release(hostname: hostname, ip: ip)
38
+ }
39
+ }
40
+
41
+ private static func allocateResolverClass() -> AnyClass {
42
+ while true {
43
+ nextClassID += 1
44
+ let className = "SniConnectPinnedDNSResolver_\(nextClassID)"
45
+ if let resolverClass = objc_allocateClassPair(
46
+ SniConnectPinnedDNSResolverBase.self,
47
+ className,
48
+ 0
49
+ ) {
50
+ objc_registerClassPair(resolverClass)
51
+ return resolverClass
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ private final class SniConnectPinnedResolverLease {
58
+ private let hostname: String
59
+ private let ip: String
60
+ private let queue = DispatchQueue(label: "com.onekey.sni.connect.resolver-lease")
61
+ private var didRelease = false
62
+
63
+ init(hostname: String, ip: String) {
64
+ self.hostname = hostname
65
+ self.ip = ip
66
+ }
67
+
68
+ func release() -> Bool {
69
+ queue.sync {
70
+ if didRelease {
71
+ return false
72
+ }
73
+ PinnedDNSResolverFactory.remove(hostname: hostname, ip: ip)
74
+ didRelease = true
75
+ return true
76
+ }
77
+ }
78
+
79
+ func waitUntilReleased() async throws {
80
+ while !isReleased {
81
+ try Task.checkCancellation()
82
+ try await Task.sleep(nanoseconds: 10_000_000)
83
+ }
84
+ }
85
+
86
+ private var isReleased: Bool {
87
+ queue.sync {
88
+ didRelease
89
+ }
90
+ }
91
+ }
92
+
93
+ private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate {
94
+ private let hostname: String
95
+ private let ip: String
96
+ private let resolverLease: SniConnectPinnedResolverLease
97
+
98
+ init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) {
99
+ self.hostname = hostname
100
+ self.ip = ip
101
+ self.resolverLease = resolverLease
102
+ }
103
+
104
+ func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
105
+ let releasedResolver = resolverLease.release()
106
+ SniConnectLog.info(SniConnectLog.event("sni_session_invalidated", [
107
+ ("hostname", hostname),
108
+ ("ipHash", SniConnectLog.shortHash(ip)),
109
+ ("releasedResolver", releasedResolver),
110
+ ("success", error == nil),
111
+ ]))
112
+ }
113
+ }
114
+
5
115
  /// Core HTTPS client that enforces IP direct connection with SNI.
6
116
  final class SniConnectClient {
7
117
 
8
- // Active requests tracking for cancellation support
9
- private var activeTasks: [String: Task<Response, Error>] = [:]
118
+ // Active requests tracking for cancellation support. Every task is tracked by
119
+ // token so cancelAllRequests() also covers requests that do not have a requestId.
120
+ private var activeTasksByToken: [UUID: Task<Response, Error>] = [:]
121
+ private var requestTokensById: [String: UUID] = [:]
10
122
  private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
123
+ private let requestLimiter = SniConnectRequestLimiter()
124
+
125
+ private struct SessionKey: Hashable {
126
+ let hostname: String
127
+ let ip: String
128
+ }
129
+
130
+ private struct SessionLease {
131
+ let session: URLSession
132
+ let sessionID: ObjectIdentifier
133
+ }
134
+
135
+ private struct ManagedSession {
136
+ let session: URLSession
137
+ let resolverLease: SniConnectPinnedResolverLease
138
+ }
139
+
140
+ private struct SessionInvalidation {
141
+ let managedSession: ManagedSession
142
+ let releaseResolverImmediately: Bool
143
+ }
144
+
145
+ private enum SessionAcquisition {
146
+ case ready(SessionLease)
147
+ case waitForResolverSlots([SniConnectPinnedResolverLease])
148
+ }
149
+
150
+ private static let maxCachedSessions = SniConnectPinnedResolverRegistry.defaultMaxEntries
151
+ private var sessionCache: [SessionKey: ManagedSession] = [:]
152
+ private var sessionAccessOrder: [SessionKey] = []
153
+ private var activeSessionCounts: [ObjectIdentifier: Int] = [:]
154
+ private let sessionsQueue = DispatchQueue(label: "com.onekey.sni.connect.sessions")
11
155
 
12
156
  // Token for the memory-warning observer (block-based observers are not removed
13
157
  // by `removeObserver(self)`, so the token must be retained and removed explicitly).
14
158
  private var memoryWarningObserver: NSObjectProtocol?
15
159
 
16
160
  init() {
161
+ SniConnectCoreDiagnostics.warnSink = { message in
162
+ SniConnectLog.warn(message)
163
+ }
164
+
17
165
  // Register for memory warnings to clean cache
18
166
  memoryWarningObserver = NotificationCenter.default.addObserver(
19
167
  forName: UIApplication.didReceiveMemoryWarningNotification,
20
168
  object: nil,
21
169
  queue: .main
22
- ) { _ in
23
- SniConnectLog.info("Memory warning received, cleaning DNS cache")
24
- DNSResolver.cleanExpiredEntries()
170
+ ) { [weak self] _ in
171
+ SniConnectLog.info(SniConnectLog.event("sni_lifecycle", [
172
+ ("action", "memory_warning"),
173
+ ("cacheAction", "clear_dns_cache"),
174
+ ]))
175
+ self?.clearDNSCache()
25
176
  }
26
177
  }
27
178
 
@@ -55,7 +206,7 @@ final class SniConnectClient {
55
206
  }
56
207
 
57
208
  struct Response {
58
- let data: Any
209
+ let data: String
59
210
  let status: Int
60
211
  let statusText: String
61
212
  let headers: [String: String] // Single-value headers (backward compatible)
@@ -72,7 +223,9 @@ final class SniConnectClient {
72
223
  case connectionRefused
73
224
  case networkUnreachable
74
225
  case requestTimeout
226
+ case resourceLimit(String)
75
227
  case httpError(code: Int, message: String)
228
+ case responseProcessingFailed(String)
76
229
  case cancelled
77
230
  case unknown(Error)
78
231
 
@@ -87,7 +240,9 @@ final class SniConnectClient {
87
240
  case .connectionRefused: return "SNI_CONNECTION_REFUSED"
88
241
  case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE"
89
242
  case .requestTimeout: return "SNI_REQUEST_TIMEOUT"
243
+ case .resourceLimit: return "SNI_RESOURCE_LIMIT"
90
244
  case .httpError: return "SNI_HTTP_ERROR"
245
+ case .responseProcessingFailed: return "SNI_RESPONSE_FAILED"
91
246
  case .cancelled: return "SNI_CANCELLED"
92
247
  case .unknown: return "SNI_UNKNOWN_ERROR"
93
248
  }
@@ -113,8 +268,12 @@ final class SniConnectClient {
113
268
  return "Network unreachable"
114
269
  case .requestTimeout:
115
270
  return "Request timeout"
271
+ case .resourceLimit(let details):
272
+ return "Resource limit exceeded: \(details)"
116
273
  case .httpError(let code, let message):
117
274
  return "HTTP error \(code): \(message)"
275
+ case .responseProcessingFailed(let details):
276
+ return "Response processing failed: \(details)"
118
277
  case .cancelled:
119
278
  return "Request cancelled"
120
279
  case .unknown(let error):
@@ -124,6 +283,27 @@ final class SniConnectClient {
124
283
 
125
284
  /// Convert NSError to SniConnectError with detailed classification
126
285
  static func from(_ error: Error) -> SniConnectError {
286
+ if let timeout = error as? SniConnectTimeout, timeout == .deadlineExceeded {
287
+ return .requestTimeout
288
+ }
289
+ if error is CancellationError {
290
+ return .cancelled
291
+ }
292
+ if let coreError = error as? SniConnectCoreError {
293
+ switch coreError {
294
+ case .resourceLimit(let details):
295
+ return .resourceLimit(details)
296
+ }
297
+ }
298
+ if let validationError = error as? SniConnectValidation.ValidationError {
299
+ switch validationError {
300
+ case .resourceLimit(let details):
301
+ return .resourceLimit(details)
302
+ default:
303
+ break
304
+ }
305
+ }
306
+
127
307
  let nsError = error as NSError
128
308
 
129
309
  // Check for URL-related errors
@@ -153,19 +333,20 @@ final class SniConnectClient {
153
333
  }
154
334
  }
155
335
 
156
- private static let urlSession: URLSession = {
336
+ private static func makeURLSession(for key: SessionKey) throws -> ManagedSession {
157
337
  let configuration = URLSessionConfiguration.default
158
338
  configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
159
339
  configuration.urlCache = nil
160
340
  configuration.httpCookieStorage = nil
161
341
  configuration.httpShouldSetCookies = false
342
+ configuration.connectionProxyDictionary = [:]
162
343
  configuration.shouldUseExtendedBackgroundIdleMode = false
163
344
 
164
345
  let curlConfig = EMASCurlConfiguration.default()
165
- curlConfig.httpVersion = .HTTP2
346
+ curlConfig.httpVersion = .HTTP1
166
347
  curlConfig.connectTimeoutInterval = 2.5
167
- curlConfig.enableBuiltInGzip = true
168
- curlConfig.enableBuiltInRedirection = true
348
+ curlConfig.enableBuiltInGzip = false
349
+ curlConfig.enableBuiltInRedirection = false
169
350
  curlConfig.cacheEnabled = false
170
351
 
171
352
  // Enable full certificate validation for security.
@@ -174,165 +355,287 @@ final class SniConnectClient {
174
355
  // original hostname for SNI and certificate CN/SAN matching.
175
356
  curlConfig.certificateValidationEnabled = true
176
357
  curlConfig.domainNameVerificationEnabled = true
177
- curlConfig.dnsResolver = DNSResolver.self
358
+ curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass(
359
+ hostname: key.hostname,
360
+ ip: key.ip
361
+ )
178
362
 
179
363
  EMASCurlProtocol.install(into: configuration, with: curlConfig)
180
- return URLSession(configuration: configuration)
181
- }()
182
-
183
- @objc private final class DNSResolver: NSObject, EMASCurlProtocolDNSResolver {
184
- private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent)
185
- private static let cache = DNSCache()
186
-
187
- /// Thread-safe hostname -> IP pin with TTL.
188
- ///
189
- /// LIMITATION: EMASCurl only exposes a process-global DNS resolver
190
- /// (`setDNSResolver:`), which receives only the hostname. There is no
191
- /// per-request DNS API, so the pin is keyed by hostname and the most recent
192
- /// IP for a hostname wins. Concurrent requests to the SAME hostname targeting
193
- /// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal
194
- /// usage (one IP per hostname at a time) is unaffected.
195
- private final class DNSCache {
196
- private struct Entry {
197
- let ip: String
198
- let timestamp: TimeInterval
199
- }
200
-
201
- private var hostnameToEntry: [String: Entry] = [:]
202
- private let maxSize = 100
203
- private let ttl: TimeInterval = 300 // 5 minutes
364
+ SniConnectLog.info(SniConnectLog.event("sni_transport_config", [
365
+ ("hostname", key.hostname),
366
+ ("ipHash", SniConnectLog.shortHash(key.ip)),
367
+ ("ipFamily", SniConnectLog.ipFamily(key.ip)),
368
+ ("proxyMode", "no_proxy"),
369
+ ("pinnedResolver", true),
370
+ ("protocol", "http1"),
371
+ ("followRedirects", false),
372
+ ("cacheEnabled", false),
373
+ ]))
374
+ let resolverLease = SniConnectPinnedResolverLease(hostname: key.hostname, ip: key.ip)
375
+ let delegate = SniConnectSessionInvalidationDelegate(
376
+ hostname: key.hostname,
377
+ ip: key.ip,
378
+ resolverLease: resolverLease
379
+ )
380
+ return ManagedSession(
381
+ session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil),
382
+ resolverLease: resolverLease
383
+ )
384
+ }
204
385
 
205
- func get(_ domain: String) -> String? {
206
- let key = domain.lowercased()
207
- guard let entry = hostnameToEntry[key] else { return nil }
208
- if Date().timeIntervalSince1970 - entry.timestamp > ttl {
209
- return nil
386
+ private func sessionLease(for config: RequestConfig) async throws -> SessionLease {
387
+ let key = SessionKey(hostname: config.hostname.lowercased(), ip: config.ip)
388
+ while true {
389
+ let acquisition = try sessionsQueue.sync { () throws -> SessionAcquisition in
390
+ if let managedSession = sessionCache[key] {
391
+ markSessionUsed(key)
392
+ return .ready(retainSession(managedSession.session))
210
393
  }
211
- return entry.ip
212
- }
213
394
 
214
- func set(_ ip: String, for domain: String) {
215
- let key = domain.lowercased()
216
- // Evict the oldest entry when at capacity (and this is a new host).
217
- if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil {
218
- if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
219
- hostnameToEntry.removeValue(forKey: oldest)
220
- }
395
+ let pendingResolverSlots = evictSessionsIfNeeded(forPendingInsert: true)
396
+ if !pendingResolverSlots.isEmpty {
397
+ return .waitForResolverSlots(pendingResolverSlots)
221
398
  }
222
- hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970)
223
- }
224
399
 
225
- func clear() {
226
- hostnameToEntry.removeAll()
400
+ let managedSession = try Self.makeURLSession(for: key)
401
+ sessionCache[key] = managedSession
402
+ markSessionUsed(key)
403
+ return .ready(retainSession(managedSession.session))
227
404
  }
228
405
 
229
- func cleanExpired() {
230
- let now = Date().timeIntervalSince1970
231
- let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key }
232
- for key in expired {
233
- hostnameToEntry.removeValue(forKey: key)
406
+ switch acquisition {
407
+ case .ready(let lease):
408
+ return lease
409
+ case .waitForResolverSlots(let resolverLeases):
410
+ SniConnectLog.info(SniConnectLog.event("sni_resolver_slot_wait", [
411
+ ("hostname", key.hostname),
412
+ ("ipHash", SniConnectLog.shortHash(key.ip)),
413
+ ("waitCount", resolverLeases.count),
414
+ ]))
415
+ for resolverLease in resolverLeases {
416
+ try await resolverLease.waitUntilReleased()
234
417
  }
235
418
  }
236
419
  }
420
+ }
237
421
 
238
- @objc static func resolveDomain(_ domain: String) -> String? {
239
- var result: String?
240
- queue.sync {
241
- result = cache.get(domain)
242
- }
243
- return result
244
- }
422
+ private func retainSession(_ session: URLSession) -> SessionLease {
423
+ let sessionID = ObjectIdentifier(session)
424
+ activeSessionCounts[sessionID, default: 0] += 1
425
+ return SessionLease(session: session, sessionID: sessionID)
426
+ }
245
427
 
246
- static func setIP(_ ip: String, for host: String) {
247
- queue.sync(flags: .barrier) {
248
- cache.set(ip, for: host)
428
+ private func releaseSessionLease(_ lease: SessionLease) {
429
+ sessionsQueue.sync {
430
+ guard let count = activeSessionCounts[lease.sessionID] else {
431
+ return
432
+ }
433
+ if count > 1 {
434
+ activeSessionCounts[lease.sessionID] = count - 1
435
+ } else {
436
+ activeSessionCounts.removeValue(forKey: lease.sessionID)
249
437
  }
250
438
  }
439
+ }
440
+
441
+ private func markSessionUsed(_ key: SessionKey) {
442
+ sessionAccessOrder.removeAll { $0 == key }
443
+ sessionAccessOrder.append(key)
444
+ }
251
445
 
252
- /// Clear all DNS cache entries
253
- static func clearCache() {
254
- queue.sync(flags: .barrier) {
255
- cache.clear()
446
+ private func evictSessionsIfNeeded(forPendingInsert: Bool = false) -> [SniConnectPinnedResolverLease] {
447
+ var pendingResolverSlots: [SniConnectPinnedResolverLease] = []
448
+ let limit = forPendingInsert ? Self.maxCachedSessions - 1 : Self.maxCachedSessions
449
+ while sessionCache.count > limit, let evictedKey = sessionAccessOrder.first {
450
+ sessionAccessOrder.removeFirst()
451
+ if let managedSession = sessionCache.removeValue(forKey: evictedKey) {
452
+ if let pendingResolverSlot = invalidateSession(managedSession, for: evictedKey) {
453
+ pendingResolverSlots.append(pendingResolverSlot)
454
+ }
256
455
  }
456
+ SniConnectLog.info(SniConnectLog.event("sni_cache_evict", [
457
+ ("hostname", evictedKey.hostname),
458
+ ("ipHash", SniConnectLog.shortHash(evictedKey.ip)),
459
+ ("cacheSize", sessionCache.count),
460
+ ("limit", Self.maxCachedSessions),
461
+ ("reason", forPendingInsert ? "max_cached_sessions_pending_insert" : "max_cached_sessions"),
462
+ ]))
257
463
  }
464
+ return pendingResolverSlots
465
+ }
258
466
 
259
- /// Clean expired DNS cache entries
260
- static func cleanExpiredEntries() {
261
- queue.sync(flags: .barrier) {
262
- cache.cleanExpired()
263
- }
467
+ private func invalidateSession(
468
+ _ managedSession: ManagedSession,
469
+ for key: SessionKey
470
+ ) -> SniConnectPinnedResolverLease? {
471
+ let releaseResolverImmediately = activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
472
+ managedSession.session.finishTasksAndInvalidate()
473
+ if releaseResolverImmediately {
474
+ _ = managedSession.resolverLease.release()
475
+ return nil
264
476
  }
477
+ return managedSession.resolverLease
265
478
  }
266
479
 
267
- /// Clear all DNS cache entries
480
+ /// Drop cached sessions so future requests cannot reuse keep-alive connections
481
+ /// from a previously pinned destination. Idle sessions release their resolver
482
+ /// slot immediately; sessions with active requests release it when invalidation
483
+ /// completes.
268
484
  func clearDNSCache() {
269
- DNSResolver.clearCache()
270
- SniConnectLog.info("DNS cache cleared")
485
+ let invalidations = sessionsQueue.sync { () -> [SessionInvalidation] in
486
+ let invalidations = sessionCache.map { _, managedSession in
487
+ SessionInvalidation(
488
+ managedSession: managedSession,
489
+ releaseResolverImmediately: activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
490
+ )
491
+ }
492
+ sessionCache.removeAll()
493
+ sessionAccessOrder.removeAll()
494
+ return invalidations
495
+ }
496
+ var releasedResolverCount = 0
497
+ for invalidation in invalidations {
498
+ invalidation.managedSession.session.finishTasksAndInvalidate()
499
+ if invalidation.releaseResolverImmediately {
500
+ if invalidation.managedSession.resolverLease.release() {
501
+ releasedResolverCount += 1
502
+ }
503
+ }
504
+ }
505
+ SniConnectLog.info(SniConnectLog.event("sni_lifecycle", [
506
+ ("action", "clear_dns_cache"),
507
+ ("sessionCount", invalidations.count),
508
+ ("releasedResolverCount", releasedResolverCount),
509
+ ("success", true),
510
+ ]))
271
511
  }
272
512
 
273
513
  /// Cancel a request by ID
274
- func cancelRequest(requestId: String) {
275
- tasksQueue.async(flags: .barrier) { [weak self] in
276
- guard let task = self?.activeTasks[requestId] else {
277
- SniConnectLog.warn("No active request found with ID: \(requestId)")
278
- return
514
+ func cancelRequest(requestId: String) -> Bool {
515
+ return tasksQueue.sync(flags: .barrier) { [weak self] in
516
+ guard let self = self, let token = self.requestTokensById.removeValue(forKey: requestId),
517
+ let task = self.activeTasksByToken.removeValue(forKey: token) else {
518
+ SniConnectLog.warn(SniConnectLog.event("sni_cancel", [
519
+ ("requestIdHash", SniConnectLog.shortHash(requestId)),
520
+ ("success", false),
521
+ ]))
522
+ return false
279
523
  }
280
524
  task.cancel()
281
- self?.activeTasks.removeValue(forKey: requestId)
282
- SniConnectLog.info("Request cancelled: \(requestId)")
525
+ SniConnectLog.info(SniConnectLog.event("sni_cancel", [
526
+ ("requestIdHash", SniConnectLog.shortHash(requestId)),
527
+ ("success", true),
528
+ ]))
529
+ return true
283
530
  }
284
531
  }
285
532
 
286
533
  /// Cancel all active requests
287
534
  func cancelAllRequests() {
288
- tasksQueue.async(flags: .barrier) { [weak self] in
535
+ tasksQueue.sync(flags: .barrier) { [weak self] in
289
536
  guard let self = self else { return }
290
- let count = self.activeTasks.count
291
- for (_, task) in self.activeTasks {
537
+ let tasks = Array(self.activeTasksByToken.values)
538
+ for task in tasks {
292
539
  task.cancel()
293
540
  }
294
- self.activeTasks.removeAll()
295
- SniConnectLog.info("Cancelled \(count) active requests")
541
+ self.activeTasksByToken.removeAll()
542
+ self.requestTokensById.removeAll()
543
+ SniConnectLog.info(SniConnectLog.event("sni_cancel_all", [
544
+ ("cancelledCount", tasks.count),
545
+ ("success", true),
546
+ ]))
296
547
  }
297
548
  }
298
549
 
299
550
  /// Register an active task immediately after creation, before JS can cancel it.
300
- func registerTask(_ task: Task<Response, Error>, for requestId: String) {
551
+ func registerTask(_ task: Task<Response, Error>, for requestId: String?) -> UUID {
552
+ let token = UUID()
301
553
  tasksQueue.sync(flags: .barrier) { [weak self] in
302
- self?.activeTasks[requestId] = task
554
+ guard let self = self else { return }
555
+ if let requestId = requestId, let previousToken = self.requestTokensById[requestId],
556
+ let previousTask = self.activeTasksByToken.removeValue(forKey: previousToken) {
557
+ previousTask.cancel()
558
+ SniConnectLog.warn(SniConnectLog.event("sni_duplicate_request_id", [
559
+ ("requestIdHash", SniConnectLog.shortHash(requestId)),
560
+ ("action", "cancel_previous"),
561
+ ]))
562
+ }
563
+ self.activeTasksByToken[token] = task
564
+ if let requestId = requestId {
565
+ self.requestTokensById[requestId] = token
566
+ }
303
567
  }
568
+ return token
304
569
  }
305
570
 
306
571
  /// Unregister a completed or failed task
307
- private func unregisterTask(requestId: String?) {
308
- guard let requestId = requestId else { return }
572
+ func unregisterTask(requestId: String?, token: UUID) {
309
573
  tasksQueue.async(flags: .barrier) { [weak self] in
310
- self?.activeTasks.removeValue(forKey: requestId)
574
+ self?.activeTasksByToken.removeValue(forKey: token)
575
+ if let requestId = requestId, self?.requestTokensById[requestId] == token {
576
+ self?.requestTokensById.removeValue(forKey: requestId)
577
+ }
311
578
  }
312
579
  }
313
580
 
314
581
  func performRequest(config: RequestConfig) async throws -> Response {
582
+ let startedAt = Date()
315
583
  // Check if task is cancelled
316
584
  try Task.checkCancellation()
317
585
 
318
- defer {
319
- unregisterTask(requestId: config.requestId)
320
- }
321
-
322
586
  // Validate every caller-controlled field before it reaches the network layer.
323
587
  let method: String
324
588
  let normalizedPath: String
589
+ let normalizedHeaders: [String: String]
325
590
  do {
591
+ try SniConnectValidation.validateRequestId(config.requestId)
326
592
  try SniConnectValidation.validatePublicIP(config.ip)
327
593
  try SniConnectValidation.validateHostname(config.hostname)
328
- try SniConnectValidation.validateHeaders(config.headers)
594
+ normalizedHeaders = try SniConnectValidation.normalizeHeaders(config.headers)
329
595
  method = try SniConnectValidation.normalizeMethod(config.method)
330
596
  normalizedPath = try SniConnectValidation.normalizePath(config.path)
597
+ try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
598
+ try SniConnectValidation.validateBody(config.body)
599
+ try SniConnectValidation.validateMethodBody(method: method, body: config.body)
331
600
  } catch {
332
- throw SniConnectError.invalidConfig("\(error)")
601
+ let sniError = SniConnectError.invalidConfig("\(error)")
602
+ SniConnectLog.error(SniConnectLog.event("sni_request_result", [
603
+ ("result", "error"),
604
+ ("code", sniError.code),
605
+ ("nativeErrorClass", String(describing: type(of: error))),
606
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
607
+ ("hostname", config.hostname.lowercased()),
608
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
609
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
610
+ ("method", config.method.uppercased()),
611
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
612
+ ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
613
+ ]))
614
+ throw sniError
333
615
  }
334
616
 
335
- DNSResolver.setIP(config.ip, for: config.hostname)
617
+ let requestSlot: SniConnectRequestLimiter.Token
618
+ do {
619
+ requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip)
620
+ } catch {
621
+ let sniError = SniConnectError.resourceLimit("\(error)")
622
+ SniConnectLog.error(SniConnectLog.event("sni_request_result", [
623
+ ("result", "error"),
624
+ ("code", sniError.code),
625
+ ("nativeErrorClass", String(describing: type(of: error))),
626
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
627
+ ("hostname", config.hostname.lowercased()),
628
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
629
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
630
+ ("method", method),
631
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
632
+ ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
633
+ ]))
634
+ throw sniError
635
+ }
636
+ defer {
637
+ requestSlot.release()
638
+ }
336
639
 
337
640
  let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
338
641
 
@@ -343,20 +646,17 @@ final class SniConnectClient {
343
646
  let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
344
647
  let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
345
648
 
346
- // Set total request timeout
649
+ // URLRequest.timeoutInterval is not a full request deadline. Keep it aligned
650
+ // with the caller timeout as a transport guard; the wall-clock deadline below
651
+ // enforces total request time including response body reads.
347
652
  mutableRequest.timeoutInterval = totalTimeoutSeconds
348
653
  mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData
349
654
 
350
- // Explicitly set Host header for SNI
351
- mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
352
-
353
- for (key, value) in config.headers {
354
- if key.caseInsensitiveCompare("host") == .orderedSame {
355
- // Host header is already set above, skip duplicate
356
- continue
357
- }
655
+ for (key, value) in normalizedHeaders {
358
656
  mutableRequest.setValue(value, forHTTPHeaderField: key)
359
657
  }
658
+ mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
659
+ mutableRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
360
660
 
361
661
  if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
362
662
  mutableRequest.httpBody = bodyData
@@ -366,39 +666,95 @@ final class SniConnectClient {
366
666
  EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds)
367
667
  let request = mutableRequest as URLRequest
368
668
 
669
+ SniConnectLog.info(SniConnectLog.event("sni_request_start", [
670
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
671
+ ("hostname", config.hostname.lowercased()),
672
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
673
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
674
+ ("method", method),
675
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
676
+ ("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
677
+ ("headerCount", normalizedHeaders.count),
678
+ ("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
679
+ ]))
680
+
369
681
  do {
370
- let (data, response) = try await Self.urlSession.data(for: request)
371
- guard let httpResponse = response as? HTTPURLResponse else {
372
- let errorMsg = "Invalid HTTP response type"
373
- SniConnectLog.error(errorMsg)
374
- throw SniConnectError.invalidConfig(errorMsg)
375
- }
682
+ return try await SniConnectWallClockDeadline.run(timeoutMilliseconds: config.effectiveTotalTimeout) {
683
+ let lease = try await sessionLease(for: config)
684
+ defer {
685
+ releaseSessionLease(lease)
686
+ }
687
+ let (bodyBytes, response) = try await lease.session.bytes(for: request)
688
+ guard let httpResponse = response as? HTTPURLResponse else {
689
+ throw SniConnectError.responseProcessingFailed("Invalid HTTP response type")
690
+ }
376
691
 
377
- let status = httpResponse.statusCode
378
- let parsedData = Self.parseResponseData(data)
379
- let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse)
380
- let statusText = HTTPURLResponse.localizedString(forStatusCode: status)
692
+ let status = httpResponse.statusCode
693
+ let data = try await Self.readResponseBody(
694
+ bodyBytes,
695
+ expectedLength: httpResponse.expectedContentLength
696
+ )
697
+ let responseText = SniConnectResponseText.decode(data)
698
+ let headerMaps = SniConnectResponseHeaders.make(from: httpResponse.allHeaderFields)
699
+ let statusText = ""
700
+
701
+ // 4xx/5xx are returned to JS as a normal response (the caller inspects
702
+ // `status`); we only record it for diagnostics.
703
+ let resultLog = SniConnectLog.event("sni_request_result", [
704
+ ("result", "response"),
705
+ ("status", status),
706
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
707
+ ("hostname", config.hostname.lowercased()),
708
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
709
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
710
+ ("method", method),
711
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
712
+ ("responseBytes", data.count),
713
+ ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
714
+ ])
715
+ if status >= 400 {
716
+ SniConnectLog.warn(resultLog)
717
+ } else {
718
+ SniConnectLog.info(resultLog)
719
+ }
381
720
 
382
- // 4xx/5xx are returned to JS as a normal response (the caller inspects
383
- // `status`); we only record it for diagnostics.
384
- if status >= 400 {
385
- SniConnectLog.warn("HTTP \(status) for \(config.hostname)")
721
+ return Response(
722
+ data: responseText,
723
+ status: status,
724
+ statusText: statusText,
725
+ headers: headerMaps.singleValueHeaders,
726
+ multiValueHeaders: headerMaps.multiValueHeaders
727
+ )
386
728
  }
387
-
388
- return Response(
389
- data: parsedData,
390
- status: status,
391
- statusText: statusText,
392
- headers: headers,
393
- multiValueHeaders: multiValueHeaders
394
- )
395
729
  } catch let error as SniConnectError {
396
- SniConnectLog.error("[\(error.code)] \(error.message)")
730
+ SniConnectLog.error(SniConnectLog.event("sni_request_result", [
731
+ ("result", "error"),
732
+ ("code", error.code),
733
+ ("nativeErrorClass", String(describing: type(of: error))),
734
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
735
+ ("hostname", config.hostname.lowercased()),
736
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
737
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
738
+ ("method", method),
739
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
740
+ ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
741
+ ]))
397
742
  throw error
398
743
  } catch {
399
744
  // Convert generic errors to specific SniConnectError types
400
745
  let sniError = SniConnectError.from(error)
401
- SniConnectLog.error("[\(sniError.code)] \(sniError.message)")
746
+ SniConnectLog.error(SniConnectLog.event("sni_request_result", [
747
+ ("result", "error"),
748
+ ("code", sniError.code),
749
+ ("nativeErrorClass", String(describing: type(of: error))),
750
+ ("requestIdHash", SniConnectLog.shortHash(config.requestId)),
751
+ ("hostname", config.hostname.lowercased()),
752
+ ("ipHash", SniConnectLog.shortHash(config.ip)),
753
+ ("ipFamily", SniConnectLog.ipFamily(config.ip)),
754
+ ("method", method),
755
+ ("timeoutMs", Int(config.effectiveTotalTimeout)),
756
+ ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
757
+ ]))
402
758
  throw sniError
403
759
  }
404
760
  }
@@ -425,48 +781,29 @@ final class SniConnectClient {
425
781
  return url
426
782
  }
427
783
 
428
- private static func parseResponseData(_ data: Data) -> Any {
429
- guard !data.isEmpty else {
430
- return ""
784
+ private static func readResponseBody(
785
+ _ bodyBytes: URLSession.AsyncBytes,
786
+ expectedLength: Int64
787
+ ) async throws -> Data {
788
+ let maxBytes = SniConnectValidation.maxResponseBodyBytes
789
+ if expectedLength > Int64(maxBytes) {
790
+ throw SniConnectError.responseProcessingFailed("Response body too large")
431
791
  }
432
792
 
433
- if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
434
- return jsonObject
793
+ var data = Data()
794
+ if expectedLength > 0 {
795
+ data.reserveCapacity(min(Int(expectedLength), maxBytes))
435
796
  }
436
797
 
437
- if let text = String(data: data, encoding: .utf8) {
438
- return text
439
- }
440
-
441
- return data.base64EncodedString()
442
- }
443
-
444
- /// Extract headers from HTTP response
445
- /// Returns both single-value headers (for backward compatibility) and multi-value headers
446
- private static func extractHeaders(from response: HTTPURLResponse) -> ([String: String], [String: [String]]) {
447
- var singleValueHeaders: [String: String] = [:]
448
- var multiValueHeaders: [String: [String]] = [:]
449
-
450
- // Group headers by normalized key (lowercase)
451
- var headerGroups: [String: [String]] = [:]
452
-
453
- for (key, value) in response.allHeaderFields {
454
- let headerKey = String(describing: key).lowercased()
455
- let headerValue = String(describing: value)
456
-
457
- if headerGroups[headerKey] == nil {
458
- headerGroups[headerKey] = []
798
+ for try await byte in bodyBytes {
799
+ try Task.checkCancellation()
800
+ if data.count >= maxBytes {
801
+ throw SniConnectError.responseProcessingFailed("Response body too large")
459
802
  }
460
- headerGroups[headerKey]?.append(headerValue)
803
+ var nextByte = byte
804
+ data.append(&nextByte, count: 1)
461
805
  }
462
-
463
- // Process grouped headers
464
- for (key, values) in headerGroups {
465
- // For backward compatibility, single-value headers use the last value
466
- singleValueHeaders[key] = values.last
467
- multiValueHeaders[key] = values
468
- }
469
-
470
- return (singleValueHeaders, multiValueHeaders)
806
+ return data
471
807
  }
808
+
472
809
  }