@onekeyfe/react-native-range-downloader 3.0.180 → 6.7.0-10

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
  /**
@@ -96,7 +100,10 @@ class ConcurrentRangeDownloader(
96
100
  * segment files, so no in-flight worker resurrects a deleted file.
97
101
  */
98
102
  class CancelHandle {
103
+ // Only an explicit caller cancellation sets aborted. Worker failures use
104
+ // workerStop to stop sibling segments without changing that intent.
99
105
  val aborted = AtomicBoolean(false)
106
+ internal val workerStop = AtomicBoolean(false)
100
107
 
101
108
  @Volatile
102
109
  private var pool: java.util.concurrent.ExecutorService? = null
@@ -104,7 +111,7 @@ class ConcurrentRangeDownloader(
104
111
  internal fun attach(pool: java.util.concurrent.ExecutorService) {
105
112
  this.pool = pool
106
113
  // If cancel() already raced in before the pool was attached, honor it.
107
- if (aborted.get()) pool.shutdownNow()
114
+ if (workerStop.get()) pool.shutdownNow()
108
115
  }
109
116
 
110
117
  /**
@@ -117,6 +124,7 @@ class ConcurrentRangeDownloader(
117
124
  */
118
125
  fun cancel() {
119
126
  aborted.set(true)
127
+ workerStop.set(true)
120
128
  pool?.let { p ->
121
129
  p.shutdownNow()
122
130
  try {
@@ -144,12 +152,15 @@ class ConcurrentRangeDownloader(
144
152
  * Fills [partialFilePath] completely with the resource at [url] using
145
153
  * concurrent ranges. See [Outcome]. Throws on a transient/IO error after
146
154
  * per-segment retries, leaving the segment files in place so a later attempt
147
- * resumes.
155
+ * resumes. [maxTotalBytes] rejects a known oversized probe before workers
156
+ * or artifact writes; callers with unknown single-stream sizes enforce their
157
+ * own streaming limit.
148
158
  */
149
159
  fun download(
150
160
  url: String,
151
161
  partialFilePath: String,
152
162
  cancelHandle: CancelHandle? = null,
163
+ maxTotalBytes: Long = Long.MAX_VALUE,
153
164
  onProgress: (transferred: Long, total: Long) -> Unit,
154
165
  ): Outcome {
155
166
  val partialFile = File(partialFilePath)
@@ -164,6 +175,9 @@ class ConcurrentRangeDownloader(
164
175
  }
165
176
 
166
177
  val probe = probe(url) ?: return Outcome.FALLBACK
178
+ if (probe.totalSize > maxTotalBytes) {
179
+ throw SecurityException("Range download exceeds caller size limit")
180
+ }
167
181
  if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) {
168
182
  log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})")
169
183
  return Outcome.FALLBACK
@@ -211,9 +225,9 @@ class ConcurrentRangeDownloader(
211
225
  )
212
226
  onProgress(transferred.get(), total)
213
227
 
214
- // Share the abort flag with the cancel handle so an external cancel() is
215
- // observed by the per-segment loops; default to a private flag otherwise.
216
- val aborted = cancelHandle?.aborted ?: AtomicBoolean(false)
228
+ // External cancel also stops workers; internal fallback/failure must not
229
+ // mark the caller's explicit-cancellation flag.
230
+ val aborted = cancelHandle?.workerStop ?: AtomicBoolean(false)
217
231
  val fallback = AtomicBoolean(false)
218
232
  val firstError = AtomicReference<Exception?>(null)
219
233
 
@@ -233,11 +247,11 @@ class ConcurrentRangeDownloader(
233
247
  }
234
248
  } catch (e: FallbackException) {
235
249
  fallback.set(true)
236
- aborted.set(true)
237
250
  firstError.compareAndSet(null, e)
238
- } catch (e: Exception) {
239
251
  aborted.set(true)
252
+ } catch (e: Exception) {
240
253
  firstError.compareAndSet(null, e)
254
+ aborted.set(true)
241
255
  }
242
256
  }
243
257
  }
@@ -264,7 +278,7 @@ class ConcurrentRangeDownloader(
264
278
  .firstOrNull { segFile(it.index).length() != it.length }
265
279
  if (incomplete != null) {
266
280
  // Keep the segment files for the next attempt to resume.
267
- throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})")
281
+ throw ProtocolException("Concurrent download incomplete (segment ${incomplete.index})")
268
282
  }
269
283
 
270
284
  // All segments complete → assemble the `.partial`.
@@ -308,12 +322,12 @@ class ConcurrentRangeDownloader(
308
322
  val len = response.body?.contentLength() ?: -1L
309
323
  Probe(if (len > 0) len else 0, false)
310
324
  }
311
- else -> null
325
+ else -> throw classifyHttpFailure(response)
312
326
  }
313
327
  }
314
328
  } catch (e: Exception) {
315
329
  log("concurrent: probe failed: ${e.javaClass.simpleName}")
316
- null
330
+ throw e
317
331
  }
318
332
  }
319
333
 
@@ -404,10 +418,13 @@ class ConcurrentRangeDownloader(
404
418
  throw e
405
419
  } catch (e: PermanentHttpException) {
406
420
  // 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.
421
+ // attempt budget. Surface the typed HTTP status immediately.
409
422
  throw e
410
423
  } catch (e: Exception) {
424
+ val retryable = e is TransientHttpException ||
425
+ e is SocketTimeoutException || e is UnknownHostException ||
426
+ e is ConnectException || e is SocketException || e is ProtocolException
427
+ if (!retryable) throw e
411
428
  if (aborted.get() || retry >= maxPartRetry) throw e
412
429
  retry += 1
413
430
  // Prefer the server's Retry-After; otherwise exponential backoff
@@ -469,8 +486,8 @@ class ConcurrentRangeDownloader(
469
486
  if (response.code != 206) {
470
487
  // OCDS §4 classification. Anything that is not a usable 206 is
471
488
  // 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>.
489
+ // condition (retry with backoff). Both exceptions retain a
490
+ // typed status for native updater callers.
474
491
  throw classifyHttpFailure(response)
475
492
  }
476
493
  // 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
@@ -244,6 +293,67 @@ class ConcurrentRangeDownloaderOcdsTest {
244
293
  assertNoArtifactsLeft(partial)
245
294
  }
246
295
 
296
+ @Test
297
+ fun internalFallbackDoesNotMarkCallerCancelled() {
298
+ val target = parts[2]
299
+ startServer(RangeFaultServer(content).arm(
300
+ RangeFaultServer.FaultMode.STATUS_200, target.start, target.end, times = Int.MAX_VALUE,
301
+ ))
302
+ val handle = ConcurrentRangeDownloader.CancelHandle()
303
+
304
+ val outcome = newDownloader().download(url(), partialPath(), handle) { _, _ -> }
305
+
306
+ assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
307
+ assertFalse(handle.aborted.get())
308
+ assertNoArtifactsLeft(partialPath())
309
+ }
310
+
311
+ @Test
312
+ fun segment403PreservesHttpStatusWithoutMarkingCallerCancelled() {
313
+ val target = parts[2]
314
+ val normal = RangeFaultServer(content)
315
+ server = MockWebServer()
316
+ server.dispatcher = object : Dispatcher() {
317
+ override fun dispatch(request: RecordedRequest): MockResponse =
318
+ if (request.getHeader("Range") == "bytes=${target.start}-${target.end}") {
319
+ MockResponse().setResponseCode(403)
320
+ } else {
321
+ normal.dispatcher.dispatch(request)
322
+ }
323
+ }
324
+ server.start()
325
+ val handle = ConcurrentRangeDownloader.CancelHandle()
326
+
327
+ val failure = runCatching {
328
+ newDownloader().download(url(), partialPath(), handle) { _, _ -> }
329
+ }.exceptionOrNull()
330
+
331
+ assertTrue(failure is ConcurrentRangeDownloader.PermanentHttpException)
332
+ assertEquals(403, (failure as ConcurrentRangeDownloader.PermanentHttpException).code)
333
+ assertFalse(handle.aborted.get())
334
+ }
335
+
336
+ @Test
337
+ fun exhaustedSocketTimeoutDoesNotMarkCallerCancelled() {
338
+ val target = parts[2]
339
+ startServer(RangeFaultServer(content).arm(
340
+ RangeFaultServer.FaultMode.STALL, target.start, target.end, times = Int.MAX_VALUE,
341
+ ))
342
+ val handle = ConcurrentRangeDownloader.CancelHandle()
343
+ val client = OkHttpClient.Builder().readTimeout(200, TimeUnit.MILLISECONDS).build()
344
+ val downloader = ConcurrentRangeDownloader(client, maxPartRetry = 0)
345
+
346
+ val failure = runCatching {
347
+ downloader.download(url(), partialPath(), handle) { _, _ -> }
348
+ }.exceptionOrNull()
349
+
350
+ assertTrue(
351
+ "expected a socket timeout, got ${failure?.javaClass?.name}: ${failure?.message}",
352
+ failure is java.net.SocketTimeoutException,
353
+ )
354
+ assertFalse(handle.aborted.get())
355
+ }
356
+
247
357
  // ------------------------------------------------------------------------
248
358
  // OCDS-T4: 429 (+Retry-After: 0) once on a segment → transient retry succeeds.
249
359
  // SHA correct; the affected segment requested twice; no from-0 restart.
@@ -387,17 +497,17 @@ class ConcurrentRangeDownloaderOcdsTest {
387
497
  val partial = partialPath()
388
498
  // First invocation: the short body leaves segment 6 one byte short, so this
389
499
  // call throws "incomplete" and keeps the `.segN` for resume.
390
- var firstThrew = false
391
- try {
500
+ val handle = ConcurrentRangeDownloader.CancelHandle()
501
+ val firstFailure = runCatching {
392
502
  newDownloader().download(
393
503
  url = url(),
394
504
  partialFilePath = partial,
505
+ cancelHandle = handle,
395
506
  onProgress = { _, _ -> },
396
507
  )
397
- } catch (e: Exception) {
398
- firstThrew = true
399
- }
400
- 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())
401
511
  // The short segment is kept on disk, one byte under its planned length.
402
512
  val seg = segFile(partial, target.index)
403
513
  assertTrue("short `.segN` must be kept for resume", seg.exists())
@@ -483,6 +593,7 @@ class ConcurrentRangeDownloaderOcdsTest {
483
593
  worker.shutdownNow()
484
594
 
485
595
  assertTrue("cancel must abort promptly (took ${elapsed}ms)", elapsed < 12_000)
596
+ assertTrue("explicit cancellation must mark caller intent", handle.aborted.get())
486
597
 
487
598
  // cancel-then-delete: now that workers are terminated, deleting the artifacts
488
599
  // must STICK — no straggler worker resurrects a `.segN`.
@@ -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,11 +374,22 @@ 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
376
389
  /// global run state to support concurrent channels.
377
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>]] = [:]
378
393
 
379
394
  /// Lazy per-channel background sessions, cached by identifier.
380
395
  private var sessions: [String: URLSession] = [:]
@@ -400,6 +415,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
400
415
  var ranges: [(start: Int64, end: Int64)] = []
401
416
  var segmentWritten: [Int64] = [] // progress estimate per segment
402
417
  var continuation: CheckedContinuation<Void, Error>?
418
+ var ownedTaskIdentifiers = Set<Int>()
419
+ var activeFileMutations = 0
420
+ var fileMutationWaiters: [CheckedContinuation<Void, Never>] = []
403
421
  var prevProgress = -1
404
422
  var fellBack = false
405
423
  /// §4 typed class for the eventual fallback. Set by the delegate when it
@@ -411,6 +429,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
411
429
  /// Set when a segment could not be stashed (move/size-check failure). Carries
412
430
  /// the terminal error so didCompleteWithError finalizes instead of hanging.
413
431
  var stashError: Error?
432
+ var httpStatus: Int?
414
433
  /// §4/§5.4: per-segment indexes whose finished body was a TRANSIENT HTTP
415
434
  /// status (429/5xx/408/416) — the body is discarded and the segment is
416
435
  /// re-enqueued (G2) instead of failing the whole run. Optional Retry-After
@@ -604,8 +623,10 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
604
623
  Array(self.listeners.values)
605
624
  }
606
625
  guard !snapshot.isEmpty else { return }
626
+ let wireMessage = channel.stringValue == DownloadChannel.bundle.stringValue && type == "fallback"
627
+ ? "" : message
607
628
  let event = RangeDownloadEvent(
608
- channel: channel, taskId: taskId, type: type, progress: progress, message: message
629
+ channel: channel, taskId: taskId, type: type, progress: progress, message: wireMessage
609
630
  )
610
631
  for cb in snapshot { cb(event) }
611
632
  }
@@ -638,11 +659,51 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
638
659
  lock.withLockValue { self.runs[key] }
639
660
  }
640
661
 
641
- 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 }
642
665
  guard let decoded = Self.decodeTaskDescription(desc) else { return nil }
643
666
  let key = Self.runKey(channel: decoded.channel, taskId: decoded.taskId)
644
- guard let r = run(forKey: key) else { return nil }
645
- 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() }
646
707
  }
647
708
 
648
709
  // MARK: - Public entry
@@ -665,7 +726,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
665
726
  requestTimeoutSeconds: Double? = nil,
666
727
  stallTimeoutSeconds: Double? = nil,
667
728
  overallDeadlineSeconds: Double? = nil
668
- ) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?) {
729
+ ) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?, Error?) {
669
730
  let segCount = max(1, segmentCount ?? Self.defaultSegmentCount)
670
731
  let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes
671
732
  let maxAttempts = max(1, maxSegmentAttempts ?? Self.defaultMaxSegmentAttempts)
@@ -683,11 +744,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
683
744
  let key = Self.runKey(channel: channel, taskId: taskId)
684
745
 
685
746
  guard let url = URL(string: urlString) else {
686
- return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported)
747
+ return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported, URLError(.badURL))
687
748
  }
688
749
  // HTTPS-only: background URLSession + transport hardening.
689
750
  guard urlString.hasPrefix("https://") else {
690
- return (.fallbackPermanent, filePath, "url must use https", .redirectRejected)
751
+ return (.fallbackPermanent, filePath, "url must use https", .redirectRejected, nil)
691
752
  }
692
753
 
693
754
  // §5.8 (G7): at most one live run per destination. A second download() for
@@ -700,22 +761,23 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
700
761
  // a half-initialized run was invisible and a 2nd download() could overwrite
701
762
  // `runs[key]`. We fail fast (the caller retry loop re-drives cleanly) to
702
763
  // avoid join bookkeeping hangs.
703
- if let existing = run(forKey: key), existing.filePath == filePath {
764
+ if lock.withLockValue({ runs[key] != nil || cancellationWaiters[key] != nil }) {
704
765
  return (.fallbackTransient, filePath,
705
- "another run is already active for this destination", .budgetExhausted)
766
+ "another run is already active for this destination", .budgetExhausted, nil)
706
767
  }
707
768
 
708
769
  let probe: ProbeResult
709
770
  do {
710
771
  probe = try await self.probe(url: url)
711
772
  } catch {
712
- // A probe that cannot reach the server is a transient network condition.
713
- return (.fallbackTransient, filePath,
714
- "probe failed: \(error.localizedDescription)", .transientNetwork)
773
+ let retryable = Self.isRetryableNetworkError(error)
774
+ let klass: RangeDownloadClass = retryable ? .fallbackTransient : .fallbackPermanent
775
+ return (klass, filePath,
776
+ "probe failed: \(error.localizedDescription)", retryable ? .transientNetwork : nil, error)
715
777
  }
716
778
  guard probe.supportsRange, probe.total >= minBytes else {
717
779
  return (.fallbackPermanent, filePath,
718
- "range unsupported or file too small", .rangeUnsupported)
780
+ "range unsupported or file too small", .rangeUnsupported, nil)
719
781
  }
720
782
 
721
783
  let state = RunState(
@@ -739,10 +801,10 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
739
801
  // half-initialized run (continuation not yet assigned, or the synchronous
740
802
  // fast path that never assigns one) is still observed as live by any racer.
741
803
  lock.lock()
742
- if let existing = runs[key], existing.filePath == filePath, existing !== state {
804
+ if runs[key] != nil || cancellationWaiters[key] != nil {
743
805
  lock.unlock()
744
806
  return (.fallbackTransient, filePath,
745
- "another run is already active for this destination", .budgetExhausted)
807
+ "another run is already active for this destination", .budgetExhausted, nil)
746
808
  }
747
809
  runs[key] = state
748
810
  lock.unlock()
@@ -754,67 +816,99 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
754
816
  // attempt are untrustworthy. Start fresh and only proceed on the resumable
755
817
  // path when expectedSha256 will gate the assembled file (verified below).
756
818
  if !state.hasValidator {
819
+ guard beginFileMutation(state) else {
820
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
821
+ }
757
822
  cleanupSegments(state: state, ranges: ranges)
823
+ endFileMutation(state)
758
824
  }
759
825
 
760
- 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
+ }
761
829
 
762
830
  // §5.4: start the bytes-stalled watchdog for this run. It cancels a stalled
763
831
  // segment task so didCompleteWithError routes it through the in-place retry
764
832
  // path. It only counts foreground/active wall time toward the stall window so
765
833
  // a legitimately suspended background transfer (§5.10) is never false-cancelled.
766
- startStallWatchdog(key: key, stallSeconds: stallSeconds)
834
+ startStallWatchdog(state: state, stallSeconds: stallSeconds)
767
835
 
768
836
  do {
769
837
  // If every segment is already on disk (resume after suspension/kill), skip
770
838
  // straight to concatenation.
771
839
  if allSegmentsPresent(state: state, ranges: ranges) {
840
+ guard beginFileMutation(state) else { throw CancellationError() }
841
+ defer { endFileMutation(state) }
772
842
  try concatenateAndFinish(state: state, ranges: ranges)
773
843
  } else {
774
844
  try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
775
845
  self.lock.lock()
846
+ guard self.runs[key] === state,
847
+ self.cancellationWaiters[key] == nil else {
848
+ self.lock.unlock()
849
+ cont.resume(throwing: CancellationError())
850
+ return
851
+ }
776
852
  state.continuation = cont
777
853
  self.lock.unlock()
778
854
  reconcileAndStartTasks(state: state, url: url, ranges: ranges)
779
855
  }
780
856
  }
781
857
  } catch let fb as FallbackError {
782
- clearRun(key: key)
783
- emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
784
- return (fb.kind.failureClass, filePath, fb.reason, fb.kind)
858
+ if clearRun(key: key, state: state) {
859
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
860
+ }
861
+ return (fb.kind.failureClass, filePath, fb.reason, fb.kind, fb.underlyingError)
785
862
  } 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)
790
863
  let reason = error.localizedDescription
791
- emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
792
- return (.fallbackTransient, filePath, reason, .transientNetwork)
864
+ if clearRun(key: key, state: state) {
865
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
866
+ }
867
+ let retryable = Self.isRetryableNetworkError(error)
868
+ let klass: RangeDownloadClass = retryable ? .fallbackTransient : .fallbackPermanent
869
+ return (klass, filePath, reason, retryable ? .transientNetwork : nil, error)
793
870
  }
794
871
 
795
- clearRun(key: key)
796
-
797
872
  // Optional immediate SHA256 self-check backstop. When omitted, the caller
798
873
  // verifies after the fact. A whole-file checksum mismatch is Permanent (§4):
799
874
  // the assembled bytes are unsalvageable, so discard final + artifacts.
800
875
  if let expected = expectedSha256, !expected.isEmpty {
876
+ guard beginFileMutation(state) else {
877
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
878
+ }
801
879
  let actual = RangeDownloadLogic.calculateSHA256(filePath)
802
880
  if actual?.lowercased() != expected.lowercased() {
803
881
  try? FileManager.default.removeItem(atPath: filePath)
804
882
  discardArtifacts(filePath: filePath)
883
+ endFileMutation(state)
884
+ guard clearRun(key: key, state: state) else {
885
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
886
+ }
805
887
  let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
806
888
  OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
807
889
  emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
808
- return (.fallbackPermanent, filePath, reason, .checksumMismatch)
890
+ return (.fallbackPermanent, filePath, reason, .checksumMismatch, nil)
809
891
  }
892
+ endFileMutation(state)
810
893
  }
811
894
 
895
+ guard clearRun(key: key, state: state) else {
896
+ return (.fallbackTransient, filePath, "cancelled", .transientNetwork, CancellationError())
897
+ }
812
898
  emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
813
- return (.completed, filePath, nil, nil)
899
+ return (.completed, filePath, nil, nil, nil)
814
900
  }
815
901
 
816
- private func clearRun(key: String) {
817
- lock.lock(); runs.removeValue(forKey: key); lock.unlock()
902
+ @discardableResult
903
+ private func clearRun(key: String, state: RunState) -> Bool {
904
+ lock.lock()
905
+ if runs[key] === state && cancellationWaiters[key] == nil {
906
+ runs.removeValue(forKey: key)
907
+ lock.unlock()
908
+ return true
909
+ }
910
+ lock.unlock()
911
+ return false
818
912
  }
819
913
 
820
914
  // MARK: - Range planning / probing
@@ -823,13 +917,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
823
917
 
824
918
  struct FallbackError: Error {
825
919
  let reason: String
920
+ let underlyingError: Error?
826
921
  /// §4 typed sub-class. Defaults to a Permanent `serverIgnoredRange` only so an
827
922
  /// un-annotated legacy throw keeps the previous "discard + single-stream"
828
923
  /// behavior; all new throw sites pass an explicit kind.
829
924
  let kind: RangeFallbackClass
830
- init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange) {
925
+ init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange,
926
+ underlyingError: Error? = nil) {
831
927
  self.reason = reason
832
928
  self.kind = kind
929
+ self.underlyingError = underlyingError
833
930
  }
834
931
  }
835
932
 
@@ -859,9 +956,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
859
956
  let cr = http.value(forHTTPHeaderField: "Content-Range"),
860
957
  let total = RangeDownloadLogic.parseContentRangeTotal(cr) {
861
958
  cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true))
862
- } else {
863
- // 200 (Range ignored) or anything else → single-stream.
959
+ } else if http.statusCode == 200 || http.statusCode == 206 {
960
+ // Range ignored, or an unusable 206 → single-stream.
864
961
  cont.resume(returning: ProbeResult(total: 0, etag: etag, supportsRange: false))
962
+ } else {
963
+ cont.resume(throwing: HTTPStatusError(statusCode: http.statusCode))
865
964
  }
866
965
  }
867
966
  task.resume()
@@ -879,6 +978,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
879
978
  let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
880
979
  session.getAllTasks { [weak self] tasks in
881
980
  guard let self = self else { return }
981
+ guard self.isActive(state) else { return }
882
982
  var liveIndexes = Set<Int>()
883
983
  for t in tasks {
884
984
  guard let desc = t.taskDescription,
@@ -897,10 +997,17 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
897
997
  if let turl = t.originalRequest?.url,
898
998
  turl.absoluteString == url.absoluteString,
899
999
  t.state == .running || t.state == .suspended {
900
- 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) }
901
1008
  } else {
902
1009
  // Same run id but different URL (etag/url changed) — drop it.
903
- t.cancel()
1010
+ if self.isActive(state) { t.cancel() }
904
1011
  }
905
1012
  }
906
1013
  // Tasks for other taskIds on the same channel are left running so
@@ -938,11 +1045,19 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
938
1045
  // §5.4 (G2): the real task now exists and is visible to `getAllTasks`, so
939
1046
  // clear the pending-retry marker — the in-flight check is authoritative again.
940
1047
  lock.lock()
1048
+ guard runs[Self.runKey(channel: state.channel, taskId: state.taskId)] === state,
1049
+ cancellationWaiters[Self.runKey(channel: state.channel, taskId: state.taskId)] == nil,
1050
+ state.continuation != nil else {
1051
+ lock.unlock()
1052
+ task.cancel()
1053
+ return
1054
+ }
941
1055
  if idx < state.segmentWritten.count { state.segmentWritten[idx] = 0 }
942
1056
  state.pendingRetryIndexes.remove(idx)
943
1057
  state.lastProgressAt = Date()
944
- lock.unlock()
1058
+ state.ownedTaskIdentifiers.insert(task.taskIdentifier)
945
1059
  task.resume()
1060
+ lock.unlock()
946
1061
  }
947
1062
 
948
1063
  /// Marks a segment's finished body as a TRANSIENT HTTP outcome (§4) so
@@ -968,7 +1083,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
968
1083
  // completion driving the missing-segment re-enqueue — must NOT bump the
969
1084
  // counter again nor arm a duplicate asyncAfter. Returning `true` here reports
970
1085
  // "a retry is in flight for this segment" without scheduling a second one.
971
- 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 }
972
1089
  if state.pendingRetryIndexes.contains(idx) {
973
1090
  return (true, state.segmentAttempts[idx] ?? 0)
974
1091
  }
@@ -977,6 +1094,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
977
1094
  state.pendingRetryIndexes.insert(idx)
978
1095
  return (false, n)
979
1096
  }
1097
+ guard let claim = claim else { return false }
980
1098
  if claim.alreadyPending { return true }
981
1099
  let attempts = claim.attempts
982
1100
  if attempts > state.maxSegmentAttempts {
@@ -1007,8 +1125,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1007
1125
  // in the meantime, and guard against a double-enqueue. On every bail path
1008
1126
  // clear the pending-retry marker (G2) so it doesn't leak: either the slot
1009
1127
  // is moot (run gone / segment landed) or a real task already exists.
1010
- guard let live = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)),
1011
- live === state else {
1128
+ guard self.isActive(state) else {
1012
1129
  self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
1013
1130
  return
1014
1131
  }
@@ -1018,6 +1135,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1018
1135
  }
1019
1136
  session.getAllTasks { tasks in
1020
1137
  let alreadyLive = tasks.contains { t in
1138
+ guard self.owns(t, state: state) else { return false }
1021
1139
  guard let d = t.taskDescription,
1022
1140
  let decoded = Self.decodeTaskDescription(d),
1023
1141
  decoded.channel.stringValue == channelStr,
@@ -1065,7 +1183,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1065
1183
  Task { [weak self] in
1066
1184
  guard let self = self else { return }
1067
1185
  // Re-confirm the run is still live before acting on the re-probe.
1068
- let stillLive = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)).map { $0 === state } ?? false
1186
+ let stillLive = self.isActive(state)
1069
1187
  guard stillLive else { return }
1070
1188
  let probe: ProbeResult
1071
1189
  do {
@@ -1118,15 +1236,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1118
1236
  /// in-flight task, so a suspended background transfer (which makes no JS/main
1119
1237
  /// progress while suspended) is not false-cancelled because the watchdog timer
1120
1238
  /// itself is also suspended with the app.
1121
- private func startStallWatchdog(key: String, stallSeconds: Double) {
1239
+ private func startStallWatchdog(state: RunState, stallSeconds: Double) {
1122
1240
  let interval = max(5.0, stallSeconds / 2.0)
1123
- DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
1124
- 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)
1125
1244
  }
1126
1245
  }
1127
1246
 
1128
- private func stallWatchdogTick(key: String, stallSeconds: Double, interval: Double) {
1129
- guard let state = run(forKey: key),
1247
+ private func stallWatchdogTick(state: RunState, stallSeconds: Double, interval: Double) {
1248
+ guard isActive(state),
1130
1249
  lock.withLockValue({ state.continuation != nil }) else {
1131
1250
  return // run finalized / cancelled — stop.
1132
1251
  }
@@ -1138,6 +1257,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1138
1257
  let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
1139
1258
  session.getAllTasks { tasks in
1140
1259
  for t in tasks {
1260
+ guard self.owns(t, state: state) else { continue }
1141
1261
  guard let d = t.taskDescription,
1142
1262
  let decoded = Self.decodeTaskDescription(d),
1143
1263
  decoded.channel.stringValue == channelStr,
@@ -1156,8 +1276,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1156
1276
  lock.lock(); state.lastProgressAt = Date(); lock.unlock()
1157
1277
  }
1158
1278
  // Reschedule.
1159
- DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
1160
- 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)
1161
1282
  }
1162
1283
  }
1163
1284
 
@@ -1180,8 +1301,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1180
1301
  public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
1181
1302
  didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
1182
1303
  totalBytesExpectedToWrite: Int64) {
1183
- guard let desc = downloadTask.taskDescription,
1184
- let (state, idx) = run(for: desc) else { return }
1304
+ guard let (state, idx) = run(for: downloadTask, in: session) else { return }
1185
1305
  lock.lock()
1186
1306
  if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten }
1187
1307
  // §5.4: stamp last-progress for the stall watchdog. Any byte on any segment
@@ -1202,7 +1322,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1202
1322
  let channel = state.channel
1203
1323
  let taskId = state.taskId
1204
1324
  lock.unlock()
1205
- if emit {
1325
+ if emit && isActive(state) {
1206
1326
  self.emit(channel: channel, taskId: taskId, type: "progress",
1207
1327
  progress: Double(progressValue), message: "")
1208
1328
  }
@@ -1210,8 +1330,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1210
1330
 
1211
1331
  public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
1212
1332
  didFinishDownloadingTo location: URL) {
1213
- guard let desc = downloadTask.taskDescription,
1214
- let (state, idx) = run(for: desc) else { return }
1333
+ guard let (state, idx) = run(for: downloadTask, in: session) else { return }
1215
1334
  let ranges = lock.withLockValue { state.ranges }
1216
1335
  guard idx < ranges.count else { return }
1217
1336
  let range = ranges[idx]
@@ -1230,6 +1349,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1230
1349
  }
1231
1350
  if http.statusCode != 206 {
1232
1351
  let kind = RangeDownloadLogic.classifyStatus(http.statusCode)
1352
+ lock.withLockValue { state.httpStatus = http.statusCode }
1233
1353
  if kind.failureClass == .fallbackTransient {
1234
1354
  // §4 (416): a 416 is transient but the requested range may no longer be
1235
1355
  // satisfiable (the object shrank/changed). Flag this segment for a size
@@ -1277,6 +1397,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1277
1397
  // Validate the downloaded body length BEFORE moving it into place: a
1278
1398
  // truncated 206 must never be stashed as a valid segment.
1279
1399
  let dest = state.segPath(idx)
1400
+ guard beginFileMutation(state) else { return }
1401
+ defer { endFileMutation(state) }
1280
1402
  do {
1281
1403
  let attrs = try FileManager.default.attributesOfItem(atPath: location.path)
1282
1404
  let size = attrs[.size] as? Int64
@@ -1308,8 +1430,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1308
1430
  }
1309
1431
 
1310
1432
  public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
1311
- guard let desc = task.taskDescription,
1312
- 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) } }
1313
1435
  let ranges = lock.withLockValue { state.ranges }
1314
1436
 
1315
1437
  // A Permanent classification anywhere in the run (didFinishDownloadingTo set
@@ -1329,14 +1451,23 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1329
1451
  if wasStall {
1330
1452
  if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
1331
1453
  finalizeTransientFallback(state: state, idx: idx, reason: "segment \(idx) stalled; retry budget exhausted", ranges: ranges)
1454
+ } else {
1455
+ // Explicit cancel removes the run before delegate delivery. A live
1456
+ // run receiving -999 is an interrupted background transfer.
1457
+ finishContinuation(state: state, with: error, ranges: ranges)
1332
1458
  }
1333
1459
  return
1334
1460
  }
1335
- // §4: connection lost / timeout / DNS / TLS → Transient. Retry THIS segment
1336
- // in place under its budget; keep the others and their `.segN`.
1461
+ // Only known transport failures are retryable. TLS/security and local I/O
1462
+ // failures must retain their original type for the update caller.
1463
+ if !Self.isRetryableNetworkError(error) {
1464
+ finishContinuation(state: state, with: error, ranges: ranges)
1465
+ return
1466
+ }
1337
1467
  if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
1338
1468
  finalizeTransientFallback(state: state, idx: idx,
1339
- reason: error.localizedDescription, ranges: ranges)
1469
+ reason: error.localizedDescription, ranges: ranges,
1470
+ underlyingError: error)
1340
1471
  return
1341
1472
  }
1342
1473
 
@@ -1368,11 +1499,9 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1368
1499
  // A segment failed to stash (move/size-check failure in didFinishDownloadingTo).
1369
1500
  // A short/truncated body is transient (the bytes will be re-fetched), so retry
1370
1501
  // 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)
1502
+ if let stashError = lock.withLockValue({ state.stashError }) {
1503
+ lock.withLockValue { state.stashError = nil }
1504
+ finishContinuation(state: state, with: stashError, ranges: ranges)
1376
1505
  return
1377
1506
  }
1378
1507
 
@@ -1390,6 +1519,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1390
1519
  session.getAllTasks { [weak self] tasks in
1391
1520
  guard let self = self else { return }
1392
1521
  let stillInFlight = tasks.contains { t in
1522
+ guard self.owns(t, state: state) else { return false }
1393
1523
  guard let d = t.taskDescription,
1394
1524
  let decoded = Self.decodeTaskDescription(d),
1395
1525
  decoded.channel.stringValue == channel.stringValue,
@@ -1439,8 +1569,10 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1439
1569
  let channel = state.channel
1440
1570
  let taskId = state.taskId
1441
1571
  let kind = lock.withLockValue { state.fellBackKind }
1442
- session.getAllTasks { tasks in
1572
+ session.getAllTasks { [weak self] tasks in
1573
+ guard let self = self, self.isActive(state) else { return }
1443
1574
  for t in tasks {
1575
+ guard self.owns(t, state: state) else { continue }
1444
1576
  if let d = t.taskDescription,
1445
1577
  let decoded = Self.decodeTaskDescription(d),
1446
1578
  decoded.channel.stringValue == channel.stringValue,
@@ -1448,29 +1580,40 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1448
1580
  t.cancel()
1449
1581
  }
1450
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)
1451
1603
  }
1452
- cleanupSegments(state: state, ranges: ranges)
1453
- let reason: String
1454
- switch kind {
1455
- case .serverIgnoredRange: reason = "server returned 200 to a Range request"
1456
- case .multipartOrBadTotal: reason = "non-conforming 206 (multipart / range / total mismatch)"
1457
- case .authExpired: reason = "auth expired (401/403); fetch a fresh signed URL"
1458
- case .notFound: reason = "object not found (404/410)"
1459
- case .redirectRejected: reason = "rejected non-HTTPS redirect"
1460
- case .rangeUnsupported: reason = "range unsupported / non-retryable status"
1461
- case .checksumMismatch: reason = "whole-file checksum mismatch"
1462
- case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback"
1463
- }
1464
- finishContinuation(state: state, with: FallbackError(reason: reason, kind: kind), ranges: ranges)
1465
1604
  }
1466
1605
 
1467
1606
  /// §4 Transient: finalize the run as a RESUMABLE fallback. `.segN` files are
1468
1607
  /// intentionally KEPT so the next concurrent attempt resumes only the missing
1469
1608
  /// segment(s); never restart from byte 0 while resumable bytes exist.
1470
1609
  private func finalizeTransientFallback(state: RunState, idx: Int, reason: String,
1471
- ranges: [(start: Int64, end: Int64)]) {
1610
+ ranges: [(start: Int64, end: Int64)],
1611
+ underlyingError: Error? = nil) {
1612
+ let status = lock.withLockValue { state.httpStatus }
1613
+ let statusError = status.flatMap { $0 >= 400 ? HTTPStatusError(statusCode: $0) : nil }
1472
1614
  finishContinuation(state: state,
1473
- with: FallbackError(reason: reason, kind: .budgetExhausted),
1615
+ with: FallbackError(reason: reason, kind: .budgetExhausted,
1616
+ underlyingError: statusError ?? underlyingError),
1474
1617
  ranges: ranges)
1475
1618
  }
1476
1619
 
@@ -1485,7 +1628,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1485
1628
  completionHandler: @escaping (URLRequest?) -> Void) {
1486
1629
  if request.url?.scheme?.lowercased() != "https" {
1487
1630
  OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL")
1488
- if let desc = task.taskDescription, let (state, _) = run(for: desc) {
1631
+ if let (state, _) = run(for: task, in: session) {
1489
1632
  lock.lock(); state.fellBack = true; state.fellBackKind = .redirectRejected; lock.unlock()
1490
1633
  }
1491
1634
  completionHandler(nil)
@@ -1521,10 +1664,16 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1521
1664
  }
1522
1665
  guard let cont = cont else { return }
1523
1666
  if let error = error { cont.resume(throwing: error); return }
1667
+ guard beginFileMutation(state) else {
1668
+ cont.resume(throwing: CancellationError())
1669
+ return
1670
+ }
1524
1671
  do {
1525
1672
  try concatenateAndFinish(state: state, ranges: ranges)
1673
+ endFileMutation(state)
1526
1674
  cont.resume(returning: ())
1527
1675
  } catch {
1676
+ endFileMutation(state)
1528
1677
  cont.resume(throwing: error)
1529
1678
  }
1530
1679
  }
@@ -1580,8 +1729,34 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1580
1729
  public func cancel(channel: DownloadChannel, taskId: String,
1581
1730
  filePath: String) async {
1582
1731
  let session = session(forChannel: channel, segmentCount: Self.defaultSegmentCount)
1732
+ let key = Self.runKey(channel: channel, taskId: taskId)
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
1740
+ state?.continuation = nil
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
1755
+ }
1756
+ pending?.resume(throwing: CancellationError())
1583
1757
  // Cancel matching tasks first and wait for getAllTasks to return so the
1584
- // 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.
1585
1760
  await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1586
1761
  session.getAllTasks { tasks in
1587
1762
  for t in tasks {
@@ -1595,9 +1770,29 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
1595
1770
  cont.resume()
1596
1771
  }
1597
1772
  }
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))
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
+ }
1600
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() }
1601
1796
  }
1602
1797
 
1603
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": "3.0.180",
3
+ "version": "6.7.0-10",
4
4
  "description": "react-native-range-downloader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",