@onekeyfe/react-native-range-downloader 6.7.0-1 → 6.7.0-alpha.311
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 +26 -13
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +49 -0
- package/ios/ReactNativeRangeDownloader.swift +99 -40
- package/package.json +1 -1
|
@@ -5,6 +5,11 @@ import okhttp3.Request
|
|
|
5
5
|
import java.io.File
|
|
6
6
|
import java.io.FileInputStream
|
|
7
7
|
import java.io.FileOutputStream
|
|
8
|
+
import java.net.ConnectException
|
|
9
|
+
import java.net.ProtocolException
|
|
10
|
+
import java.net.SocketException
|
|
11
|
+
import java.net.SocketTimeoutException
|
|
12
|
+
import java.net.UnknownHostException
|
|
8
13
|
import java.util.concurrent.Executors
|
|
9
14
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
10
15
|
import java.util.concurrent.atomic.AtomicLong
|
|
@@ -74,19 +79,18 @@ class ConcurrentRangeDownloader(
|
|
|
74
79
|
* any other non-retryable 4xx per OCDS §4's catch-all). Unlike a generic
|
|
75
80
|
* transient [java.io.IOException], this MUST bypass the per-segment retry
|
|
76
81
|
* loop ([downloadSegment]) — retrying a dead URL only wastes the attempt
|
|
77
|
-
* budget.
|
|
78
|
-
* taxonomy ([updateErrorTaxonomy.ts]) maps it to `HTTP_<code>`.
|
|
82
|
+
* budget. Native updater callers can read [code] without parsing prose.
|
|
79
83
|
*/
|
|
80
|
-
|
|
84
|
+
class PermanentHttpException(val code: Int) :
|
|
81
85
|
Exception("HTTP $code")
|
|
82
86
|
|
|
83
87
|
/**
|
|
84
88
|
* Typed transient carrying an optional `Retry-After` delay (milliseconds)
|
|
85
89
|
* the server asked us to wait before retrying (`429`/`503`). The retry loop
|
|
86
|
-
* in [downloadSegment] prefers this over its computed backoff.
|
|
87
|
-
*
|
|
90
|
+
* in [downloadSegment] prefers this over its computed backoff. Native
|
|
91
|
+
* updater callers can read [code] without parsing prose.
|
|
88
92
|
*/
|
|
89
|
-
|
|
93
|
+
class TransientHttpException(val code: Int, val retryAfterMillis: Long?) :
|
|
90
94
|
java.io.IOException("HTTP $code")
|
|
91
95
|
|
|
92
96
|
/**
|
|
@@ -144,12 +148,15 @@ class ConcurrentRangeDownloader(
|
|
|
144
148
|
* Fills [partialFilePath] completely with the resource at [url] using
|
|
145
149
|
* concurrent ranges. See [Outcome]. Throws on a transient/IO error after
|
|
146
150
|
* per-segment retries, leaving the segment files in place so a later attempt
|
|
147
|
-
* resumes.
|
|
151
|
+
* resumes. [maxTotalBytes] rejects a known oversized probe before workers
|
|
152
|
+
* or artifact writes; callers with unknown single-stream sizes enforce their
|
|
153
|
+
* own streaming limit.
|
|
148
154
|
*/
|
|
149
155
|
fun download(
|
|
150
156
|
url: String,
|
|
151
157
|
partialFilePath: String,
|
|
152
158
|
cancelHandle: CancelHandle? = null,
|
|
159
|
+
maxTotalBytes: Long = Long.MAX_VALUE,
|
|
153
160
|
onProgress: (transferred: Long, total: Long) -> Unit,
|
|
154
161
|
): Outcome {
|
|
155
162
|
val partialFile = File(partialFilePath)
|
|
@@ -164,6 +171,9 @@ class ConcurrentRangeDownloader(
|
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
val probe = probe(url) ?: return Outcome.FALLBACK
|
|
174
|
+
if (probe.totalSize > maxTotalBytes) {
|
|
175
|
+
throw SecurityException("Range download exceeds caller size limit")
|
|
176
|
+
}
|
|
167
177
|
if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) {
|
|
168
178
|
log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})")
|
|
169
179
|
return Outcome.FALLBACK
|
|
@@ -308,12 +318,12 @@ class ConcurrentRangeDownloader(
|
|
|
308
318
|
val len = response.body?.contentLength() ?: -1L
|
|
309
319
|
Probe(if (len > 0) len else 0, false)
|
|
310
320
|
}
|
|
311
|
-
else ->
|
|
321
|
+
else -> throw classifyHttpFailure(response)
|
|
312
322
|
}
|
|
313
323
|
}
|
|
314
324
|
} catch (e: Exception) {
|
|
315
325
|
log("concurrent: probe failed: ${e.javaClass.simpleName}")
|
|
316
|
-
|
|
326
|
+
throw e
|
|
317
327
|
}
|
|
318
328
|
}
|
|
319
329
|
|
|
@@ -404,10 +414,13 @@ class ConcurrentRangeDownloader(
|
|
|
404
414
|
throw e
|
|
405
415
|
} catch (e: PermanentHttpException) {
|
|
406
416
|
// Dead URL (auth/gone/non-retryable) — retrying only burns the
|
|
407
|
-
// attempt budget. Surface
|
|
408
|
-
// `HTTP <code>` message to a permanent JS taxonomy bucket.
|
|
417
|
+
// attempt budget. Surface the typed HTTP status immediately.
|
|
409
418
|
throw e
|
|
410
419
|
} catch (e: Exception) {
|
|
420
|
+
val retryable = e is TransientHttpException ||
|
|
421
|
+
e is SocketTimeoutException || e is UnknownHostException ||
|
|
422
|
+
e is ConnectException || e is SocketException || e is ProtocolException
|
|
423
|
+
if (!retryable) throw e
|
|
411
424
|
if (aborted.get() || retry >= maxPartRetry) throw e
|
|
412
425
|
retry += 1
|
|
413
426
|
// Prefer the server's Retry-After; otherwise exponential backoff
|
|
@@ -469,8 +482,8 @@ class ConcurrentRangeDownloader(
|
|
|
469
482
|
if (response.code != 206) {
|
|
470
483
|
// OCDS §4 classification. Anything that is not a usable 206 is
|
|
471
484
|
// either a permanently-dead URL (bypass retries) or a transient
|
|
472
|
-
// condition (retry with backoff).
|
|
473
|
-
//
|
|
485
|
+
// condition (retry with backoff). Both exceptions retain a
|
|
486
|
+
// typed status for native updater callers.
|
|
474
487
|
throw classifyHttpFailure(response)
|
|
475
488
|
}
|
|
476
489
|
// Reject a multipart/byteranges body: it carries range delimiters and
|
|
@@ -2,6 +2,9 @@ package com.margelo.nitro.reactnativerangedownloader
|
|
|
2
2
|
|
|
3
3
|
import okhttp3.OkHttpClient
|
|
4
4
|
import okhttp3.mockwebserver.MockWebServer
|
|
5
|
+
import okhttp3.mockwebserver.Dispatcher
|
|
6
|
+
import okhttp3.mockwebserver.MockResponse
|
|
7
|
+
import okhttp3.mockwebserver.RecordedRequest
|
|
5
8
|
import org.junit.After
|
|
6
9
|
import org.junit.Assert.assertEquals
|
|
7
10
|
import org.junit.Assert.assertFalse
|
|
@@ -82,6 +85,52 @@ class ConcurrentRangeDownloaderOcdsTest {
|
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
@Test
|
|
89
|
+
fun oversizedProbedTotalRejectsBeforeRangeWorkers() {
|
|
90
|
+
val cap = content.size.toLong()
|
|
91
|
+
server = MockWebServer()
|
|
92
|
+
server.dispatcher = object : Dispatcher() {
|
|
93
|
+
override fun dispatch(request: RecordedRequest) = MockResponse()
|
|
94
|
+
.setResponseCode(206)
|
|
95
|
+
.addHeader("Content-Range", "bytes 0-0/${cap + 1}")
|
|
96
|
+
.setBody("x")
|
|
97
|
+
}
|
|
98
|
+
server.start()
|
|
99
|
+
|
|
100
|
+
val partial = partialPath()
|
|
101
|
+
val failure = runCatching {
|
|
102
|
+
newDownloader().download(
|
|
103
|
+
url = url(),
|
|
104
|
+
partialFilePath = partial,
|
|
105
|
+
maxTotalBytes = cap,
|
|
106
|
+
onProgress = { _, _ -> },
|
|
107
|
+
)
|
|
108
|
+
}.exceptionOrNull()
|
|
109
|
+
|
|
110
|
+
assertTrue("oversized total must reject", failure is SecurityException)
|
|
111
|
+
assertEquals("only the probe may reach the server", 1, server.requestCount)
|
|
112
|
+
assertEquals("bytes=0-0", server.takeRequest().getHeader("Range"))
|
|
113
|
+
assertNoArtifactsLeft(partial)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
fun probedTotalAtLimitCanComplete() {
|
|
118
|
+
val fs = RangeFaultServer(content)
|
|
119
|
+
startServer(fs)
|
|
120
|
+
|
|
121
|
+
val partial = partialPath()
|
|
122
|
+
val outcome = newDownloader().download(
|
|
123
|
+
url = url(),
|
|
124
|
+
partialFilePath = partial,
|
|
125
|
+
maxTotalBytes = content.size.toLong(),
|
|
126
|
+
onProgress = { _, _ -> },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
130
|
+
assertEquals(content.size.toLong(), File(partial).length())
|
|
131
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
132
|
+
}
|
|
133
|
+
|
|
85
134
|
// ------------------------------------------------------------------------
|
|
86
135
|
// OCDS-T1: drop one segment's connection mid-body a couple of times.
|
|
87
136
|
// Only that segment is re-fetched (retries stay in its own window); the
|
|
@@ -29,7 +29,7 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
|
|
|
29
29
|
|
|
30
30
|
func download(params: RangeDownloadParams) throws -> Promise<RangeDownloadResult> {
|
|
31
31
|
return Promise.async {
|
|
32
|
-
let (klass, filePath, fallbackReason, fallbackClass) = await RangeDownloader.shared.download(
|
|
32
|
+
let (klass, filePath, fallbackReason, fallbackClass, _) = await RangeDownloader.shared.download(
|
|
33
33
|
channel: params.channel,
|
|
34
34
|
taskId: params.taskId,
|
|
35
35
|
urlString: params.url,
|
|
@@ -350,6 +350,10 @@ extension RangeFallbackClass {
|
|
|
350
350
|
|
|
351
351
|
public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
352
352
|
|
|
353
|
+
public struct HTTPStatusError: Error {
|
|
354
|
+
public let statusCode: Int
|
|
355
|
+
}
|
|
356
|
+
|
|
353
357
|
public static let shared = RangeDownloader()
|
|
354
358
|
|
|
355
359
|
/// Posted by the AppDelegate from
|
|
@@ -370,6 +374,15 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
370
374
|
private static let defaultSegmentCount = 8
|
|
371
375
|
private static let defaultMinConcurrentBytes: Int64 = 2 * 1024 * 1024
|
|
372
376
|
|
|
377
|
+
private static func isRetryableNetworkError(_ error: Error) -> Bool {
|
|
378
|
+
let nsError = error as NSError
|
|
379
|
+
guard nsError.domain == NSURLErrorDomain else { return false }
|
|
380
|
+
return [NSURLErrorTimedOut, NSURLErrorCannotFindHost,
|
|
381
|
+
NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost,
|
|
382
|
+
NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed]
|
|
383
|
+
.contains(nsError.code)
|
|
384
|
+
}
|
|
385
|
+
|
|
373
386
|
private let lock = NSLock()
|
|
374
387
|
|
|
375
388
|
/// Active-run state keyed by "channel|taskId". Replaces the origin's single
|
|
@@ -411,6 +424,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
411
424
|
/// Set when a segment could not be stashed (move/size-check failure). Carries
|
|
412
425
|
/// the terminal error so didCompleteWithError finalizes instead of hanging.
|
|
413
426
|
var stashError: Error?
|
|
427
|
+
var httpStatus: Int?
|
|
414
428
|
/// §4/§5.4: per-segment indexes whose finished body was a TRANSIENT HTTP
|
|
415
429
|
/// status (429/5xx/408/416) — the body is discarded and the segment is
|
|
416
430
|
/// re-enqueued (G2) instead of failing the whole run. Optional Retry-After
|
|
@@ -604,8 +618,10 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
604
618
|
Array(self.listeners.values)
|
|
605
619
|
}
|
|
606
620
|
guard !snapshot.isEmpty else { return }
|
|
621
|
+
let wireMessage = channel.stringValue == DownloadChannel.bundle.stringValue && type == "fallback"
|
|
622
|
+
? "" : message
|
|
607
623
|
let event = RangeDownloadEvent(
|
|
608
|
-
channel: channel, taskId: taskId, type: type, progress: progress, message:
|
|
624
|
+
channel: channel, taskId: taskId, type: type, progress: progress, message: wireMessage
|
|
609
625
|
)
|
|
610
626
|
for cb in snapshot { cb(event) }
|
|
611
627
|
}
|
|
@@ -665,7 +681,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
665
681
|
requestTimeoutSeconds: Double? = nil,
|
|
666
682
|
stallTimeoutSeconds: Double? = nil,
|
|
667
683
|
overallDeadlineSeconds: Double? = nil
|
|
668
|
-
) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?) {
|
|
684
|
+
) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?, Error?) {
|
|
669
685
|
let segCount = max(1, segmentCount ?? Self.defaultSegmentCount)
|
|
670
686
|
let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes
|
|
671
687
|
let maxAttempts = max(1, maxSegmentAttempts ?? Self.defaultMaxSegmentAttempts)
|
|
@@ -683,11 +699,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
683
699
|
let key = Self.runKey(channel: channel, taskId: taskId)
|
|
684
700
|
|
|
685
701
|
guard let url = URL(string: urlString) else {
|
|
686
|
-
return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported)
|
|
702
|
+
return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported, URLError(.badURL))
|
|
687
703
|
}
|
|
688
704
|
// HTTPS-only: background URLSession + transport hardening.
|
|
689
705
|
guard urlString.hasPrefix("https://") else {
|
|
690
|
-
return (.fallbackPermanent, filePath, "url must use https", .redirectRejected)
|
|
706
|
+
return (.fallbackPermanent, filePath, "url must use https", .redirectRejected, nil)
|
|
691
707
|
}
|
|
692
708
|
|
|
693
709
|
// §5.8 (G7): at most one live run per destination. A second download() for
|
|
@@ -702,20 +718,21 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
702
718
|
// avoid join bookkeeping hangs.
|
|
703
719
|
if let existing = run(forKey: key), existing.filePath == filePath {
|
|
704
720
|
return (.fallbackTransient, filePath,
|
|
705
|
-
"another run is already active for this destination", .budgetExhausted)
|
|
721
|
+
"another run is already active for this destination", .budgetExhausted, nil)
|
|
706
722
|
}
|
|
707
723
|
|
|
708
724
|
let probe: ProbeResult
|
|
709
725
|
do {
|
|
710
726
|
probe = try await self.probe(url: url)
|
|
711
727
|
} catch {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
728
|
+
let retryable = Self.isRetryableNetworkError(error)
|
|
729
|
+
let klass: RangeDownloadClass = retryable ? .fallbackTransient : .fallbackPermanent
|
|
730
|
+
return (klass, filePath,
|
|
731
|
+
"probe failed: \(error.localizedDescription)", retryable ? .transientNetwork : nil, error)
|
|
715
732
|
}
|
|
716
733
|
guard probe.supportsRange, probe.total >= minBytes else {
|
|
717
734
|
return (.fallbackPermanent, filePath,
|
|
718
|
-
"range unsupported or file too small", .rangeUnsupported)
|
|
735
|
+
"range unsupported or file too small", .rangeUnsupported, nil)
|
|
719
736
|
}
|
|
720
737
|
|
|
721
738
|
let state = RunState(
|
|
@@ -742,7 +759,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
742
759
|
if let existing = runs[key], existing.filePath == filePath, existing !== state {
|
|
743
760
|
lock.unlock()
|
|
744
761
|
return (.fallbackTransient, filePath,
|
|
745
|
-
"another run is already active for this destination", .budgetExhausted)
|
|
762
|
+
"another run is already active for this destination", .budgetExhausted, nil)
|
|
746
763
|
}
|
|
747
764
|
runs[key] = state
|
|
748
765
|
lock.unlock()
|
|
@@ -773,26 +790,30 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
773
790
|
} else {
|
|
774
791
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
775
792
|
self.lock.lock()
|
|
793
|
+
guard self.runs[key] === state else {
|
|
794
|
+
self.lock.unlock()
|
|
795
|
+
cont.resume(throwing: CancellationError())
|
|
796
|
+
return
|
|
797
|
+
}
|
|
776
798
|
state.continuation = cont
|
|
777
799
|
self.lock.unlock()
|
|
778
800
|
reconcileAndStartTasks(state: state, url: url, ranges: ranges)
|
|
779
801
|
}
|
|
780
802
|
}
|
|
781
803
|
} catch let fb as FallbackError {
|
|
782
|
-
clearRun(key: key)
|
|
804
|
+
clearRun(key: key, state: state)
|
|
783
805
|
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
|
|
784
|
-
return (fb.kind.failureClass, filePath, fb.reason, fb.kind)
|
|
806
|
+
return (fb.kind.failureClass, filePath, fb.reason, fb.kind, fb.underlyingError)
|
|
785
807
|
} catch {
|
|
786
|
-
|
|
787
|
-
// (disk I/O, an interrupted segment) → ask the caller to retry the
|
|
788
|
-
// concurrent path; segments are retained for resume.
|
|
789
|
-
clearRun(key: key)
|
|
808
|
+
clearRun(key: key, state: state)
|
|
790
809
|
let reason = error.localizedDescription
|
|
791
810
|
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
|
|
792
|
-
|
|
811
|
+
let retryable = Self.isRetryableNetworkError(error)
|
|
812
|
+
let klass: RangeDownloadClass = retryable ? .fallbackTransient : .fallbackPermanent
|
|
813
|
+
return (klass, filePath, reason, retryable ? .transientNetwork : nil, error)
|
|
793
814
|
}
|
|
794
815
|
|
|
795
|
-
clearRun(key: key)
|
|
816
|
+
clearRun(key: key, state: state)
|
|
796
817
|
|
|
797
818
|
// Optional immediate SHA256 self-check backstop. When omitted, the caller
|
|
798
819
|
// verifies after the fact. A whole-file checksum mismatch is Permanent (§4):
|
|
@@ -805,16 +826,20 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
805
826
|
let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
|
|
806
827
|
OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
|
|
807
828
|
emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
|
|
808
|
-
return (.fallbackPermanent, filePath, reason, .checksumMismatch)
|
|
829
|
+
return (.fallbackPermanent, filePath, reason, .checksumMismatch, nil)
|
|
809
830
|
}
|
|
810
831
|
}
|
|
811
832
|
|
|
812
833
|
emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
|
|
813
|
-
return (.completed, filePath, nil, nil)
|
|
834
|
+
return (.completed, filePath, nil, nil, nil)
|
|
814
835
|
}
|
|
815
836
|
|
|
816
|
-
private func clearRun(key: String) {
|
|
817
|
-
lock.lock()
|
|
837
|
+
private func clearRun(key: String, state: RunState) {
|
|
838
|
+
lock.lock()
|
|
839
|
+
if runs[key] === state {
|
|
840
|
+
runs.removeValue(forKey: key)
|
|
841
|
+
}
|
|
842
|
+
lock.unlock()
|
|
818
843
|
}
|
|
819
844
|
|
|
820
845
|
// MARK: - Range planning / probing
|
|
@@ -823,13 +848,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
823
848
|
|
|
824
849
|
struct FallbackError: Error {
|
|
825
850
|
let reason: String
|
|
851
|
+
let underlyingError: Error?
|
|
826
852
|
/// §4 typed sub-class. Defaults to a Permanent `serverIgnoredRange` only so an
|
|
827
853
|
/// un-annotated legacy throw keeps the previous "discard + single-stream"
|
|
828
854
|
/// behavior; all new throw sites pass an explicit kind.
|
|
829
855
|
let kind: RangeFallbackClass
|
|
830
|
-
init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange
|
|
856
|
+
init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange,
|
|
857
|
+
underlyingError: Error? = nil) {
|
|
831
858
|
self.reason = reason
|
|
832
859
|
self.kind = kind
|
|
860
|
+
self.underlyingError = underlyingError
|
|
833
861
|
}
|
|
834
862
|
}
|
|
835
863
|
|
|
@@ -859,9 +887,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
859
887
|
let cr = http.value(forHTTPHeaderField: "Content-Range"),
|
|
860
888
|
let total = RangeDownloadLogic.parseContentRangeTotal(cr) {
|
|
861
889
|
cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true))
|
|
862
|
-
} else {
|
|
863
|
-
//
|
|
890
|
+
} else if http.statusCode == 200 || http.statusCode == 206 {
|
|
891
|
+
// Range ignored, or an unusable 206 → single-stream.
|
|
864
892
|
cont.resume(returning: ProbeResult(total: 0, etag: etag, supportsRange: false))
|
|
893
|
+
} else {
|
|
894
|
+
cont.resume(throwing: HTTPStatusError(statusCode: http.statusCode))
|
|
865
895
|
}
|
|
866
896
|
}
|
|
867
897
|
task.resume()
|
|
@@ -879,6 +909,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
879
909
|
let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
|
|
880
910
|
session.getAllTasks { [weak self] tasks in
|
|
881
911
|
guard let self = self else { return }
|
|
912
|
+
guard self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)) === state else { return }
|
|
882
913
|
var liveIndexes = Set<Int>()
|
|
883
914
|
for t in tasks {
|
|
884
915
|
guard let desc = t.taskDescription,
|
|
@@ -938,11 +969,17 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
938
969
|
// §5.4 (G2): the real task now exists and is visible to `getAllTasks`, so
|
|
939
970
|
// clear the pending-retry marker — the in-flight check is authoritative again.
|
|
940
971
|
lock.lock()
|
|
972
|
+
guard runs[Self.runKey(channel: state.channel, taskId: state.taskId)] === state,
|
|
973
|
+
state.continuation != nil else {
|
|
974
|
+
lock.unlock()
|
|
975
|
+
task.cancel()
|
|
976
|
+
return
|
|
977
|
+
}
|
|
941
978
|
if idx < state.segmentWritten.count { state.segmentWritten[idx] = 0 }
|
|
942
979
|
state.pendingRetryIndexes.remove(idx)
|
|
943
980
|
state.lastProgressAt = Date()
|
|
944
|
-
lock.unlock()
|
|
945
981
|
task.resume()
|
|
982
|
+
lock.unlock()
|
|
946
983
|
}
|
|
947
984
|
|
|
948
985
|
/// Marks a segment's finished body as a TRANSIENT HTTP outcome (§4) so
|
|
@@ -1230,6 +1267,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1230
1267
|
}
|
|
1231
1268
|
if http.statusCode != 206 {
|
|
1232
1269
|
let kind = RangeDownloadLogic.classifyStatus(http.statusCode)
|
|
1270
|
+
lock.withLockValue { state.httpStatus = http.statusCode }
|
|
1233
1271
|
if kind.failureClass == .fallbackTransient {
|
|
1234
1272
|
// §4 (416): a 416 is transient but the requested range may no longer be
|
|
1235
1273
|
// satisfiable (the object shrank/changed). Flag this segment for a size
|
|
@@ -1329,14 +1367,23 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1329
1367
|
if wasStall {
|
|
1330
1368
|
if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
|
|
1331
1369
|
finalizeTransientFallback(state: state, idx: idx, reason: "segment \(idx) stalled; retry budget exhausted", ranges: ranges)
|
|
1370
|
+
} else {
|
|
1371
|
+
// Explicit cancel removes the run before delegate delivery. A live
|
|
1372
|
+
// run receiving -999 is an interrupted background transfer.
|
|
1373
|
+
finishContinuation(state: state, with: error, ranges: ranges)
|
|
1332
1374
|
}
|
|
1333
1375
|
return
|
|
1334
1376
|
}
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1377
|
+
// Only known transport failures are retryable. TLS/security and local I/O
|
|
1378
|
+
// failures must retain their original type for the update caller.
|
|
1379
|
+
if !Self.isRetryableNetworkError(error) {
|
|
1380
|
+
finishContinuation(state: state, with: error, ranges: ranges)
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1337
1383
|
if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
|
|
1338
1384
|
finalizeTransientFallback(state: state, idx: idx,
|
|
1339
|
-
reason: error.localizedDescription, ranges: ranges
|
|
1385
|
+
reason: error.localizedDescription, ranges: ranges,
|
|
1386
|
+
underlyingError: error)
|
|
1340
1387
|
return
|
|
1341
1388
|
}
|
|
1342
1389
|
|
|
@@ -1368,11 +1415,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1368
1415
|
// A segment failed to stash (move/size-check failure in didFinishDownloadingTo).
|
|
1369
1416
|
// A short/truncated body is transient (the bytes will be re-fetched), so retry
|
|
1370
1417
|
// this segment under budget rather than failing the whole run.
|
|
1371
|
-
if lock.withLockValue({ state.stashError })
|
|
1372
|
-
lock.
|
|
1373
|
-
|
|
1374
|
-
finalizeTransientFallback(state: state, idx: idx,
|
|
1375
|
-
reason: "segment \(idx) could not be stashed; retry budget exhausted", ranges: ranges)
|
|
1418
|
+
if let stashError = lock.withLockValue({ state.stashError }) {
|
|
1419
|
+
lock.withLockValue { state.stashError = nil }
|
|
1420
|
+
finishContinuation(state: state, with: stashError, ranges: ranges)
|
|
1376
1421
|
return
|
|
1377
1422
|
}
|
|
1378
1423
|
|
|
@@ -1461,16 +1506,24 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1461
1506
|
case .checksumMismatch: reason = "whole-file checksum mismatch"
|
|
1462
1507
|
case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback"
|
|
1463
1508
|
}
|
|
1464
|
-
|
|
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)
|
|
1465
1514
|
}
|
|
1466
1515
|
|
|
1467
1516
|
/// §4 Transient: finalize the run as a RESUMABLE fallback. `.segN` files are
|
|
1468
1517
|
/// intentionally KEPT so the next concurrent attempt resumes only the missing
|
|
1469
1518
|
/// segment(s); never restart from byte 0 while resumable bytes exist.
|
|
1470
1519
|
private func finalizeTransientFallback(state: RunState, idx: Int, reason: String,
|
|
1471
|
-
ranges: [(start: Int64, end: Int64)]
|
|
1520
|
+
ranges: [(start: Int64, end: Int64)],
|
|
1521
|
+
underlyingError: Error? = nil) {
|
|
1522
|
+
let status = lock.withLockValue { state.httpStatus }
|
|
1523
|
+
let statusError = status.flatMap { $0 >= 400 ? HTTPStatusError(statusCode: $0) : nil }
|
|
1472
1524
|
finishContinuation(state: state,
|
|
1473
|
-
with: FallbackError(reason: reason, kind: .budgetExhausted
|
|
1525
|
+
with: FallbackError(reason: reason, kind: .budgetExhausted,
|
|
1526
|
+
underlyingError: statusError ?? underlyingError),
|
|
1474
1527
|
ranges: ranges)
|
|
1475
1528
|
}
|
|
1476
1529
|
|
|
@@ -1580,6 +1633,14 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1580
1633
|
public func cancel(channel: DownloadChannel, taskId: String,
|
|
1581
1634
|
filePath: String) async {
|
|
1582
1635
|
let session = session(forChannel: channel, segmentCount: Self.defaultSegmentCount)
|
|
1636
|
+
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
|
|
1640
|
+
state?.continuation = nil
|
|
1641
|
+
return pending
|
|
1642
|
+
}
|
|
1643
|
+
continuation?.resume(throwing: CancellationError())
|
|
1583
1644
|
// Cancel matching tasks first and wait for getAllTasks to return so the
|
|
1584
1645
|
// cancels have been issued before we touch the files.
|
|
1585
1646
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
@@ -1595,8 +1656,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
|
|
|
1595
1656
|
cont.resume()
|
|
1596
1657
|
}
|
|
1597
1658
|
}
|
|
1598
|
-
// Drop the run state so a late delegate callback can't re-stash a segment.
|
|
1599
|
-
clearRun(key: Self.runKey(channel: channel, taskId: taskId))
|
|
1600
1659
|
discardArtifacts(filePath: filePath)
|
|
1601
1660
|
}
|
|
1602
1661
|
|
package/package.json
CHANGED