@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.
@@ -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. The `HTTP <code>` message shape is preserved so the JS error
78
- * taxonomy ([updateErrorTaxonomy.ts]) maps it to `HTTP_<code>`.
82
+ * budget. Native updater callers can read [code] without parsing prose.
79
83
  */
80
- private class PermanentHttpException(val code: Int) :
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. Keeps the
87
- * `HTTP <code>` message shape for the JS taxonomy.
90
+ * in [downloadSegment] prefers this over its computed backoff. Native
91
+ * updater callers can read [code] without parsing prose.
88
92
  */
89
- private class TransientHttpException(val code: Int, val retryAfterMillis: Long?) :
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 -> null
321
+ else -> throw classifyHttpFailure(response)
312
322
  }
313
323
  }
314
324
  } catch (e: Exception) {
315
325
  log("concurrent: probe failed: ${e.javaClass.simpleName}")
316
- null
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 immediately; the caller maps the
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). The `HTTP <code>` message shape
473
- // is preserved on both so the JS taxonomy maps it to HTTP_<code>.
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: 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
- // A probe that cannot reach the server is a transient network condition.
713
- return (.fallbackTransient, filePath,
714
- "probe failed: \(error.localizedDescription)", .transientNetwork)
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
- // A non-FallbackError thrown out of the run is a local/transient condition
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
- return (.fallbackTransient, filePath, reason, .transientNetwork)
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(); runs.removeValue(forKey: key); lock.unlock()
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
- // 200 (Range ignored) or anything else → single-stream.
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
- // §4: connection lost / timeout / DNS / TLS → Transient. Retry THIS segment
1336
- // in place under its budget; keep the others and their `.segN`.
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 }) != nil {
1372
- lock.lock(); state.stashError = nil; lock.unlock()
1373
- if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
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
- finishContinuation(state: state, with: FallbackError(reason: reason, kind: kind), ranges: ranges)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-range-downloader",
3
- "version": "6.7.0-1",
3
+ "version": "6.7.0-alpha.311",
4
4
  "description": "react-native-range-downloader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",