@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.
@@ -28,6 +28,14 @@ class SniConnectValidationTest {
28
28
  assertEquals("/v1?q=1", SniConnectValidation.normalizePath("v1?q=1"))
29
29
  }
30
30
 
31
+ @Test
32
+ fun canonicalizesEquivalentPublicIpLiterals() {
33
+ assertEquals(
34
+ SniConnectValidation.canonicalizePublicIp("2001:4860:4860::8888"),
35
+ SniConnectValidation.canonicalizePublicIp("2001:4860:4860:0:0:0:0:8888"),
36
+ )
37
+ }
38
+
31
39
  @Test
32
40
  fun rejectsIpLiteralHostnames() {
33
41
  assertValidationFails { SniConnectValidation.validateHostname("93.184.216.34") }
@@ -57,6 +65,7 @@ class SniConnectValidationTest {
57
65
  "example.com",
58
66
  "93.184.216.34:443",
59
67
  " 93.184.216.34",
68
+ "093.184.216.034",
60
69
  "10.0.0.1",
61
70
  "127.0.0.1",
62
71
  "100.64.0.1",
@@ -117,8 +126,10 @@ class SniConnectValidationTest {
117
126
 
118
127
  @Test
119
128
  fun enforcesRequestIdTimeoutAndBodyLimits() {
129
+ SniConnectValidation.validateRequestId("界".repeat(42))
120
130
  assertValidationFails { SniConnectValidation.validateRequestId("") }
121
131
  assertValidationFails { SniConnectValidation.validateRequestId("x".repeat(129)) }
132
+ assertValidationFails { SniConnectValidation.validateRequestId("界".repeat(43)) }
122
133
  assertValidationFails { SniConnectValidation.validateRequestId("req\n1") }
123
134
  assertValidationFails { SniConnectValidation.validateTimeout(0) }
124
135
  assertValidationFails { SniConnectValidation.validateTimeout(120_001) }
@@ -184,32 +195,6 @@ class SniConnectValidationTest {
184
195
  SniConnectValidation.validateMethodBody("OPTIONS", null)
185
196
  }
186
197
 
187
- @Test
188
- fun requestLimiterEnforcesGlobalAndPerDestinationLimits() {
189
- val limiter = SniConnectRequestLimiter(
190
- maxActiveRequests = 2,
191
- maxActiveRequestsPerPair = 1,
192
- )
193
-
194
- val firstToken = limiter.acquire("Example.com", "93.184.216.34")
195
- assertValidationFails {
196
- limiter.acquire("example.com", "93.184.216.34")
197
- }
198
-
199
- val secondToken = limiter.acquire("example.com", "93.184.216.35")
200
- assertValidationFails {
201
- limiter.acquire("example.net", "93.184.216.36")
202
- }
203
-
204
- firstToken.release()
205
- val replacementToken = limiter.acquire("example.com", "93.184.216.34")
206
- firstToken.release()
207
- secondToken.release()
208
- replacementToken.release()
209
-
210
- assertTrue(true)
211
- }
212
-
213
198
  @Test
214
199
  fun classifiesSecurityFailuresAsFailClosedErrorCodes() {
215
200
  assertEquals(
@@ -244,6 +229,18 @@ class SniConnectValidationTest {
244
229
  "SNI_REQUEST_FAILED",
245
230
  classifySniFailureCode(IOException("connection reset")),
246
231
  )
232
+ assertEquals(
233
+ "SNI_CANCELLED",
234
+ classifySniResponseFailureCode(IOException("cancelled"), true),
235
+ )
236
+ assertEquals(
237
+ "SNI_REQUEST_TIMEOUT",
238
+ classifySniResponseFailureCode(SocketTimeoutException("timeout"), false),
239
+ )
240
+ assertEquals(
241
+ "SNI_RESPONSE_FAILED",
242
+ classifySniResponseFailureCode(IOException("bad body"), false),
243
+ )
247
244
  }
248
245
 
249
246
  private fun assertValidationFails(block: () -> Unit) {
package/ios/SniConnect.mm CHANGED
@@ -1,4 +1,5 @@
1
1
  #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTInvalidating.h>
2
3
  #import <React/RCTUtils.h>
3
4
 
4
5
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -8,6 +9,7 @@
8
9
  // Forward declaration of the Swift implementation
9
10
  @interface SniConnectImpl : NSObject
10
11
  - (instancetype)init;
12
+ - (void)invalidate;
11
13
  - (void)request:(NSDictionary *)config
12
14
  resolve:(RCTPromiseResolveBlock)resolve
13
15
  reject:(RCTPromiseRejectBlock)reject;
@@ -18,6 +20,9 @@
18
20
  reject:(RCTPromiseRejectBlock)reject;
19
21
  - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve
20
22
  reject:(RCTPromiseRejectBlock)reject;
23
+ - (void)getDebugSnapshot:(NSDictionary *)target
24
+ resolve:(RCTPromiseResolveBlock)resolve
25
+ reject:(RCTPromiseRejectBlock)reject;
21
26
  - (void)isProxyActiveForUrl:(NSString *)url
22
27
  resolve:(RCTPromiseResolveBlock)resolve
23
28
  reject:(RCTPromiseRejectBlock)reject;
@@ -25,9 +30,9 @@
25
30
 
26
31
  @interface SniConnect : NSObject
27
32
  #ifdef RCT_NEW_ARCH_ENABLED
28
- <NativeSniConnectSpec>
33
+ <NativeSniConnectSpec, RCTInvalidating>
29
34
  #else
30
- <RCTBridgeModule>
35
+ <RCTBridgeModule, RCTInvalidating>
31
36
  #endif
32
37
  @end
33
38
 
@@ -48,6 +53,10 @@ RCT_EXPORT_MODULE(SniConnect)
48
53
  return self;
49
54
  }
50
55
 
56
+ - (void)invalidate {
57
+ [_implementation invalidate];
58
+ }
59
+
51
60
  #ifdef RCT_NEW_ARCH_ENABLED
52
61
  // TurboModule interface implementation
53
62
  - (void)request:(JS::NativeSniConnect::NativeSniConnectRequest &)config
@@ -89,6 +98,16 @@ RCT_EXPORT_MODULE(SniConnect)
89
98
  [_implementation clearDNSCache:resolve reject:reject];
90
99
  }
91
100
 
101
+ - (void)getDebugSnapshot:(JS::NativeSniConnect::SniConnectDebugTarget &)target
102
+ resolve:(RCTPromiseResolveBlock)resolve
103
+ reject:(RCTPromiseRejectBlock)reject {
104
+ NSDictionary *targetDict = @{
105
+ @"ip": target.ip() ?: @"",
106
+ @"hostname": target.hostname() ?: @"",
107
+ };
108
+ [_implementation getDebugSnapshot:targetDict resolve:resolve reject:reject];
109
+ }
110
+
92
111
  - (void)isProxyActiveForUrl:(NSString *)url
93
112
  resolve:(RCTPromiseResolveBlock)resolve
94
113
  reject:(RCTPromiseRejectBlock)reject {
@@ -124,6 +143,12 @@ RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver
124
143
  [_implementation clearDNSCache:resolver reject:rejecter];
125
144
  }
126
145
 
146
+ RCT_EXPORT_METHOD(getDebugSnapshot:(NSDictionary *)target
147
+ resolver:(RCTPromiseResolveBlock)resolver
148
+ rejecter:(RCTPromiseRejectBlock)rejecter) {
149
+ [_implementation getDebugSnapshot:target resolve:resolver reject:rejecter];
150
+ }
151
+
127
152
  RCT_EXPORT_METHOD(isProxyActiveForUrl:(NSString *)url
128
153
  resolver:(RCTPromiseResolveBlock)resolver
129
154
  rejecter:(RCTPromiseRejectBlock)rejecter) {
@@ -16,6 +16,11 @@ final class SniConnectImpl: NSObject {
16
16
  super.init()
17
17
  }
18
18
 
19
+ @objc
20
+ public func invalidate() {
21
+ client.invalidate()
22
+ }
23
+
19
24
  @objc
20
25
  public func request(
21
26
  _ config: NSDictionary,
@@ -133,6 +138,35 @@ final class SniConnectImpl: NSObject {
133
138
  resolve(["success": true])
134
139
  }
135
140
 
141
+ @objc
142
+ public func getDebugSnapshot(
143
+ _ target: NSDictionary,
144
+ resolve: @escaping RCTPromiseResolveBlock,
145
+ reject: @escaping RCTPromiseRejectBlock
146
+ ) {
147
+ do {
148
+ guard let ip = target["ip"] as? String, !ip.isEmpty else {
149
+ throw SniConnectError.invalidConfig("Missing ip")
150
+ }
151
+ guard let hostname = target["hostname"] as? String, !hostname.isEmpty else {
152
+ throw SniConnectError.invalidConfig("Missing hostname")
153
+ }
154
+ try SniConnectValidation.validatePublicIP(ip)
155
+ try SniConnectValidation.validateHostname(hostname)
156
+ let snapshot = client.debugSnapshot(hostname: hostname, ip: ip)
157
+ resolve([
158
+ "activeRequests": snapshot.activeRequests,
159
+ "activeRequestsForPair": snapshot.activeRequestsForPair,
160
+ "pendingRequests": snapshot.pendingRequests,
161
+ "pendingRequestsForPair": snapshot.pendingRequestsForPair,
162
+ "activeRequestIdsForPair": snapshot.activeRequestIdsForPair,
163
+ "pendingRequestIdsForPair": snapshot.pendingRequestIdsForPair,
164
+ ])
165
+ } catch {
166
+ reject("SNI_INVALID_CONFIG", "\(error)", error)
167
+ }
168
+ }
169
+
136
170
  @objc
137
171
  public func isProxyActiveForUrl(
138
172
  _ url: String,
@@ -83,7 +83,7 @@ final class SniConnectPinnedResolverLease {
83
83
  }
84
84
  }
85
85
 
86
- private var isReleased: Bool {
86
+ var isReleased: Bool {
87
87
  queue.sync {
88
88
  didRelease
89
89
  }
@@ -186,8 +186,9 @@ final class SniConnectClient {
186
186
  // token so cancelAllRequests() also covers requests that do not have a requestId.
187
187
  private var activeTasksByToken: [UUID: Task<Response, Error>] = [:]
188
188
  private var requestTokensById: [String: UUID] = [:]
189
+ private var invalidated = false
189
190
  private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
190
- private let requestLimiter = SniConnectRequestLimiter()
191
+ private let requestLimiter = SniConnectRequestLimiter.shared
191
192
 
192
193
  private struct SessionKey: Hashable {
193
194
  let hostname: String
@@ -215,10 +216,13 @@ final class SniConnectClient {
215
216
  }
216
217
 
217
218
  private static let maxCachedSessions = SniConnectPinnedResolverRegistry.defaultMaxEntries
218
- private var sessionCache: [SessionKey: ManagedSession] = [:]
219
- private var sessionAccessOrder: [SessionKey] = []
220
- private var activeSessionCounts: [ObjectIdentifier: Int] = [:]
221
- private let sessionsQueue = DispatchQueue(label: "com.onekey.sni.connect.sessions")
219
+ // The resolver registry is process-shared, so the cache and its slot accounting
220
+ // must have the same owner across every RN runtime.
221
+ private static var sessionCache: [SessionKey: ManagedSession] = [:]
222
+ private static var sessionAccessOrder: [SessionKey] = []
223
+ private static var activeSessionCounts: [ObjectIdentifier: Int] = [:]
224
+ private static var pendingResolverSlots: [SniConnectPinnedResolverLease] = []
225
+ private static let sessionsQueue = DispatchQueue(label: "com.onekey.sni.connect.sessions")
222
226
 
223
227
  // Token for the memory-warning observer (block-based observers are not removed
224
228
  // by `removeObserver(self)`, so the token must be retained and removed explicitly).
@@ -428,21 +432,26 @@ final class SniConnectClient {
428
432
  private func sessionLease(for config: RequestConfig) async throws -> SessionLease {
429
433
  let key = SessionKey(hostname: config.hostname.lowercased(), ip: config.ip)
430
434
  while true {
431
- let acquisition = try sessionsQueue.sync { () throws -> SessionAcquisition in
432
- if let managedSession = sessionCache[key] {
433
- markSessionUsed(key)
434
- return .ready(retainSession(managedSession.session))
435
+ let acquisition = try Self.sessionsQueue.sync { () throws -> SessionAcquisition in
436
+ Self.pendingResolverSlots.removeAll { $0.isReleased }
437
+ if let managedSession = Self.sessionCache[key] {
438
+ Self.markSessionUsed(key)
439
+ return .ready(Self.retainSession(managedSession.session))
440
+ }
441
+ if !Self.pendingResolverSlots.isEmpty {
442
+ return .waitForResolverSlots(Self.pendingResolverSlots)
435
443
  }
436
444
 
437
- let pendingResolverSlots = evictSessionsIfNeeded(forPendingInsert: true)
445
+ let pendingResolverSlots = Self.evictSessionsIfNeeded(forPendingInsert: true)
438
446
  if !pendingResolverSlots.isEmpty {
447
+ Self.pendingResolverSlots.append(contentsOf: pendingResolverSlots)
439
448
  return .waitForResolverSlots(pendingResolverSlots)
440
449
  }
441
450
 
442
451
  let managedSession = try Self.makeURLSession(for: key)
443
- sessionCache[key] = managedSession
444
- markSessionUsed(key)
445
- return .ready(retainSession(managedSession.session))
452
+ Self.sessionCache[key] = managedSession
453
+ Self.markSessionUsed(key)
454
+ return .ready(Self.retainSession(managedSession.session))
446
455
  }
447
456
 
448
457
  switch acquisition {
@@ -457,35 +466,40 @@ final class SniConnectClient {
457
466
  for resolverLease in resolverLeases {
458
467
  try await resolverLease.waitUntilReleased()
459
468
  }
469
+ Self.sessionsQueue.sync {
470
+ Self.pendingResolverSlots.removeAll { $0.isReleased }
471
+ }
460
472
  }
461
473
  }
462
474
  }
463
475
 
464
- private func retainSession(_ session: URLSession) -> SessionLease {
476
+ private static func retainSession(_ session: URLSession) -> SessionLease {
465
477
  let sessionID = ObjectIdentifier(session)
466
478
  activeSessionCounts[sessionID, default: 0] += 1
467
479
  return SessionLease(session: session, sessionID: sessionID)
468
480
  }
469
481
 
470
482
  private func releaseSessionLease(_ lease: SessionLease) {
471
- sessionsQueue.sync {
472
- guard let count = activeSessionCounts[lease.sessionID] else {
483
+ Self.sessionsQueue.sync {
484
+ guard let count = Self.activeSessionCounts[lease.sessionID] else {
473
485
  return
474
486
  }
475
487
  if count > 1 {
476
- activeSessionCounts[lease.sessionID] = count - 1
488
+ Self.activeSessionCounts[lease.sessionID] = count - 1
477
489
  } else {
478
- activeSessionCounts.removeValue(forKey: lease.sessionID)
490
+ Self.activeSessionCounts.removeValue(forKey: lease.sessionID)
479
491
  }
480
492
  }
481
493
  }
482
494
 
483
- private func markSessionUsed(_ key: SessionKey) {
495
+ private static func markSessionUsed(_ key: SessionKey) {
484
496
  sessionAccessOrder.removeAll { $0 == key }
485
497
  sessionAccessOrder.append(key)
486
498
  }
487
499
 
488
- private func evictSessionsIfNeeded(forPendingInsert: Bool = false) -> [SniConnectPinnedResolverLease] {
500
+ private static func evictSessionsIfNeeded(
501
+ forPendingInsert: Bool = false
502
+ ) -> [SniConnectPinnedResolverLease] {
489
503
  var pendingResolverSlots: [SniConnectPinnedResolverLease] = []
490
504
  let limit = forPendingInsert ? Self.maxCachedSessions - 1 : Self.maxCachedSessions
491
505
  while sessionCache.count > limit, let evictedKey = sessionAccessOrder.first {
@@ -506,7 +520,7 @@ final class SniConnectClient {
506
520
  return pendingResolverSlots
507
521
  }
508
522
 
509
- private func invalidateSession(
523
+ private static func invalidateSession(
510
524
  _ managedSession: ManagedSession,
511
525
  for key: SessionKey
512
526
  ) -> SniConnectPinnedResolverLease? {
@@ -524,15 +538,21 @@ final class SniConnectClient {
524
538
  /// slot immediately; sessions with active requests release it when invalidation
525
539
  /// completes.
526
540
  func clearDNSCache() {
527
- let invalidations = sessionsQueue.sync { () -> [SessionInvalidation] in
528
- let invalidations = sessionCache.map { _, managedSession in
541
+ let invalidations = Self.sessionsQueue.sync { () -> [SessionInvalidation] in
542
+ let invalidations = Self.sessionCache.map { _, managedSession in
529
543
  SessionInvalidation(
530
544
  managedSession: managedSession,
531
- releaseResolverImmediately: activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
545
+ releaseResolverImmediately:
546
+ Self.activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
532
547
  )
533
548
  }
534
- sessionCache.removeAll()
535
- sessionAccessOrder.removeAll()
549
+ Self.sessionCache.removeAll()
550
+ Self.sessionAccessOrder.removeAll()
551
+ // Block new cache inserts until every registry entry removed by this
552
+ // process-wide clear has actually released its resolver slot.
553
+ Self.pendingResolverSlots.append(contentsOf: invalidations.map {
554
+ $0.managedSession.resolverLease
555
+ })
536
556
  return invalidations
537
557
  }
538
558
  var releasedResolverCount = 0
@@ -552,6 +572,10 @@ final class SniConnectClient {
552
572
  ]))
553
573
  }
554
574
 
575
+ func debugSnapshot(hostname: String, ip: String) -> SniConnectRequestLimiter.Snapshot {
576
+ requestLimiter.snapshot(hostname: hostname, ip: ip)
577
+ }
578
+
555
579
  /// Cancel a request by ID
556
580
  func cancelRequest(requestId: String) -> Bool {
557
581
  return tasksQueue.sync(flags: .barrier) { [weak self] in
@@ -574,15 +598,27 @@ final class SniConnectClient {
574
598
 
575
599
  /// Cancel all active requests
576
600
  func cancelAllRequests() {
601
+ cancelOwnedRequests(markInvalidated: false, logAction: "sni_cancel_all")
602
+ }
603
+
604
+ /// Permanently stop this runtime's client and cancel work racing with teardown.
605
+ func invalidate() {
606
+ cancelOwnedRequests(markInvalidated: true, logAction: "sni_runtime_invalidate")
607
+ }
608
+
609
+ private func cancelOwnedRequests(markInvalidated: Bool, logAction: String) {
577
610
  tasksQueue.sync(flags: .barrier) { [weak self] in
578
611
  guard let self = self else { return }
612
+ if markInvalidated {
613
+ self.invalidated = true
614
+ }
579
615
  let tasks = Array(self.activeTasksByToken.values)
580
616
  for task in tasks {
581
617
  task.cancel()
582
618
  }
583
619
  self.activeTasksByToken.removeAll()
584
620
  self.requestTokensById.removeAll()
585
- SniConnectLog.info(SniConnectLog.event("sni_cancel_all", [
621
+ SniConnectLog.info(SniConnectLog.event(logAction, [
586
622
  ("cancelledCount", tasks.count),
587
623
  ("success", true),
588
624
  ]))
@@ -592,8 +628,13 @@ final class SniConnectClient {
592
628
  /// Register an active task immediately after creation, before JS can cancel it.
593
629
  func registerTask(_ task: Task<Response, Error>, for requestId: String?) -> UUID {
594
630
  let token = UUID()
631
+ var shouldCancel = false
595
632
  tasksQueue.sync(flags: .barrier) { [weak self] in
596
633
  guard let self = self else { return }
634
+ if self.invalidated {
635
+ shouldCancel = true
636
+ return
637
+ }
597
638
  if let requestId = requestId, let previousToken = self.requestTokensById[requestId],
598
639
  let previousTask = self.activeTasksByToken.removeValue(forKey: previousToken) {
599
640
  previousTask.cancel()
@@ -607,6 +648,9 @@ final class SniConnectClient {
607
648
  self.requestTokensById[requestId] = token
608
649
  }
609
650
  }
651
+ if shouldCancel {
652
+ task.cancel()
653
+ }
610
654
  return token
611
655
  }
612
656
 
@@ -656,11 +700,24 @@ final class SniConnectClient {
656
700
  throw sniError
657
701
  }
658
702
 
703
+ let deadline = SniConnectWallClockDeadline.makeDeadline(
704
+ timeoutMilliseconds: config.effectiveTotalTimeout
705
+ )
659
706
  let requestSlot: SniConnectRequestLimiter.Token
660
707
  do {
661
- requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip)
708
+ // Admission wait and transport share one total wall-clock deadline.
709
+ let limiter = requestLimiter
710
+ requestSlot = try await SniConnectWallClockDeadline.run(
711
+ until: deadline
712
+ ) {
713
+ try await limiter.acquire(
714
+ hostname: config.hostname,
715
+ ip: config.ip,
716
+ requestId: config.requestId
717
+ )
718
+ }
662
719
  } catch {
663
- let sniError = SniConnectError.resourceLimit("\(error)")
720
+ let sniError = SniConnectError.from(error)
664
721
  SniConnectLog.error(SniConnectLog.event("sni_request_result", [
665
722
  ("result", "error"),
666
723
  ("code", sniError.code),
@@ -678,6 +735,14 @@ final class SniConnectClient {
678
735
  defer {
679
736
  requestSlot.release()
680
737
  }
738
+ try Task.checkCancellation()
739
+
740
+ let queueWaitMilliseconds = SniConnectLog.elapsedMs(since: startedAt)
741
+ let remainingTimeoutMilliseconds =
742
+ SniConnectWallClockDeadline.remainingMilliseconds(until: deadline)
743
+ guard remainingTimeoutMilliseconds > 0 else {
744
+ throw SniConnectError.requestTimeout
745
+ }
681
746
 
682
747
  let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
683
748
 
@@ -685,8 +750,9 @@ final class SniConnectClient {
685
750
  mutableRequest.httpMethod = method
686
751
 
687
752
  // Convert milliseconds to seconds for timeout values
688
- let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
689
- let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
753
+ let totalTimeoutSeconds = remainingTimeoutMilliseconds / 1000.0
754
+ let connectTimeoutSeconds =
755
+ min(config.effectiveConnectTimeout, remainingTimeoutMilliseconds) / 1000.0
690
756
 
691
757
  // URLRequest.timeoutInterval is not a full request deadline. Keep it aligned
692
758
  // with the caller timeout as a transport guard; the wall-clock deadline below
@@ -716,12 +782,15 @@ final class SniConnectClient {
716
782
  ("method", method),
717
783
  ("timeoutMs", Int(config.effectiveTotalTimeout)),
718
784
  ("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
785
+ ("queueWaitMs", queueWaitMilliseconds),
719
786
  ("headerCount", normalizedHeaders.count),
720
787
  ("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
721
788
  ]))
722
789
 
723
790
  do {
724
- return try await SniConnectWallClockDeadline.run(timeoutMilliseconds: config.effectiveTotalTimeout) {
791
+ return try await SniConnectWallClockDeadline.run(
792
+ until: deadline
793
+ ) {
725
794
  let lease = try await self.sessionLease(for: config)
726
795
  defer {
727
796
  self.releaseSessionLease(lease)
@@ -160,11 +160,37 @@ enum SniConnectTimeout: Error, Equatable {
160
160
  }
161
161
 
162
162
  enum SniConnectWallClockDeadline {
163
+ struct Deadline: Sendable {
164
+ fileprivate let uptimeNanoseconds: UInt64
165
+ }
166
+
167
+ static func makeDeadline(timeoutMilliseconds: TimeInterval) -> Deadline {
168
+ let now = DispatchTime.now().uptimeNanoseconds
169
+ let (deadline, overflow) = now.addingReportingOverflow(
170
+ timeoutNanoseconds(milliseconds: timeoutMilliseconds)
171
+ )
172
+ return Deadline(uptimeNanoseconds: overflow ? UInt64.max : deadline)
173
+ }
174
+
175
+ static func remainingMilliseconds(until deadline: Deadline) -> TimeInterval {
176
+ Double(remainingNanoseconds(until: deadline)) / 1_000_000.0
177
+ }
178
+
163
179
  static func run<T>(
164
180
  timeoutMilliseconds: TimeInterval,
165
181
  operation: @escaping @Sendable () async throws -> T
166
182
  ) async throws -> T {
167
- let timeoutNanoseconds = UInt64(max(1.0, timeoutMilliseconds) * 1_000_000.0)
183
+ try await run(
184
+ until: makeDeadline(timeoutMilliseconds: timeoutMilliseconds),
185
+ operation: operation
186
+ )
187
+ }
188
+
189
+ static func run<T>(
190
+ until deadline: Deadline,
191
+ operation: @escaping @Sendable () async throws -> T
192
+ ) async throws -> T {
193
+ let timeoutNanoseconds = remainingNanoseconds(until: deadline)
168
194
  return try await withThrowingTaskGroup(of: T.self) { group in
169
195
  group.addTask {
170
196
  try await operation()
@@ -174,18 +200,38 @@ enum SniConnectWallClockDeadline {
174
200
  throw SniConnectTimeout.deadlineExceeded
175
201
  }
176
202
 
203
+ defer {
204
+ group.cancelAll()
205
+ }
177
206
  do {
178
207
  guard let result = try await group.next() else {
179
208
  throw SniConnectTimeout.deadlineExceeded
180
209
  }
181
- group.cancelAll()
210
+ guard !hasExpired(deadline) else {
211
+ throw SniConnectTimeout.deadlineExceeded
212
+ }
182
213
  return result
183
214
  } catch {
184
- group.cancelAll()
215
+ if hasExpired(deadline) {
216
+ throw SniConnectTimeout.deadlineExceeded
217
+ }
185
218
  throw error
186
219
  }
187
220
  }
188
221
  }
222
+
223
+ private static func remainingNanoseconds(until deadline: Deadline) -> UInt64 {
224
+ let now = DispatchTime.now().uptimeNanoseconds
225
+ return deadline.uptimeNanoseconds > now ? deadline.uptimeNanoseconds - now : 0
226
+ }
227
+
228
+ private static func hasExpired(_ deadline: Deadline) -> Bool {
229
+ DispatchTime.now().uptimeNanoseconds >= deadline.uptimeNanoseconds
230
+ }
231
+
232
+ private static func timeoutNanoseconds(milliseconds: TimeInterval) -> UInt64 {
233
+ UInt64(max(1.0, milliseconds) * 1_000_000.0)
234
+ }
189
235
  }
190
236
 
191
237
  struct SniConnectHeaderMaps: Equatable {