@onekeyfe/react-native-range-downloader 6.7.0-alpha.314 → 6.7.0-alpha.315

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.
@@ -388,6 +388,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
388
388
  /// Active-run state keyed by "channel|taskId". Replaces the origin's single
389
389
  /// global run state to support concurrent channels.
390
390
  private var runs: [String: RunState] = [:]
391
+ /// A key stays reserved until its cancelled tasks and file writers are drained.
392
+ private var cancellationWaiters: [String: [CheckedContinuation<Void, Never>]] = [:]
391
393
 
392
394
  /// Lazy per-channel background sessions, cached by identifier.
393
395
  private var sessions: [String: URLSession] = [:]
@@ -413,6 +415,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
413
415
  var ranges: [(start: Int64, end: Int64)] = []
414
416
  var segmentWritten: [Int64] = [] // progress estimate per segment
415
417
  var continuation: CheckedContinuation<Void, Error>?
418
+ var ownedTaskIdentifiers = Set<Int>()
419
+ var activeFileMutations = 0
420
+ var fileMutationWaiters: [CheckedContinuation<Void, Never>] = []
416
421
  var prevProgress = -1
417
422
  var fellBack = false
418
423
  /// §4 typed class for the eventual fallback. Set by the delegate when it
@@ -654,11 +659,51 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
654
659
  lock.withLockValue { self.runs[key] }
655
660
  }
656
661
 
657
- private func run(for desc: String) -> (run: RunState, segIndex: Int)? {
662
+ private func run(for task: URLSessionTask, in session: URLSession)
663
+ -> (run: RunState, segIndex: Int)? {
664
+ guard let desc = task.taskDescription else { return nil }
658
665
  guard let decoded = Self.decodeTaskDescription(desc) else { return nil }
659
666
  let key = Self.runKey(channel: decoded.channel, taskId: decoded.taskId)
660
- guard let r = run(forKey: key) else { return nil }
661
- return (r, decoded.segIndex)
667
+ return lock.withLockValue {
668
+ guard cancellationWaiters[key] == nil,
669
+ let state = runs[key],
670
+ sessions[state.sessionIdentifier] === session,
671
+ state.ownedTaskIdentifiers.contains(task.taskIdentifier) else { return nil }
672
+ return (state, decoded.segIndex)
673
+ }
674
+ }
675
+
676
+ private func isActive(_ state: RunState) -> Bool {
677
+ let key = Self.runKey(channel: state.channel, taskId: state.taskId)
678
+ return lock.withLockValue { runs[key] === state && cancellationWaiters[key] == nil }
679
+ }
680
+
681
+ private func owns(_ task: URLSessionTask, state: RunState) -> Bool {
682
+ let key = Self.runKey(channel: state.channel, taskId: state.taskId)
683
+ return lock.withLockValue {
684
+ runs[key] === state && cancellationWaiters[key] == nil
685
+ && state.ownedTaskIdentifiers.contains(task.taskIdentifier)
686
+ }
687
+ }
688
+
689
+ private func beginFileMutation(_ state: RunState) -> Bool {
690
+ let key = Self.runKey(channel: state.channel, taskId: state.taskId)
691
+ return lock.withLockValue {
692
+ guard runs[key] === state && cancellationWaiters[key] == nil else { return false }
693
+ state.activeFileMutations += 1
694
+ return true
695
+ }
696
+ }
697
+
698
+ private func endFileMutation(_ state: RunState) {
699
+ let waiters: [CheckedContinuation<Void, Never>] = lock.withLockValue {
700
+ state.activeFileMutations -= 1
701
+ guard state.activeFileMutations == 0 else { return [] }
702
+ let pending = state.fileMutationWaiters
703
+ state.fileMutationWaiters.removeAll()
704
+ return pending
705
+ }
706
+ waiters.forEach { $0.resume() }
662
707
  }
663
708
 
664
709
  // MARK: - Public entry
@@ -716,7 +761,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
716
761
  // a half-initialized run was invisible and a 2nd download() could overwrite
717
762
  // `runs[key]`. We fail fast (the caller retry loop re-drives cleanly) to
718
763
  // avoid join bookkeeping hangs.
719
- if let existing = run(forKey: key), existing.filePath == filePath {
764
+ if lock.withLockValue({ runs[key] != nil || cancellationWaiters[key] != nil }) {
720
765
  return (.fallbackTransient, filePath,
721
766
  "another run is already active for this destination", .budgetExhausted, nil)
722
767
  }
@@ -756,7 +801,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
756
801
  // half-initialized run (continuation not yet assigned, or the synchronous
757
802
  // fast path that never assigns one) is still observed as live by any racer.
758
803
  lock.lock()
759
- if let existing = runs[key], existing.filePath == filePath, existing !== state {
804
+ if runs[key] != nil || cancellationWaiters[key] != nil {
760
805
  lock.unlock()
761
806
  return (.fallbackTransient, filePath,
762
807
  "another run is already active for this destination", .budgetExhausted, nil)
@@ -771,26 +816,35 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
771
816
  // attempt are untrustworthy. Start fresh and only proceed on the resumable
772
817
  // path when expectedSha256 will gate the assembled file (verified below).
773
818
  if !state.hasValidator {
819
+ guard beginFileMutation(state) else {
820
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
821
+ }
774
822
  cleanupSegments(state: state, ranges: ranges)
823
+ endFileMutation(state)
775
824
  }
776
825
 
777
- emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
826
+ if isActive(state) {
827
+ emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
828
+ }
778
829
 
779
830
  // §5.4: start the bytes-stalled watchdog for this run. It cancels a stalled
780
831
  // segment task so didCompleteWithError routes it through the in-place retry
781
832
  // path. It only counts foreground/active wall time toward the stall window so
782
833
  // a legitimately suspended background transfer (§5.10) is never false-cancelled.
783
- startStallWatchdog(key: key, stallSeconds: stallSeconds)
834
+ startStallWatchdog(state: state, stallSeconds: stallSeconds)
784
835
 
785
836
  do {
786
837
  // If every segment is already on disk (resume after suspension/kill), skip
787
838
  // straight to concatenation.
788
839
  if allSegmentsPresent(state: state, ranges: ranges) {
840
+ guard beginFileMutation(state) else { throw CancellationError() }
841
+ defer { endFileMutation(state) }
789
842
  try concatenateAndFinish(state: state, ranges: ranges)
790
843
  } else {
791
844
  try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
792
845
  self.lock.lock()
793
- guard self.runs[key] === state else {
846
+ guard self.runs[key] === state,
847
+ self.cancellationWaiters[key] == nil else {
794
848
  self.lock.unlock()
795
849
  cont.resume(throwing: CancellationError())
796
850
  return
@@ -801,45 +855,60 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
801
855
  }
802
856
  }
803
857
  } catch let fb as FallbackError {
804
- clearRun(key: key, state: state)
805
- emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
858
+ if clearRun(key: key, state: state) {
859
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
860
+ }
806
861
  return (fb.kind.failureClass, filePath, fb.reason, fb.kind, fb.underlyingError)
807
862
  } catch {
808
- clearRun(key: key, state: state)
809
863
  let reason = error.localizedDescription
810
- emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
864
+ if clearRun(key: key, state: state) {
865
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
866
+ }
811
867
  let retryable = Self.isRetryableNetworkError(error)
812
868
  let klass: RangeDownloadClass = retryable ? .fallbackTransient : .fallbackPermanent
813
869
  return (klass, filePath, reason, retryable ? .transientNetwork : nil, error)
814
870
  }
815
871
 
816
- clearRun(key: key, state: state)
817
-
818
872
  // Optional immediate SHA256 self-check backstop. When omitted, the caller
819
873
  // verifies after the fact. A whole-file checksum mismatch is Permanent (§4):
820
874
  // the assembled bytes are unsalvageable, so discard final + artifacts.
821
875
  if let expected = expectedSha256, !expected.isEmpty {
876
+ guard beginFileMutation(state) else {
877
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
878
+ }
822
879
  let actual = RangeDownloadLogic.calculateSHA256(filePath)
823
880
  if actual?.lowercased() != expected.lowercased() {
824
881
  try? FileManager.default.removeItem(atPath: filePath)
825
882
  discardArtifacts(filePath: filePath)
883
+ endFileMutation(state)
884
+ guard clearRun(key: key, state: state) else {
885
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
886
+ }
826
887
  let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
827
888
  OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
828
889
  emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
829
890
  return (.fallbackPermanent, filePath, reason, .checksumMismatch, nil)
830
891
  }
892
+ endFileMutation(state)
831
893
  }
832
894
 
895
+ guard clearRun(key: key, state: state) else {
896
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
897
+ }
833
898
  emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
834
899
  return (.completed, filePath, nil, nil, nil)
835
900
  }
836
901
 
837
- private func clearRun(key: String, state: RunState) {
902
+ @discardableResult
903
+ private func clearRun(key: String, state: RunState) -> Bool {
838
904
  lock.lock()
839
- if runs[key] === state {
905
+ if runs[key] === state && cancellationWaiters[key] == nil {
840
906
  runs.removeValue(forKey: key)
907
+ lock.unlock()
908
+ return true
841
909
  }
842
910
  lock.unlock()
911
+ return false
843
912
  }
844
913
 
845
914
  // MARK: - Range planning / probing
@@ -909,7 +978,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
909
978
  let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
910
979
  session.getAllTasks { [weak self] tasks in
911
980
  guard let self = self else { return }
912
- guard self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)) === state else { return }
981
+ guard self.isActive(state) else { return }
913
982
  var liveIndexes = Set<Int>()
914
983
  for t in tasks {
915
984
  guard let desc = t.taskDescription,
@@ -928,10 +997,17 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
928
997
  if let turl = t.originalRequest?.url,
929
998
  turl.absoluteString == url.absoluteString,
930
999
  t.state == .running || t.state == .suspended {
931
- liveIndexes.insert(decoded.segIndex)
1000
+ let adopted = self.lock.withLockValue { () -> Bool in
1001
+ let key = Self.runKey(channel: state.channel, taskId: state.taskId)
1002
+ guard self.runs[key] === state,
1003
+ self.cancellationWaiters[key] == nil else { return false }
1004
+ state.ownedTaskIdentifiers.insert(t.taskIdentifier)
1005
+ return true
1006
+ }
1007
+ if adopted { liveIndexes.insert(decoded.segIndex) }
932
1008
  } else {
933
1009
  // Same run id but different URL (etag/url changed) — drop it.
934
- t.cancel()
1010
+ if self.isActive(state) { t.cancel() }
935
1011
  }
936
1012
  }
937
1013
  // Tasks for other taskIds on the same channel are left running so
@@ -970,6 +1046,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
970
1046
  // clear the pending-retry marker — the in-flight check is authoritative again.
971
1047
  lock.lock()
972
1048
  guard runs[Self.runKey(channel: state.channel, taskId: state.taskId)] === state,
1049
+ cancellationWaiters[Self.runKey(channel: state.channel, taskId: state.taskId)] == nil,
973
1050
  state.continuation != nil else {
974
1051
  lock.unlock()
975
1052
  task.cancel()
@@ -978,6 +1055,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
978
1055
  if idx < state.segmentWritten.count { state.segmentWritten[idx] = 0 }
979
1056
  state.pendingRetryIndexes.remove(idx)
980
1057
  state.lastProgressAt = Date()
1058
+ state.ownedTaskIdentifiers.insert(task.taskIdentifier)
981
1059
  task.resume()
982
1060
  lock.unlock()
983
1061
  }
@@ -1005,7 +1083,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1005
1083
  // completion driving the missing-segment re-enqueue — must NOT bump the
1006
1084
  // counter again nor arm a duplicate asyncAfter. Returning `true` here reports
1007
1085
  // "a retry is in flight for this segment" without scheduling a second one.
1008
- let claim: (alreadyPending: Bool, attempts: Int) = lock.withLockValue {
1086
+ let claim: (alreadyPending: Bool, attempts: Int)? = lock.withLockValue {
1087
+ let key = Self.runKey(channel: state.channel, taskId: state.taskId)
1088
+ guard runs[key] === state && cancellationWaiters[key] == nil else { return nil }
1009
1089
  if state.pendingRetryIndexes.contains(idx) {
1010
1090
  return (true, state.segmentAttempts[idx] ?? 0)
1011
1091
  }
@@ -1014,6 +1094,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1014
1094
  state.pendingRetryIndexes.insert(idx)
1015
1095
  return (false, n)
1016
1096
  }
1097
+ guard let claim = claim else { return false }
1017
1098
  if claim.alreadyPending { return true }
1018
1099
  let attempts = claim.attempts
1019
1100
  if attempts > state.maxSegmentAttempts {
@@ -1044,8 +1125,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1044
1125
  // in the meantime, and guard against a double-enqueue. On every bail path
1045
1126
  // clear the pending-retry marker (G2) so it doesn't leak: either the slot
1046
1127
  // is moot (run gone / segment landed) or a real task already exists.
1047
- guard let live = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)),
1048
- live === state else {
1128
+ guard self.isActive(state) else {
1049
1129
  self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
1050
1130
  return
1051
1131
  }
@@ -1055,6 +1135,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1055
1135
  }
1056
1136
  session.getAllTasks { tasks in
1057
1137
  let alreadyLive = tasks.contains { t in
1138
+ guard self.owns(t, state: state) else { return false }
1058
1139
  guard let d = t.taskDescription,
1059
1140
  let decoded = Self.decodeTaskDescription(d),
1060
1141
  decoded.channel.stringValue == channelStr,
@@ -1102,7 +1183,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1102
1183
  Task { [weak self] in
1103
1184
  guard let self = self else { return }
1104
1185
  // Re-confirm the run is still live before acting on the re-probe.
1105
- let stillLive = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)).map { $0 === state } ?? false
1186
+ let stillLive = self.isActive(state)
1106
1187
  guard stillLive else { return }
1107
1188
  let probe: ProbeResult
1108
1189
  do {
@@ -1155,15 +1236,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1155
1236
  /// in-flight task, so a suspended background transfer (which makes no JS/main
1156
1237
  /// progress while suspended) is not false-cancelled because the watchdog timer
1157
1238
  /// itself is also suspended with the app.
1158
- private func startStallWatchdog(key: String, stallSeconds: Double) {
1239
+ private func startStallWatchdog(state: RunState, stallSeconds: Double) {
1159
1240
  let interval = max(5.0, stallSeconds / 2.0)
1160
- DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
1161
- self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval)
1241
+ DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self, weak state] in
1242
+ guard let state = state else { return }
1243
+ self?.stallWatchdogTick(state: state, stallSeconds: stallSeconds, interval: interval)
1162
1244
  }
1163
1245
  }
1164
1246
 
1165
- private func stallWatchdogTick(key: String, stallSeconds: Double, interval: Double) {
1166
- guard let state = run(forKey: key),
1247
+ private func stallWatchdogTick(state: RunState, stallSeconds: Double, interval: Double) {
1248
+ guard isActive(state),
1167
1249
  lock.withLockValue({ state.continuation != nil }) else {
1168
1250
  return // run finalized / cancelled — stop.
1169
1251
  }
@@ -1175,6 +1257,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1175
1257
  let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
1176
1258
  session.getAllTasks { tasks in
1177
1259
  for t in tasks {
1260
+ guard self.owns(t, state: state) else { continue }
1178
1261
  guard let d = t.taskDescription,
1179
1262
  let decoded = Self.decodeTaskDescription(d),
1180
1263
  decoded.channel.stringValue == channelStr,
@@ -1193,8 +1276,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1193
1276
  lock.lock(); state.lastProgressAt = Date(); lock.unlock()
1194
1277
  }
1195
1278
  // Reschedule.
1196
- DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
1197
- self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval)
1279
+ DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self, weak state] in
1280
+ guard let state = state else { return }
1281
+ self?.stallWatchdogTick(state: state, stallSeconds: stallSeconds, interval: interval)
1198
1282
  }
1199
1283
  }
1200
1284
 
@@ -1217,8 +1301,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1217
1301
  public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
1218
1302
  didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
1219
1303
  totalBytesExpectedToWrite: Int64) {
1220
- guard let desc = downloadTask.taskDescription,
1221
- let (state, idx) = run(for: desc) else { return }
1304
+ guard let (state, idx) = run(for: downloadTask, in: session) else { return }
1222
1305
  lock.lock()
1223
1306
  if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten }
1224
1307
  // §5.4: stamp last-progress for the stall watchdog. Any byte on any segment
@@ -1239,7 +1322,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1239
1322
  let channel = state.channel
1240
1323
  let taskId = state.taskId
1241
1324
  lock.unlock()
1242
- if emit {
1325
+ if emit && isActive(state) {
1243
1326
  self.emit(channel: channel, taskId: taskId, type: "progress",
1244
1327
  progress: Double(progressValue), message: "")
1245
1328
  }
@@ -1247,8 +1330,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1247
1330
 
1248
1331
  public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
1249
1332
  didFinishDownloadingTo location: URL) {
1250
- guard let desc = downloadTask.taskDescription,
1251
- let (state, idx) = run(for: desc) else { return }
1333
+ guard let (state, idx) = run(for: downloadTask, in: session) else { return }
1252
1334
  let ranges = lock.withLockValue { state.ranges }
1253
1335
  guard idx < ranges.count else { return }
1254
1336
  let range = ranges[idx]
@@ -1315,6 +1397,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1315
1397
  // Validate the downloaded body length BEFORE moving it into place: a
1316
1398
  // truncated 206 must never be stashed as a valid segment.
1317
1399
  let dest = state.segPath(idx)
1400
+ guard beginFileMutation(state) else { return }
1401
+ defer { endFileMutation(state) }
1318
1402
  do {
1319
1403
  let attrs = try FileManager.default.attributesOfItem(atPath: location.path)
1320
1404
  let size = attrs[.size] as? Int64
@@ -1346,8 +1430,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1346
1430
  }
1347
1431
 
1348
1432
  public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
1349
- guard let desc = task.taskDescription,
1350
- let (state, idx) = run(for: desc) else { return }
1433
+ guard let (state, idx) = run(for: task, in: session) else { return }
1434
+ defer { lock.withLockValue { _ = state.ownedTaskIdentifiers.remove(task.taskIdentifier) } }
1351
1435
  let ranges = lock.withLockValue { state.ranges }
1352
1436
 
1353
1437
  // A Permanent classification anywhere in the run (didFinishDownloadingTo set
@@ -1435,6 +1519,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1435
1519
  session.getAllTasks { [weak self] tasks in
1436
1520
  guard let self = self else { return }
1437
1521
  let stillInFlight = tasks.contains { t in
1522
+ guard self.owns(t, state: state) else { return false }
1438
1523
  guard let d = t.taskDescription,
1439
1524
  let decoded = Self.decodeTaskDescription(d),
1440
1525
  decoded.channel.stringValue == channel.stringValue,
@@ -1484,8 +1569,10 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1484
1569
  let channel = state.channel
1485
1570
  let taskId = state.taskId
1486
1571
  let kind = lock.withLockValue { state.fellBackKind }
1487
- session.getAllTasks { tasks in
1572
+ session.getAllTasks { [weak self] tasks in
1573
+ guard let self = self, self.isActive(state) else { return }
1488
1574
  for t in tasks {
1575
+ guard self.owns(t, state: state) else { continue }
1489
1576
  if let d = t.taskDescription,
1490
1577
  let decoded = Self.decodeTaskDescription(d),
1491
1578
  decoded.channel.stringValue == channel.stringValue,
@@ -1493,24 +1580,27 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1493
1580
  t.cancel()
1494
1581
  }
1495
1582
  }
1583
+ if self.beginFileMutation(state) {
1584
+ self.cleanupSegments(state: state, ranges: ranges)
1585
+ self.endFileMutation(state)
1586
+ }
1587
+ let reason: String
1588
+ switch kind {
1589
+ case .serverIgnoredRange: reason = "server returned 200 to a Range request"
1590
+ case .multipartOrBadTotal: reason = "non-conforming 206 (multipart / range / total mismatch)"
1591
+ case .authExpired: reason = "auth expired (401/403); fetch a fresh signed URL"
1592
+ case .notFound: reason = "object not found (404/410)"
1593
+ case .redirectRejected: reason = "rejected non-HTTPS redirect"
1594
+ case .rangeUnsupported: reason = "range unsupported / non-retryable status"
1595
+ case .checksumMismatch: reason = "whole-file checksum mismatch"
1596
+ case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback"
1597
+ }
1598
+ let status = self.lock.withLockValue { state.httpStatus }
1599
+ let statusError = status.flatMap { $0 >= 400 ? HTTPStatusError(statusCode: $0) : nil }
1600
+ self.finishContinuation(state: state,
1601
+ with: FallbackError(reason: reason, kind: kind,
1602
+ underlyingError: statusError), ranges: ranges)
1496
1603
  }
1497
- cleanupSegments(state: state, ranges: ranges)
1498
- let reason: String
1499
- switch kind {
1500
- case .serverIgnoredRange: reason = "server returned 200 to a Range request"
1501
- case .multipartOrBadTotal: reason = "non-conforming 206 (multipart / range / total mismatch)"
1502
- case .authExpired: reason = "auth expired (401/403); fetch a fresh signed URL"
1503
- case .notFound: reason = "object not found (404/410)"
1504
- case .redirectRejected: reason = "rejected non-HTTPS redirect"
1505
- case .rangeUnsupported: reason = "range unsupported / non-retryable status"
1506
- case .checksumMismatch: reason = "whole-file checksum mismatch"
1507
- case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback"
1508
- }
1509
- let status = lock.withLockValue { state.httpStatus }
1510
- let statusError = status.flatMap { $0 >= 400 ? HTTPStatusError(statusCode: $0) : nil }
1511
- finishContinuation(state: state,
1512
- with: FallbackError(reason: reason, kind: kind,
1513
- underlyingError: statusError), ranges: ranges)
1514
1604
  }
1515
1605
 
1516
1606
  /// §4 Transient: finalize the run as a RESUMABLE fallback. `.segN` files are
@@ -1538,7 +1628,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1538
1628
  completionHandler: @escaping (URLRequest?) -> Void) {
1539
1629
  if request.url?.scheme?.lowercased() != "https" {
1540
1630
  OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL")
1541
- if let desc = task.taskDescription, let (state, _) = run(for: desc) {
1631
+ if let (state, _) = run(for: task, in: session) {
1542
1632
  lock.lock(); state.fellBack = true; state.fellBackKind = .redirectRejected; lock.unlock()
1543
1633
  }
1544
1634
  completionHandler(nil)
@@ -1574,10 +1664,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1574
1664
  }
1575
1665
  guard let cont = cont else { return }
1576
1666
  if let error = error { cont.resume(throwing: error); return }
1667
+ guard beginFileMutation(state) else {
1668
+ cont.resume(throwing: CancellationError())
1669
+ return
1670
+ }
1577
1671
  do {
1578
1672
  try concatenateAndFinish(state: state, ranges: ranges)
1673
+ endFileMutation(state)
1579
1674
  cont.resume(returning: ())
1580
1675
  } catch {
1676
+ endFileMutation(state)
1581
1677
  cont.resume(throwing: error)
1582
1678
  }
1583
1679
  }
@@ -1634,15 +1730,33 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1634
1730
  filePath: String) async {
1635
1731
  let session = session(forChannel: channel, segmentCount: Self.defaultSegmentCount)
1636
1732
  let key = Self.runKey(channel: channel, taskId: taskId)
1637
- let continuation: CheckedContinuation<Void, Error>? = lock.withLockValue {
1638
- let state = runs.removeValue(forKey: key)
1639
- let pending = state?.continuation
1733
+ var state: RunState?
1734
+ var pending: CheckedContinuation<Void, Error>?
1735
+ let ownsCancellation: Bool = lock.withLockValue {
1736
+ guard cancellationWaiters[key] == nil else { return false }
1737
+ cancellationWaiters[key] = []
1738
+ state = runs[key]
1739
+ pending = state?.continuation
1640
1740
  state?.continuation = nil
1641
- return pending
1741
+ return true
1742
+ }
1743
+ if !ownsCancellation {
1744
+ await withCheckedContinuation { (waiter: CheckedContinuation<Void, Never>) in
1745
+ lock.lock()
1746
+ if cancellationWaiters[key] != nil {
1747
+ cancellationWaiters[key]?.append(waiter)
1748
+ lock.unlock()
1749
+ } else {
1750
+ lock.unlock()
1751
+ waiter.resume()
1752
+ }
1753
+ }
1754
+ return
1642
1755
  }
1643
- continuation?.resume(throwing: CancellationError())
1756
+ pending?.resume(throwing: CancellationError())
1644
1757
  // Cancel matching tasks first and wait for getAllTasks to return so the
1645
- // cancels have been issued before we touch the files.
1758
+ // cancels have been issued before we touch the files. The key stays reserved
1759
+ // during this asynchronous enumeration, including when no run was registered.
1646
1760
  await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1647
1761
  session.getAllTasks { tasks in
1648
1762
  for t in tasks {
@@ -1656,7 +1770,29 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1656
1770
  cont.resume()
1657
1771
  }
1658
1772
  }
1773
+ // URLSession's serial delegate queue drains callbacks that had already
1774
+ // entered before cancellation. Later callbacks fail the run/task owner check.
1775
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1776
+ session.delegateQueue.addOperation { cont.resume() }
1777
+ }
1778
+ if let state = state {
1779
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1780
+ lock.lock()
1781
+ if state.activeFileMutations == 0 {
1782
+ lock.unlock()
1783
+ cont.resume()
1784
+ } else {
1785
+ state.fileMutationWaiters.append(cont)
1786
+ lock.unlock()
1787
+ }
1788
+ }
1789
+ }
1659
1790
  discardArtifacts(filePath: filePath)
1791
+ let waiters: [CheckedContinuation<Void, Never>] = lock.withLockValue {
1792
+ if let state = state, runs[key] === state { runs.removeValue(forKey: key) }
1793
+ return cancellationWaiters.removeValue(forKey: key) ?? []
1794
+ }
1795
+ waiters.forEach { $0.resume() }
1660
1796
  }
1661
1797
 
1662
1798
  /// True when at least one concurrent segment artifact survives for this file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-range-downloader",
3
- "version": "6.7.0-alpha.314",
3
+ "version": "6.7.0-alpha.315",
4
4
  "description": "react-native-range-downloader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",