@onekeyfe/react-native-range-downloader 6.7.0-alpha.313 → 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.
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +6 -6
- package/ios/ReactNativeRangeDownloader.swift +198 -62
- package/package.json +1 -1
|
@@ -278,7 +278,7 @@ class ConcurrentRangeDownloader(
|
|
|
278
278
|
.firstOrNull { segFile(it.index).length() != it.length }
|
|
279
279
|
if (incomplete != null) {
|
|
280
280
|
// Keep the segment files for the next attempt to resume.
|
|
281
|
-
throw
|
|
281
|
+
throw ProtocolException("Concurrent download incomplete (segment ${incomplete.index})")
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// All segments complete → assemble the `.partial`.
|
|
@@ -497,17 +497,17 @@ class ConcurrentRangeDownloaderOcdsTest {
|
|
|
497
497
|
val partial = partialPath()
|
|
498
498
|
// First invocation: the short body leaves segment 6 one byte short, so this
|
|
499
499
|
// call throws "incomplete" and keeps the `.segN` for resume.
|
|
500
|
-
|
|
501
|
-
|
|
500
|
+
val handle = ConcurrentRangeDownloader.CancelHandle()
|
|
501
|
+
val firstFailure = runCatching {
|
|
502
502
|
newDownloader().download(
|
|
503
503
|
url = url(),
|
|
504
504
|
partialFilePath = partial,
|
|
505
|
+
cancelHandle = handle,
|
|
505
506
|
onProgress = { _, _ -> },
|
|
506
507
|
)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
assertTrue("short body must leave the first invocation incomplete", firstThrew)
|
|
508
|
+
}.exceptionOrNull()
|
|
509
|
+
assertTrue("short body must preserve a typed network failure", firstFailure is java.net.ProtocolException)
|
|
510
|
+
assertFalse("internal short body must not mark explicit cancellation", handle.aborted.get())
|
|
511
511
|
// The short segment is kept on disk, one byte under its planned length.
|
|
512
512
|
val seg = segFile(partial, target.index)
|
|
513
513
|
assertTrue("short `.segN` must be kept for resume", seg.exists())
|
|
@@ -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
|
|
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
|
-
|
|
661
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1166
|
-
guard
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1350
|
-
|
|
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
|
|
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
|
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
|
-
|
|
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