@onekeyfe/react-native-range-downloader 3.0.66 → 3.0.68

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.
Files changed (44) hide show
  1. package/README.md +15 -0
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
  10. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
  11. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
  12. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
  13. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
  14. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
  15. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
  16. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
  17. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
  18. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
  19. package/ios/RangeDownloadLogic.swift +187 -0
  20. package/ios/ReactNativeRangeDownloader.swift +669 -133
  21. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
  22. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
  24. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
  25. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
  26. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
  27. package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
  32. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
  33. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
  34. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
  35. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
  36. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
  37. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
  38. package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
  39. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
  40. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
  41. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
  42. package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
  43. package/package.json +1 -1
  44. package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
@@ -1,5 +1,4 @@
1
1
  import Foundation
2
- import CommonCrypto
3
2
  import NitroModules
4
3
  import ReactNativeNativeLogger
5
4
 
@@ -14,24 +13,38 @@ import ReactNativeNativeLogger
14
13
  // - one hardcoded background session identifier → one session per channel;
15
14
  // - `onProgress` closure → a listener registry broadcasting
16
15
  // `RangeDownloadEvent`s (multi-consumer);
17
- // - `throws FallbackError` → returns `RangeDownloadResult(.fallback, …)`.
16
+ // - `throws FallbackError` → returns a typed `RangeDownloadResult`
17
+ // (`fallbackTransient` / `fallbackPermanent` + `fallbackKind`).
18
18
  class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
19
19
 
20
20
  func download(params: RangeDownloadParams) throws -> Promise<RangeDownloadResult> {
21
21
  return Promise.async {
22
- let (outcome, filePath, fallbackReason) = await RangeDownloader.shared.download(
22
+ let (klass, filePath, fallbackReason, fallbackClass) = await RangeDownloader.shared.download(
23
23
  channel: params.channel,
24
24
  taskId: params.taskId,
25
25
  urlString: params.url,
26
26
  filePath: params.destFilePath,
27
27
  expectedSha256: params.expectedSha256,
28
28
  segmentCount: params.segmentCount.map { Int($0) },
29
- minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) }
29
+ minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) },
30
+ // §5.4: caller-tunable retry/timeout/deadline knobs forwarded straight
31
+ // from the regenerated `RangeDownloadParams`. Omitted (nil) values let the
32
+ // core use its platform defaults.
33
+ maxSegmentAttempts: params.maxSegmentAttempts.map { Int($0) },
34
+ requestTimeoutSeconds: params.requestTimeoutSeconds,
35
+ stallTimeoutSeconds: params.stallTimeoutSeconds,
36
+ overallDeadlineSeconds: params.overallDeadlineSeconds
30
37
  )
38
+ // Wire mapping (OCDS §4): map the in-process typed class onto the
39
+ // regenerated wire enum — no lossy collapse. `completed`, `fallbackTransient`
40
+ // and `fallbackPermanent` each cross the JS bridge as their own case, and the
41
+ // optional `fallbackKind` sub-classification is forwarded so callers /
42
+ // analytics can branch without parsing the reason string.
31
43
  return RangeDownloadResult(
32
- outcome: outcome,
44
+ outcome: klass.wireOutcome,
33
45
  filePath: filePath,
34
- fallbackReason: fallbackReason
46
+ fallbackReason: fallbackReason,
47
+ fallbackKind: fallbackClass?.wireKind
35
48
  )
36
49
  }
37
50
  }
@@ -107,6 +120,42 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
107
120
  /// "channel|taskId" so multiple channels can download at once. Each running
108
121
  /// task's `taskDescription` encodes "channel|taskId|segIndex" so a delegate
109
122
  /// callback can locate both the run and the segment.
123
+ // MARK: - Typed failure model (OCDS §4) — wire projections
124
+ //
125
+ // The dependency-free enum bodies for `RangeDownloadClass` / `RangeFallbackClass`
126
+ // (plus the deterministic logic funcs) live in `RangeDownloadLogic.swift` so they
127
+ // can be unit-tested without the Nitro / NativeLogger deps. Only the wire
128
+ // projections — which depend on the codegen enums (`RangeDownloadOutcome` /
129
+ // `RangeFallbackKind`) — remain here, in the module that has those generated types.
130
+ //
131
+ // The IN-PROCESS core returns the Swift-native typed class to its in-process
132
+ // caller (BundleUpdate); the Nitro shim maps it onto the regenerated wire enum
133
+ // `RangeDownloadOutcome` (completed | fallbackTransient | fallbackPermanent) so
134
+ // the failure class crosses the JS boundary as an EXPLICIT value, never inferred
135
+ // from incidental on-disk side effects (which §4 forbids).
136
+ extension RangeDownloadClass {
137
+ /// 1:1 projection onto the generated wire enum (`RangeDownloadOutcome`) that
138
+ /// crosses the JS bridge. No collapse: each in-process class maps to its own
139
+ /// typed wire case.
140
+ var wireOutcome: RangeDownloadOutcome {
141
+ switch self {
142
+ case .completed: return .completed
143
+ case .fallbackTransient: return .fallbacktransient
144
+ case .fallbackPermanent: return .fallbackpermanent
145
+ }
146
+ }
147
+ }
148
+
149
+ extension RangeFallbackClass {
150
+ /// 1:1 projection onto the generated wire enum (`RangeFallbackKind`). The case
151
+ /// names are the wire union's string values verbatim, so `fromString` is exact
152
+ /// and total (the force-unwrap can never fail).
153
+ var wireKind: RangeFallbackKind {
154
+ // swiftlint:disable:next force_unwrapping
155
+ return RangeFallbackKind(fromString: self.rawValue)!
156
+ }
157
+ }
158
+
110
159
  public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
111
160
 
112
161
  public static let shared = RangeDownloader()
@@ -161,26 +210,87 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
161
210
  var continuation: CheckedContinuation<Void, Error>?
162
211
  var prevProgress = -1
163
212
  var fellBack = false
213
+ /// §4 typed class for the eventual fallback. Set by the delegate when it
214
+ /// classifies a non-206 status or a redirect/total/stash failure; consumed by
215
+ /// finalize so the outcome is an EXPLICIT class, never inferred from on-disk
216
+ /// side effects. `serverIgnoredRange` (Permanent) is the historical default
217
+ /// for the bare `fellBack` path (status 200).
218
+ var fellBackKind: RangeFallbackClass = .serverIgnoredRange
164
219
  /// Set when a segment could not be stashed (move/size-check failure). Carries
165
220
  /// the terminal error so didCompleteWithError finalizes instead of hanging.
166
221
  var stashError: Error?
222
+ /// §4/§5.4: per-segment indexes whose finished body was a TRANSIENT HTTP
223
+ /// status (429/5xx/408/416) — the body is discarded and the segment is
224
+ /// re-enqueued (G2) instead of failing the whole run. Optional Retry-After
225
+ /// seconds captured from that response, used to override backoff.
226
+ var transientSegmentRetryAfter: [Int: Double?] = [:]
227
+ /// §4 (416): segment indexes that returned `416 Range Not Satisfiable`. A
228
+ /// bare 416 must NOT re-request the same range blindly: the total size /
229
+ /// validator is re-evaluated (re-probe) first. If the total or ETag changed
230
+ /// the object changed under us → object-change (wipe + restart, Permanent);
231
+ /// otherwise the range is still valid and we keep the resumable `.segN` and
232
+ /// retry. Set in didFinishDownloadingTo, consumed in didCompleteWithError.
233
+ var sizeReevalIndexes: Set<Int> = []
167
234
  /// Whether a strong validator (ETag) was captured for this run. When false,
168
235
  /// resumable `.segN` state must not be trusted across attempts.
169
236
  var hasValidator = false
170
237
  let sessionIdentifier: String
171
238
 
239
+ /// §5.4: per-segment transient retry budget (caller-tunable).
240
+ let maxSegmentAttempts: Int
241
+ /// §5.4: number of transient retry attempts already spent per segment index.
242
+ var segmentAttempts: [Int: Int] = [:]
243
+ /// §5.4: segment indexes the stall watchdog cancelled, so didCompleteWithError
244
+ /// treats the resulting NSURLErrorCancelled as a transient stall (retry),
245
+ /// not an external user cancel (terminate).
246
+ var stallCancelledIndexes: Set<Int> = []
247
+ /// §5.4 (G2): segment indexes with a backoff retry already SCHEDULED (an
248
+ /// asyncAfter pending) but not yet re-enqueued. A pending retry is invisible
249
+ /// to `getAllTasks` (no live task exists during the backoff window), so a
250
+ /// sibling segment's completion could otherwise re-increment this segment's
251
+ /// attempt counter and schedule a DUPLICATE retry. Inserted when the
252
+ /// asyncAfter is armed; cleared inside `enqueueSegment` when the real task is
253
+ /// created. Both `retrySegmentIfUnderBudget` and the missing-segment
254
+ /// re-enqueue gate on this set so a segment is never double-enqueued.
255
+ var pendingRetryIndexes: Set<Int> = []
256
+ /// §5.11 (single-run portion): wall-clock deadline; nil = unbounded. The
257
+ /// cross-restart budget/deadline is owned by the shared-JS track.
258
+ let deadline: Date?
259
+ /// §5.4: last time ANY segment of this run made progress (didWriteData),
260
+ /// used by the stall watchdog. Only foreground/active time should count.
261
+ var lastProgressAt = Date()
262
+ /// The URL this run is fetching, retained so the delegate can re-enqueue a
263
+ /// single segment without re-plumbing it through every call.
264
+ var url: URL?
265
+
172
266
  init(channel: DownloadChannel, taskId: String, filePath: String,
173
- segmentCount: Int, sessionIdentifier: String) {
267
+ segmentCount: Int, sessionIdentifier: String,
268
+ maxSegmentAttempts: Int, deadline: Date?) {
174
269
  self.channel = channel
175
270
  self.taskId = taskId
176
271
  self.filePath = filePath
177
272
  self.segmentCount = segmentCount
178
273
  self.sessionIdentifier = sessionIdentifier
274
+ self.maxSegmentAttempts = maxSegmentAttempts
275
+ self.deadline = deadline
179
276
  }
180
277
 
181
278
  func segPath(_ index: Int) -> String { "\(filePath).seg\(index)" }
279
+
280
+ /// True once the wall-clock deadline (if any) has passed.
281
+ var isPastDeadline: Bool {
282
+ guard let deadline = deadline else { return false }
283
+ return Date() >= deadline
284
+ }
182
285
  }
183
286
 
287
+ // Default retry/backoff/deadline knobs (§5.4 / §5.11 single-run). Caller-tunable
288
+ // via RangeDownloadParams; these are platform configuration, not part of OCDS.
289
+ private static let defaultMaxSegmentAttempts = 4
290
+ private static let defaultRequestTimeoutSeconds: Double = 60
291
+ private static let defaultStallTimeoutSeconds: Double = 30
292
+ // §5.4 backoff knobs now live on `RangeDownloadLogic` (used only by backoffDelay).
293
+
184
294
  override init() {
185
295
  super.init()
186
296
  NotificationCenter.default.addObserver(
@@ -246,6 +356,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
246
356
  cfg.isDiscretionary = false
247
357
  cfg.sessionSendsLaunchEvents = true
248
358
  cfg.httpMaximumConnectionsPerHost = segmentCount
359
+ // §5.4: connection / request timeout. The session is cached per channel, so
360
+ // this uses the default; per-run knobs are enforced by the JS-side deadline
361
+ // and the in-app stall watchdog rather than re-creating the session. A
362
+ // background session keeps the resource timeout generous so a legitimately
363
+ // long suspended transfer is not killed by the OS resource clock (§5.10).
364
+ cfg.timeoutIntervalForRequest = RangeDownloader.defaultRequestTimeoutSeconds
249
365
  let created = URLSession(configuration: cfg, delegate: self, delegateQueue: nil)
250
366
  lock.lock()
251
367
  // Another thread may have raced us; prefer the first-stored session.
@@ -324,10 +440,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
324
440
  // MARK: - Public entry
325
441
 
326
442
  /// Downloads [urlString] into [filePath] using concurrent background ranges.
327
- /// Returns `(.completed, filePath, nil)` on success, or `(.fallback, filePath,
328
- /// reason)` when the caller should use its single-stream path. Transient
329
- /// network errors are also reported as `.fallback` with the error reason (the
330
- /// `.segN` files are kept for the next attempt).
443
+ /// Returns `(.completed, filePath, nil, nil)` on success, or a typed fallback
444
+ /// tuple `(.fallbackTransient | .fallbackPermanent, filePath, reason, kind)`
445
+ /// when the caller should use its single-stream path. Transient network errors
446
+ /// resolve to `.fallbackTransient` and KEEP the `.segN` files for the next
447
+ /// attempt; permanent ones discard them.
331
448
  public func download(
332
449
  channel: DownloadChannel,
333
450
  taskId: String,
@@ -335,42 +452,90 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
335
452
  filePath: String,
336
453
  expectedSha256: String?,
337
454
  segmentCount: Int?,
338
- minConcurrentBytes: Int64?
339
- ) async -> (RangeDownloadOutcome, String, String?) {
455
+ minConcurrentBytes: Int64?,
456
+ maxSegmentAttempts: Int? = nil,
457
+ requestTimeoutSeconds: Double? = nil,
458
+ stallTimeoutSeconds: Double? = nil,
459
+ overallDeadlineSeconds: Double? = nil
460
+ ) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?) {
340
461
  let segCount = max(1, segmentCount ?? Self.defaultSegmentCount)
341
462
  let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes
463
+ let maxAttempts = max(1, maxSegmentAttempts ?? Self.defaultMaxSegmentAttempts)
464
+ // §5.4: caller-tunable stall window for this run's watchdog; the background
465
+ // URLSession's per-request timeout is cached per channel (see `session(...)`),
466
+ // so the per-run request timeout is enforced via the deadline + stall watchdog
467
+ // rather than re-creating the session.
468
+ let stallSeconds = (stallTimeoutSeconds.map { $0 > 0 ? $0 : nil } ?? nil)
469
+ ?? Self.defaultStallTimeoutSeconds
470
+ _ = requestTimeoutSeconds // documented above; session timeout is channel-cached
471
+ let deadline: Date? = {
472
+ guard let s = overallDeadlineSeconds, s > 0 else { return nil }
473
+ return Date().addingTimeInterval(s)
474
+ }()
342
475
  let key = Self.runKey(channel: channel, taskId: taskId)
343
476
 
344
477
  guard let url = URL(string: urlString) else {
345
- return (.fallback, filePath, "invalid url")
478
+ return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported)
346
479
  }
347
480
  // HTTPS-only: background URLSession + transport hardening.
348
481
  guard urlString.hasPrefix("https://") else {
349
- return (.fallback, filePath, "url must use https")
482
+ return (.fallbackPermanent, filePath, "url must use https", .redirectRejected)
483
+ }
484
+
485
+ // §5.8 (G7): at most one live run per destination. A second download() for
486
+ // the same key/filePath while one is in flight joins-or-fails-fast rather
487
+ // than overwriting RunState and co-writing the same `.segN`. The guard keys
488
+ // off `runs[key]` MEMBERSHIP, not `continuation != nil`: the continuation is
489
+ // only assigned after probe + insert, so a `continuation != nil` test left a
490
+ // window (insert → continuation assignment, and the synchronous
491
+ // allSegmentsPresent fast path which never sets a continuation at all) where
492
+ // a half-initialized run was invisible and a 2nd download() could overwrite
493
+ // `runs[key]`. We fail fast (the caller retry loop re-drives cleanly) to
494
+ // avoid join bookkeeping hangs.
495
+ if let existing = run(forKey: key), existing.filePath == filePath {
496
+ return (.fallbackTransient, filePath,
497
+ "another run is already active for this destination", .budgetExhausted)
350
498
  }
351
499
 
352
500
  let probe: ProbeResult
353
501
  do {
354
502
  probe = try await self.probe(url: url)
355
503
  } catch {
356
- return (.fallback, filePath, "probe failed: \(error.localizedDescription)")
504
+ // A probe that cannot reach the server is a transient network condition.
505
+ return (.fallbackTransient, filePath,
506
+ "probe failed: \(error.localizedDescription)", .transientNetwork)
357
507
  }
358
508
  guard probe.supportsRange, probe.total >= minBytes else {
359
- return (.fallback, filePath, "range unsupported or file too small")
509
+ return (.fallbackPermanent, filePath,
510
+ "range unsupported or file too small", .rangeUnsupported)
360
511
  }
361
512
 
362
513
  let state = RunState(
363
514
  channel: channel, taskId: taskId, filePath: filePath,
364
515
  segmentCount: segCount,
365
- sessionIdentifier: Self.sessionIdentifier(for: channel)
516
+ sessionIdentifier: Self.sessionIdentifier(for: channel),
517
+ maxSegmentAttempts: maxAttempts, deadline: deadline
366
518
  )
367
519
  state.totalSize = probe.total
368
520
  state.etag = probe.etag
521
+ state.url = url
369
522
  state.hasValidator = (probe.etag?.isEmpty == false)
370
- state.ranges = Self.planRanges(total: probe.total, segments: segCount)
523
+ state.ranges = RangeDownloadLogic.planRanges(total: probe.total, segments: segCount)
371
524
  state.segmentWritten = [Int64](repeating: 0, count: state.ranges.count)
372
525
 
526
+ // §5.8 (G7): claim the destination slot atomically. Re-check membership under
527
+ // the SAME lock hold as the insert so two download() calls that both passed
528
+ // the early guard (which ran before their respective async probes) cannot
529
+ // both insert — the loser fails fast and never co-writes `.segN`. From this
530
+ // insert onward `runs[key]` membership is the single-flight authority, so a
531
+ // half-initialized run (continuation not yet assigned, or the synchronous
532
+ // fast path that never assigns one) is still observed as live by any racer.
373
533
  lock.lock()
534
+ if let existing = runs[key], existing.filePath == filePath, existing !== state {
535
+ lock.unlock()
536
+ return (.fallbackTransient, filePath,
537
+ "another run is already active for this destination", .budgetExhausted)
538
+ }
374
539
  runs[key] = state
375
540
  lock.unlock()
376
541
 
@@ -386,6 +551,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
386
551
 
387
552
  emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
388
553
 
554
+ // §5.4: start the bytes-stalled watchdog for this run. It cancels a stalled
555
+ // segment task so didCompleteWithError routes it through the in-place retry
556
+ // path. It only counts foreground/active wall time toward the stall window so
557
+ // a legitimately suspended background transfer (§5.10) is never false-cancelled.
558
+ startStallWatchdog(key: key, stallSeconds: stallSeconds)
559
+
389
560
  do {
390
561
  // If every segment is already on disk (resume after suspension/kill), skip
391
562
  // straight to concatenation.
@@ -402,32 +573,36 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
402
573
  } catch let fb as FallbackError {
403
574
  clearRun(key: key)
404
575
  emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
405
- return (.fallback, filePath, fb.reason)
576
+ return (fb.kind.failureClass, filePath, fb.reason, fb.kind)
406
577
  } catch {
407
- // Transient error ask caller to fall back (segments retained for retry).
578
+ // A non-FallbackError thrown out of the run is a local/transient condition
579
+ // (disk I/O, an interrupted segment) → ask the caller to retry the
580
+ // concurrent path; segments are retained for resume.
408
581
  clearRun(key: key)
409
582
  let reason = error.localizedDescription
410
583
  emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
411
- return (.fallback, filePath, reason)
584
+ return (.fallbackTransient, filePath, reason, .transientNetwork)
412
585
  }
413
586
 
414
587
  clearRun(key: key)
415
588
 
416
589
  // Optional immediate SHA256 self-check backstop. When omitted, the caller
417
- // verifies after the fact.
590
+ // verifies after the fact. A whole-file checksum mismatch is Permanent (§4):
591
+ // the assembled bytes are unsalvageable, so discard final + artifacts.
418
592
  if let expected = expectedSha256, !expected.isEmpty {
419
- let actual = Self.calculateSHA256(filePath)
593
+ let actual = RangeDownloadLogic.calculateSHA256(filePath)
420
594
  if actual?.lowercased() != expected.lowercased() {
421
595
  try? FileManager.default.removeItem(atPath: filePath)
596
+ discardArtifacts(filePath: filePath)
422
597
  let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
423
598
  OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
424
599
  emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
425
- return (.fallback, filePath, reason)
600
+ return (.fallbackPermanent, filePath, reason, .checksumMismatch)
426
601
  }
427
602
  }
428
603
 
429
604
  emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
430
- return (.completed, filePath, nil)
605
+ return (.completed, filePath, nil, nil)
431
606
  }
432
607
 
433
608
  private func clearRun(key: String) {
@@ -436,23 +611,23 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
436
611
 
437
612
  // MARK: - Range planning / probing
438
613
 
439
- static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] {
440
- var out: [(Int64, Int64)] = []
441
- let chunk = (total + Int64(segments) - 1) / Int64(segments)
442
- var i = 0
443
- while i < segments {
444
- let start = Int64(i) * chunk
445
- if start >= total { break }
446
- let end = min(start + chunk - 1, total - 1)
447
- out.append((start, end))
448
- i += 1
614
+ private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool }
615
+
616
+ struct FallbackError: Error {
617
+ let reason: String
618
+ /// §4 typed sub-class. Defaults to a Permanent `serverIgnoredRange` only so an
619
+ /// un-annotated legacy throw keeps the previous "discard + single-stream"
620
+ /// behavior; all new throw sites pass an explicit kind.
621
+ let kind: RangeFallbackClass
622
+ init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange) {
623
+ self.reason = reason
624
+ self.kind = kind
449
625
  }
450
- return out
451
626
  }
452
627
 
453
- private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool }
454
-
455
- struct FallbackError: Error { let reason: String }
628
+ // HTTP status classification (classifyStatus), Retry-After parsing
629
+ // (parseRetryAfterSeconds), Content-Range parsing and backoff math now live on
630
+ // `RangeDownloadLogic` (OCDS §4 / §5). Callsites use `RangeDownloadLogic.<fn>`.
456
631
 
457
632
  /// One-byte Range request on a default (foreground) session to learn total
458
633
  /// size + ETag + Range support. Background sessions can't do data tasks, so
@@ -474,7 +649,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
474
649
  let etag = http.value(forHTTPHeaderField: "ETag")
475
650
  if http.statusCode == 206,
476
651
  let cr = http.value(forHTTPHeaderField: "Content-Range"),
477
- let total = Self.parseContentRangeTotal(cr) {
652
+ let total = RangeDownloadLogic.parseContentRangeTotal(cr) {
478
653
  cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true))
479
654
  } else {
480
655
  // 200 (Range ignored) or anything else → single-stream.
@@ -485,29 +660,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
485
660
  }
486
661
  }
487
662
 
488
- static func parseContentRangeTotal(_ header: String) -> Int64? {
489
- // "bytes 0-0/65226095"
490
- guard let slash = header.lastIndex(of: "/") else { return nil }
491
- let tail = header[header.index(after: slash)...]
492
- return Int64(tail.trimmingCharacters(in: .whitespaces))
493
- }
494
-
495
- /// Parses the start/end of a "bytes <start>-<end>/<total>" Content-Range.
496
- static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? {
497
- // Drop the leading "bytes " and the trailing "/<total>".
498
- let trimmed = header.trimmingCharacters(in: .whitespaces)
499
- guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil }
500
- var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...])
501
- if let slash = rangePart.firstIndex(of: "/") {
502
- rangePart = String(rangePart[..<slash])
503
- }
504
- let bounds = rangePart.split(separator: "-", maxSplits: 1).map { String($0) }
505
- guard bounds.count == 2,
506
- let start = Int64(bounds[0].trimmingCharacters(in: .whitespaces)),
507
- let end = Int64(bounds[1].trimmingCharacters(in: .whitespaces)) else { return nil }
508
- return (start, end)
509
- }
510
-
511
663
  // MARK: - Task reconciliation (handles app relaunch)
512
664
 
513
665
  /// Ensures exactly one in-flight (or completed) artifact per missing segment:
@@ -549,14 +701,8 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
549
701
  for (idx, range) in ranges.enumerated() {
550
702
  if FileManager.default.fileExists(atPath: state.segPath(idx)) { continue }
551
703
  if liveIndexes.contains(idx) { continue }
552
- var req = URLRequest(url: url)
553
- req.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
554
- if let etag = state.etag { req.setValue(etag, forHTTPHeaderField: "If-Range") }
555
- let task = session.downloadTask(with: req)
556
- task.taskDescription = Self.encodeTaskDescription(
557
- channel: state.channel, taskId: state.taskId, segIndex: idx
558
- )
559
- task.resume()
704
+ self.enqueueSegment(state: state, session: session, url: url,
705
+ idx: idx, range: range)
560
706
  }
561
707
  // It's possible every segment was already present but the early
562
708
  // allSegmentsPresent check raced a just-finished task; re-check.
@@ -566,6 +712,247 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
566
712
  }
567
713
  }
568
714
 
715
+ /// Creates and starts a single Range download task for [idx]. Factored out of
716
+ /// `reconcileAndStartTasks` so the delegate can re-enqueue ONE segment on a
717
+ /// transient failure (§5.4) without touching the others. Caller must ensure no
718
+ /// live task already exists for this index (double-enqueue guard).
719
+ private func enqueueSegment(state: RunState, session: URLSession, url: URL,
720
+ idx: Int, range: (start: Int64, end: Int64)) {
721
+ var req = URLRequest(url: url)
722
+ req.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
723
+ if let etag = state.etag { req.setValue(etag, forHTTPHeaderField: "If-Range") }
724
+ let task = session.downloadTask(with: req)
725
+ task.taskDescription = Self.encodeTaskDescription(
726
+ channel: state.channel, taskId: state.taskId, segIndex: idx
727
+ )
728
+ // Reset this segment's progress estimate so a re-enqueued attempt doesn't
729
+ // double-count bytes from the failed attempt in the aggregate progress.
730
+ // §5.4 (G2): the real task now exists and is visible to `getAllTasks`, so
731
+ // clear the pending-retry marker — the in-flight check is authoritative again.
732
+ lock.lock()
733
+ if idx < state.segmentWritten.count { state.segmentWritten[idx] = 0 }
734
+ state.pendingRetryIndexes.remove(idx)
735
+ state.lastProgressAt = Date()
736
+ lock.unlock()
737
+ task.resume()
738
+ }
739
+
740
+ /// Marks a segment's finished body as a TRANSIENT HTTP outcome (§4) so
741
+ /// didCompleteWithError re-enqueues just that segment instead of failing the run.
742
+ private func markSegmentTransient(state: RunState, idx: Int, retryAfter: Double?) {
743
+ lock.lock()
744
+ state.transientSegmentRetryAfter[idx] = retryAfter
745
+ lock.unlock()
746
+ }
747
+
748
+ /// §5.4: re-enqueue a single transient-failed segment under its attempt budget,
749
+ /// with jittered exponential backoff (overridden by `Retry-After`). Returns true
750
+ /// when a retry was scheduled; false when the budget/deadline is exhausted (the
751
+ /// caller then finalizes the run as a resumable transient fallback). Must be
752
+ /// called from a delegate callback for [idx].
753
+ private func retrySegmentIfUnderBudget(state: RunState, idx: Int,
754
+ retryAfter: Double?) -> Bool {
755
+ // Deadline check first (§5.11 single-run bound).
756
+ if state.isPastDeadline { return false }
757
+ // §5.4 (G2): claim the pending-retry slot and bump the attempt counter under
758
+ // ONE lock hold. If a retry is already pending for this idx (its asyncAfter
759
+ // hasn't fired yet), a concurrent caller — e.g. a sibling segment's
760
+ // completion driving the missing-segment re-enqueue — must NOT bump the
761
+ // counter again nor arm a duplicate asyncAfter. Returning `true` here reports
762
+ // "a retry is in flight for this segment" without scheduling a second one.
763
+ let claim: (alreadyPending: Bool, attempts: Int) = lock.withLockValue {
764
+ if state.pendingRetryIndexes.contains(idx) {
765
+ return (true, state.segmentAttempts[idx] ?? 0)
766
+ }
767
+ let n = (state.segmentAttempts[idx] ?? 0) + 1
768
+ state.segmentAttempts[idx] = n
769
+ state.pendingRetryIndexes.insert(idx)
770
+ return (false, n)
771
+ }
772
+ if claim.alreadyPending { return true }
773
+ let attempts = claim.attempts
774
+ if attempts > state.maxSegmentAttempts {
775
+ // Over budget: release the slot we just claimed so a later genuine retry
776
+ // (if any) isn't blocked, and report exhausted.
777
+ lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
778
+ return false
779
+ }
780
+ guard let url = lock.withLockValue({ state.url }) else {
781
+ lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
782
+ return false
783
+ }
784
+ let ranges = lock.withLockValue { state.ranges }
785
+ guard idx < ranges.count else {
786
+ lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
787
+ return false
788
+ }
789
+ let range = ranges[idx]
790
+ let delay = RangeDownloadLogic.backoffDelay(attempt: attempts, retryAfter: retryAfter)
791
+ let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
792
+ let channelStr = state.channel.stringValue
793
+ let taskId = state.taskId
794
+ OneKeyLog.info("RangeDownloader",
795
+ "\(channelStr)/\(taskId): retry segment \(idx) attempt \(attempts)/\(state.maxSegmentAttempts) in \(String(format: "%.2f", delay))s")
796
+ DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
797
+ guard let self = self else { return }
798
+ // Bail if the run was cleared (cancel / finalize) or the segment landed
799
+ // in the meantime, and guard against a double-enqueue. On every bail path
800
+ // clear the pending-retry marker (G2) so it doesn't leak: either the slot
801
+ // is moot (run gone / segment landed) or a real task already exists.
802
+ guard let live = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)),
803
+ live === state else {
804
+ self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
805
+ return
806
+ }
807
+ if FileManager.default.fileExists(atPath: state.segPath(idx)) {
808
+ self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
809
+ return
810
+ }
811
+ session.getAllTasks { tasks in
812
+ let alreadyLive = tasks.contains { t in
813
+ guard let d = t.taskDescription,
814
+ let decoded = Self.decodeTaskDescription(d),
815
+ decoded.channel.stringValue == channelStr,
816
+ decoded.taskId == taskId,
817
+ decoded.segIndex == idx else { return false }
818
+ return t.state == .running || t.state == .suspended
819
+ }
820
+ if alreadyLive {
821
+ self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) }
822
+ return
823
+ }
824
+ // enqueueSegment clears the pending marker once the real task exists.
825
+ self.enqueueSegment(state: state, session: session, url: url,
826
+ idx: idx, range: range)
827
+ }
828
+ }
829
+ return true
830
+ }
831
+
832
+ /// §4 (416): a segment got `416 Range Not Satisfiable`. Per §4 a bare 416 must
833
+ /// NOT discard resumable bytes and must NOT blindly re-request the same range —
834
+ /// the total/validator is re-evaluated first. We re-probe the URL:
835
+ /// • Probe fails / range no longer supported → transient; retry the segment
836
+ /// in place under budget (the network blip will clear), keeping `.segN`.
837
+ /// • Total or ETag CHANGED → the object changed under us; the planned ranges
838
+ /// are stale → object-change: wipe + restart (Permanent), so the run
839
+ /// re-plans against the new object instead of stitching mismatched bytes.
840
+ /// • Total/ETag UNCHANGED → the range is genuinely still valid (a transient
841
+ /// server hiccup); keep `.segN` and retry the segment normally.
842
+ /// Must be called from a delegate callback for [idx]; runs the re-probe async.
843
+ private func reevaluateSizeThenRetry(state: RunState, session: URLSession,
844
+ idx: Int, retryAfter: Double?,
845
+ ranges: [(start: Int64, end: Int64)]) {
846
+ guard let url = lock.withLockValue({ state.url }) else {
847
+ finalizeTransientFallback(state: state, idx: idx,
848
+ reason: "segment \(idx) 416 but url missing", ranges: ranges)
849
+ return
850
+ }
851
+ let priorTotal = lock.withLockValue { state.totalSize }
852
+ let priorEtag = lock.withLockValue { state.etag }
853
+ let channelStr = state.channel.stringValue
854
+ let taskId = state.taskId
855
+ OneKeyLog.info("RangeDownloader",
856
+ "\(channelStr)/\(taskId): segment \(idx) 416 — re-evaluating size before retry")
857
+ Task { [weak self] in
858
+ guard let self = self else { return }
859
+ // Re-confirm the run is still live before acting on the re-probe.
860
+ let stillLive = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)).map { $0 === state } ?? false
861
+ guard stillLive else { return }
862
+ let probe: ProbeResult
863
+ do {
864
+ probe = try await self.probe(url: url)
865
+ } catch {
866
+ // Re-probe itself failed → transient network condition. Retry the
867
+ // segment in place; the original range is unchanged and `.segN` is kept.
868
+ if self.retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: retryAfter) { return }
869
+ self.finalizeTransientFallback(state: state, idx: idx,
870
+ reason: "segment \(idx) 416; re-probe failed, retry budget exhausted",
871
+ ranges: ranges)
872
+ return
873
+ }
874
+ let totalChanged = !probe.supportsRange || probe.total != priorTotal
875
+ let etagChanged = (probe.etag?.isEmpty == false || priorEtag?.isEmpty == false)
876
+ && (probe.etag != priorEtag)
877
+ if totalChanged || etagChanged {
878
+ // §4: the object changed under us — the planned `.segN` ranges no longer
879
+ // describe this object. Object-change → wipe + restart (Permanent) so a
880
+ // fresh run re-plans; never stitch bytes from two different objects.
881
+ OneKeyLog.info("RangeDownloader",
882
+ "\(channelStr)/\(taskId): segment \(idx) 416 → object changed (total \(priorTotal)→\(probe.total), etag \(priorEtag ?? "nil")→\(probe.etag ?? "nil")); wipe + restart")
883
+ self.lock.lock()
884
+ state.fellBack = true
885
+ state.fellBackKind = .multipartOrBadTotal
886
+ self.lock.unlock()
887
+ self.finalizePermanentFallback(state: state, session: session, ranges: ranges)
888
+ return
889
+ }
890
+ // Total/validator unchanged → the 416 was a transient server hiccup; the
891
+ // range is still valid. Keep `.segN` and retry this segment normally.
892
+ OneKeyLog.info("RangeDownloader",
893
+ "\(channelStr)/\(taskId): segment \(idx) 416 → size unchanged (total \(priorTotal)); retrying range as-is")
894
+ if self.retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: retryAfter) { return }
895
+ self.finalizeTransientFallback(state: state, idx: idx,
896
+ reason: "segment \(idx) 416; size unchanged, retry budget exhausted",
897
+ ranges: ranges)
898
+ }
899
+ }
900
+
901
+ // backoffDelay (§5.4) now lives on `RangeDownloadLogic`.
902
+
903
+ // MARK: - Stall watchdog (§5.4)
904
+
905
+ /// Periodically checks whether the run has received any bytes within the stall
906
+ /// window. A stalled segment task is cancelled so didCompleteWithError routes it
907
+ /// through the in-place retry path. The watchdog stops itself when the run is no
908
+ /// longer live (finalized / cancelled). It is intentionally lenient: it only
909
+ /// fires when wall time since the last byte exceeds the window AND there is an
910
+ /// in-flight task, so a suspended background transfer (which makes no JS/main
911
+ /// progress while suspended) is not false-cancelled because the watchdog timer
912
+ /// itself is also suspended with the app.
913
+ private func startStallWatchdog(key: String, stallSeconds: Double) {
914
+ let interval = max(5.0, stallSeconds / 2.0)
915
+ DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
916
+ self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval)
917
+ }
918
+ }
919
+
920
+ private func stallWatchdogTick(key: String, stallSeconds: Double, interval: Double) {
921
+ guard let state = run(forKey: key),
922
+ lock.withLockValue({ state.continuation != nil }) else {
923
+ return // run finalized / cancelled — stop.
924
+ }
925
+ let last = lock.withLockValue { state.lastProgressAt }
926
+ let stalled = Date().timeIntervalSince(last) >= stallSeconds
927
+ if stalled {
928
+ let channelStr = state.channel.stringValue
929
+ let taskId = state.taskId
930
+ let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
931
+ session.getAllTasks { tasks in
932
+ for t in tasks {
933
+ guard let d = t.taskDescription,
934
+ let decoded = Self.decodeTaskDescription(d),
935
+ decoded.channel.stringValue == channelStr,
936
+ decoded.taskId == taskId,
937
+ t.state == .running else { continue }
938
+ OneKeyLog.info("RangeDownloader",
939
+ "\(channelStr)/\(taskId): stall watchdog cancelling segment \(decoded.segIndex) (no bytes for \(String(format: "%.0f", stallSeconds))s)")
940
+ // Mark this index as stall-cancelled so didCompleteWithError treats the
941
+ // resulting NSURLErrorCancelled as a transient stall, not a user cancel.
942
+ self.lock.lock(); state.stallCancelledIndexes.insert(decoded.segIndex); self.lock.unlock()
943
+ t.cancel()
944
+ }
945
+ }
946
+ // Re-stamp so we don't repeatedly cancel within the same window while the
947
+ // cancel + retry round-trips.
948
+ lock.lock(); state.lastProgressAt = Date(); lock.unlock()
949
+ }
950
+ // Reschedule.
951
+ DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
952
+ self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval)
953
+ }
954
+ }
955
+
569
956
  private func allSegmentsPresent(state: RunState,
570
957
  ranges: [(start: Int64, end: Int64)]) -> Bool {
571
958
  for (idx, range) in ranges.enumerated() {
@@ -589,12 +976,19 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
589
976
  let (state, idx) = run(for: desc) else { return }
590
977
  lock.lock()
591
978
  if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten }
979
+ // §5.4: stamp last-progress for the stall watchdog. Any byte on any segment
980
+ // counts as the run making progress.
981
+ state.lastProgressAt = Date()
592
982
  let sum = state.segmentWritten.reduce(0, +)
593
983
  let total = state.totalSize
594
984
  var emit = false
595
985
  if total > 0 {
596
986
  let p = Int((sum * 100) / total)
597
- if p != state.prevProgress { state.prevProgress = p; emit = true }
987
+ // §5.7 monotonic non-decreasing: only emit on a strict increase, so a
988
+ // transient re-enqueue (which resets segmentWritten[idx]=0 and dips the
989
+ // aggregate) never ticks the bar backward — prevProgress is the run's
990
+ // running max. A genuine restart resets prevProgress separately.
991
+ if p > state.prevProgress { state.prevProgress = p; emit = true }
598
992
  }
599
993
  let progressValue = total > 0 ? Int((sum * 100) / total) : 0
600
994
  let channel = state.channel
@@ -616,28 +1010,59 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
616
1010
  let expectedLen = range.end - range.start + 1
617
1011
 
618
1012
  // We require a 206 Partial Content that matches the requested byte range.
619
- // Anything else 200 (Range ignored / ETag changed) or an out-of-range
620
- // Content-Range means we cannot safely assemble this segment, so flag
621
- // fallback; finalize happens in didCompleteWithError.
1013
+ // Anything else is classified per §4: a Permanent status (200/401/403/404/
1014
+ // 410/501/505/other-4xx) flags the whole run for discard+single-stream; a
1015
+ // Transient status (408/416/429/5xx) marks just THIS segment for in-place
1016
+ // retry (G2) and keeps the other segments / `.segN`. Finalize/retry happens
1017
+ // in didCompleteWithError.
622
1018
  guard let http = downloadTask.response as? HTTPURLResponse else {
623
- lock.lock(); state.fellBack = true; lock.unlock()
1019
+ // No HTTP response on a finished body is anomalous → treat as transient.
1020
+ markSegmentTransient(state: state, idx: idx, retryAfter: nil)
624
1021
  return
625
1022
  }
626
1023
  if http.statusCode != 206 {
627
- lock.lock(); state.fellBack = true; lock.unlock()
1024
+ let kind = RangeDownloadLogic.classifyStatus(http.statusCode)
1025
+ if kind.failureClass == .fallbackTransient {
1026
+ // §4 (416): a 416 is transient but the requested range may no longer be
1027
+ // satisfiable (the object shrank/changed). Flag this segment for a size
1028
+ // re-evaluation (re-probe) BEFORE the range is re-requested, instead of
1029
+ // blindly re-asking for the same bytes.
1030
+ if http.statusCode == 416 {
1031
+ lock.lock(); state.sizeReevalIndexes.insert(idx); lock.unlock()
1032
+ }
1033
+ let retryAfter = RangeDownloadLogic.parseRetryAfterSeconds(
1034
+ http.value(forHTTPHeaderField: "Retry-After"))
1035
+ markSegmentTransient(state: state, idx: idx, retryAfter: retryAfter)
1036
+ } else {
1037
+ lock.lock(); state.fellBack = true; state.fellBackKind = kind; lock.unlock()
1038
+ }
1039
+ return
1040
+ }
1041
+ // §5.5: reject multipart/byteranges — we requested exactly one range and can
1042
+ // only assemble a single contiguous body per segment.
1043
+ if let ctype = http.value(forHTTPHeaderField: "Content-Type")?.lowercased(),
1044
+ ctype.contains("multipart/byteranges") {
1045
+ lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock()
628
1046
  return
629
1047
  }
630
1048
  // Verify the server's Content-Range start/end matches what we asked for so a
631
- // stashed `.segN` can't be a slice of a different object/range.
1049
+ // stashed `.segN` can't be a slice of a different object/range; and (§5.5)
1050
+ // verify the total matches the probe total (reject absent / `*` / mismatch).
632
1051
  if let cr = http.value(forHTTPHeaderField: "Content-Range") {
633
- guard let parsed = Self.parseContentRangeBounds(cr),
1052
+ guard let parsed = RangeDownloadLogic.parseContentRangeBounds(cr),
634
1053
  parsed.start == range.start, parsed.end == range.end else {
635
- lock.lock(); state.fellBack = true; lock.unlock()
1054
+ lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock()
1055
+ return
1056
+ }
1057
+ let expectedTotal = lock.withLockValue { state.totalSize }
1058
+ guard let total = RangeDownloadLogic.parseContentRangeTotal(cr), total == expectedTotal else {
1059
+ // Absent / `*` / disagreeing total → the object changed or is non-conforming.
1060
+ lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock()
636
1061
  return
637
1062
  }
638
1063
  } else {
639
1064
  // 206 without a Content-Range header is non-conforming — don't trust it.
640
- lock.lock(); state.fellBack = true; lock.unlock()
1065
+ lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock()
641
1066
  return
642
1067
  }
643
1068
 
@@ -676,53 +1101,82 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
676
1101
 
677
1102
  public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
678
1103
  guard let desc = task.taskDescription,
679
- let (state, _) = run(for: desc) else { return }
1104
+ let (state, idx) = run(for: desc) else { return }
680
1105
  let ranges = lock.withLockValue { state.ranges }
1106
+
1107
+ // A Permanent classification anywhere in the run (didFinishDownloadingTo set
1108
+ // `fellBack`) wins over per-segment retries: discard and single-stream.
1109
+ if lock.withLockValue({ state.fellBack }) {
1110
+ finalizePermanentFallback(state: state, session: session, ranges: ranges)
1111
+ return
1112
+ }
1113
+
681
1114
  if let error = error {
682
- // Background ignores user-cancels (e.g. our own fallback cancel below) —
683
- // surface only genuine give-ups.
684
1115
  let nsErr = error as NSError
685
1116
  if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorCancelled {
1117
+ // Distinguish a stall-watchdog cancel (transient → retry this segment)
1118
+ // from an external/user cancel or our own permanent-fallback sibling
1119
+ // cancel (swallow). A genuine cancel never set stallCancelledIndexes.
1120
+ let wasStall = lock.withLockValue { state.stallCancelledIndexes.remove(idx) != nil }
1121
+ if wasStall {
1122
+ if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
1123
+ finalizeTransientFallback(state: state, idx: idx, reason: "segment \(idx) stalled; retry budget exhausted", ranges: ranges)
1124
+ }
686
1125
  return
687
1126
  }
688
- finishContinuation(state: state, with: error, ranges: ranges)
1127
+ // §4: connection lost / timeout / DNS / TLS → Transient. Retry THIS segment
1128
+ // in place under its budget; keep the others and their `.segN`.
1129
+ if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
1130
+ finalizeTransientFallback(state: state, idx: idx,
1131
+ reason: error.localizedDescription, ranges: ranges)
689
1132
  return
690
1133
  }
691
- if lock.withLockValue({ state.fellBack }) {
692
- // Abandon the other in-flight segment tasks for THIS run so they don't
693
- // keep downloading after we've decided to fall back to single-stream.
694
- let channel = state.channel
695
- let taskId = state.taskId
696
- session.getAllTasks { tasks in
697
- for t in tasks {
698
- if let d = t.taskDescription,
699
- let decoded = Self.decodeTaskDescription(d),
700
- decoded.channel.stringValue == channel.stringValue,
701
- decoded.taskId == taskId {
702
- t.cancel()
703
- }
704
- }
1134
+
1135
+ // §4: this segment's finished body was a Transient HTTP status (429/5xx/408/
1136
+ // 416). Discard the body and re-enqueue just this segment under its budget.
1137
+ let transientRetryAfter = lock.withLockValue { () -> (present: Bool, retryAfter: Double?) in
1138
+ if let ra = state.transientSegmentRetryAfter[idx] {
1139
+ state.transientSegmentRetryAfter.removeValue(forKey: idx)
1140
+ return (true, ra)
705
1141
  }
706
- cleanupSegments(state: state, ranges: ranges)
707
- finishContinuation(state: state,
708
- with: FallbackError(reason: "server returned 200 to a Range request"),
709
- ranges: ranges)
1142
+ return (false, nil)
1143
+ }
1144
+ if transientRetryAfter.present {
1145
+ // §4 (416): a 416 segment must re-evaluate the total/validator (re-probe)
1146
+ // BEFORE re-requesting the same range. If the object changed → object-change
1147
+ // (wipe + restart, Permanent); otherwise keep `.segN` and retry normally.
1148
+ let needsSizeReeval = lock.withLockValue { state.sizeReevalIndexes.remove(idx) != nil }
1149
+ if needsSizeReeval {
1150
+ reevaluateSizeThenRetry(state: state, session: session, idx: idx,
1151
+ retryAfter: transientRetryAfter.retryAfter, ranges: ranges)
1152
+ return
1153
+ }
1154
+ if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: transientRetryAfter.retryAfter) { return }
1155
+ finalizeTransientFallback(state: state, idx: idx,
1156
+ reason: "segment \(idx) throttled/5xx; retry budget exhausted", ranges: ranges)
710
1157
  return
711
1158
  }
1159
+
712
1160
  // A segment failed to stash (move/size-check failure in didFinishDownloadingTo).
713
- // That segment will never appear, so finalize terminally instead of waiting.
714
- if let stashErr = lock.withLockValue({ state.stashError }) {
715
- finishContinuation(state: state, with: stashErr, ranges: ranges)
1161
+ // A short/truncated body is transient (the bytes will be re-fetched), so retry
1162
+ // this segment under budget rather than failing the whole run.
1163
+ if lock.withLockValue({ state.stashError }) != nil {
1164
+ lock.lock(); state.stashError = nil; lock.unlock()
1165
+ if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return }
1166
+ finalizeTransientFallback(state: state, idx: idx,
1167
+ reason: "segment \(idx) could not be stashed; retry budget exhausted", ranges: ranges)
716
1168
  return
717
1169
  }
1170
+
718
1171
  if allSegmentsPresent(state: state, ranges: ranges) {
719
1172
  finishContinuation(state: state, with: nil, ranges: ranges)
720
1173
  return
721
1174
  }
722
1175
  // This task finished error==nil but not all segments are present. If any
723
- // tasks for THIS run are still in flight, wait for their completions.
724
- // Otherwise nothing will ever resume the continuation finalize with a
725
- // descriptive terminal error so the JS promise resolves (as fallback).
1176
+ // tasks for THIS run are still in flight (or a backoff retry is pending),
1177
+ // wait. Otherwise re-enqueue the missing segment(s) under budget; only when a
1178
+ // segment's budget/deadline is exhausted do we finalize as a resumable
1179
+ // transient fallback (keeping `.segN`).
726
1180
  let channel = state.channel
727
1181
  let taskId = state.taskId
728
1182
  session.getAllTasks { [weak self] tasks in
@@ -735,18 +1189,100 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
735
1189
  return t.state == .running || t.state == .suspended
736
1190
  }
737
1191
  if stillInFlight { return }
1192
+ // §5.4 (G2): a backoff retry pending for ANY missing segment has no live
1193
+ // task (it's a delayed asyncAfter), so it's invisible to `getAllTasks`
1194
+ // above. If one is pending, that scheduled retry will re-drive completion;
1195
+ // bail now rather than double-incrementing its attempt counter and arming
1196
+ // a duplicate retry.
1197
+ let pendingRetry = self.lock.withLockValue { !state.pendingRetryIndexes.isEmpty }
1198
+ if pendingRetry { return }
738
1199
  // Re-check under no-in-flight: a just-finished stash may have completed.
739
1200
  if self.allSegmentsPresent(state: state, ranges: ranges) {
740
1201
  self.finishContinuation(state: state, with: nil, ranges: ranges)
741
1202
  return
742
1203
  }
743
- let missing = ranges.indices.first {
744
- !FileManager.default.fileExists(atPath: state.segPath($0))
1204
+ // Re-enqueue every missing segment that still has budget. `retrySegmentIf
1205
+ // UnderBudget` itself gates on `pendingRetryIndexes`, so a segment already
1206
+ // mid-backoff is reported as "in flight" (true) and never re-armed (G2).
1207
+ var anyRetryScheduled = false
1208
+ var exhaustedIdx: Int?
1209
+ for mIdx in ranges.indices
1210
+ where !FileManager.default.fileExists(atPath: state.segPath(mIdx)) {
1211
+ if self.retrySegmentIfUnderBudget(state: state, idx: mIdx, retryAfter: nil) {
1212
+ anyRetryScheduled = true
1213
+ } else if exhaustedIdx == nil {
1214
+ exhaustedIdx = mIdx
1215
+ }
745
1216
  }
746
- let reason = "segment \(missing.map(String.init) ?? "?") missing/truncated after completion"
747
- self.finishContinuation(state: state,
748
- with: FallbackError(reason: reason),
749
- ranges: ranges)
1217
+ if anyRetryScheduled { return }
1218
+ let missing = exhaustedIdx
1219
+ ?? ranges.indices.first { !FileManager.default.fileExists(atPath: state.segPath($0)) }
1220
+ self.finalizeTransientFallback(
1221
+ state: state, idx: missing ?? 0,
1222
+ reason: "segment \(missing.map(String.init) ?? "?") missing/truncated; retry budget exhausted",
1223
+ ranges: ranges)
1224
+ }
1225
+ }
1226
+
1227
+ /// §4 Permanent: abandon sibling tasks, discard `.segN`, finalize the run with a
1228
+ /// Permanent fallback carrying the run's classified kind.
1229
+ private func finalizePermanentFallback(state: RunState, session: URLSession,
1230
+ ranges: [(start: Int64, end: Int64)]) {
1231
+ let channel = state.channel
1232
+ let taskId = state.taskId
1233
+ let kind = lock.withLockValue { state.fellBackKind }
1234
+ session.getAllTasks { tasks in
1235
+ for t in tasks {
1236
+ if let d = t.taskDescription,
1237
+ let decoded = Self.decodeTaskDescription(d),
1238
+ decoded.channel.stringValue == channel.stringValue,
1239
+ decoded.taskId == taskId {
1240
+ t.cancel()
1241
+ }
1242
+ }
1243
+ }
1244
+ cleanupSegments(state: state, ranges: ranges)
1245
+ let reason: String
1246
+ switch kind {
1247
+ case .serverIgnoredRange: reason = "server returned 200 to a Range request"
1248
+ case .multipartOrBadTotal: reason = "non-conforming 206 (multipart / range / total mismatch)"
1249
+ case .authExpired: reason = "auth expired (401/403); fetch a fresh signed URL"
1250
+ case .notFound: reason = "object not found (404/410)"
1251
+ case .redirectRejected: reason = "rejected non-HTTPS redirect"
1252
+ case .rangeUnsupported: reason = "range unsupported / non-retryable status"
1253
+ case .checksumMismatch: reason = "whole-file checksum mismatch"
1254
+ case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback"
1255
+ }
1256
+ finishContinuation(state: state, with: FallbackError(reason: reason, kind: kind), ranges: ranges)
1257
+ }
1258
+
1259
+ /// §4 Transient: finalize the run as a RESUMABLE fallback. `.segN` files are
1260
+ /// intentionally KEPT so the next concurrent attempt resumes only the missing
1261
+ /// segment(s); never restart from byte 0 while resumable bytes exist.
1262
+ private func finalizeTransientFallback(state: RunState, idx: Int, reason: String,
1263
+ ranges: [(start: Int64, end: Int64)]) {
1264
+ finishContinuation(state: state,
1265
+ with: FallbackError(reason: reason, kind: .budgetExhausted),
1266
+ ranges: ranges)
1267
+ }
1268
+
1269
+ /// §5.9: HTTPS-only on every redirect hop. A redirect to a non-HTTPS URL is
1270
+ /// rejected (Permanent, `redirectRejected`). Passing `nil` to the completion
1271
+ /// handler cancels the redirect; the task then completes with an error which
1272
+ /// our run classification turns into a Permanent fallback (we pre-mark the run
1273
+ /// so didCompleteWithError doesn't misread the cancel as transient).
1274
+ public func urlSession(_ session: URLSession, task: URLSessionTask,
1275
+ willPerformHTTPRedirection response: HTTPURLResponse,
1276
+ newRequest request: URLRequest,
1277
+ completionHandler: @escaping (URLRequest?) -> Void) {
1278
+ if request.url?.scheme?.lowercased() != "https" {
1279
+ OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL")
1280
+ if let desc = task.taskDescription, let (state, _) = run(for: desc) {
1281
+ lock.lock(); state.fellBack = true; state.fellBackKind = .redirectRejected; lock.unlock()
1282
+ }
1283
+ completionHandler(nil)
1284
+ } else {
1285
+ completionHandler(request)
750
1286
  }
751
1287
  }
752
1288
 
@@ -856,6 +1392,22 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
856
1392
  discardArtifacts(filePath: filePath)
857
1393
  }
858
1394
 
1395
+ /// True when at least one concurrent segment artifact survives for this file.
1396
+ ///
1397
+ /// The downloader cleans its `.segN` files itself on every *permanent*
1398
+ /// fallback (server returned 200 → `cleanupSegments` in didCompleteWithError;
1399
+ /// SHA mismatch → cleanup in concatenateAndFinish; Range unsupported → nothing
1400
+ /// was ever stashed) and deliberately RETAINS them on a *transient* one (a
1401
+ /// suspend/network drop that left "segment N missing/truncated"). So a
1402
+ /// surviving `.segN` is a reliable signal that the fallback is resumable —
1403
+ /// the caller uses it to avoid deleting bytes a later attempt can resume.
1404
+ public func hasArtifacts(filePath: String) -> Bool {
1405
+ for idx in 0..<Self.defaultSegmentCount {
1406
+ if FileManager.default.fileExists(atPath: "\(filePath).seg\(idx)") { return true }
1407
+ }
1408
+ return false
1409
+ }
1410
+
859
1411
  /// Discards all segment artifacts (used by the caller when falling back to
860
1412
  /// single-stream so the bare slot is clean). Prefer `cancel(...)` when tasks
861
1413
  /// may still be in flight; this file-only delete is for the post-fallback
@@ -869,23 +1421,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
869
1421
 
870
1422
  // MARK: - SHA256 (streaming backstop)
871
1423
 
872
- static func calculateSHA256(_ filePath: String) -> String? {
873
- let fm = FileManager.default
874
- guard fm.fileExists(atPath: filePath),
875
- let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
876
- defer { try? fileHandle.close() }
877
- var context = CC_SHA256_CTX()
878
- CC_SHA256_Init(&context)
879
- while autoreleasepool(invoking: { () -> Bool in
880
- let data = fileHandle.readData(ofLength: 8192)
881
- if data.isEmpty { return false }
882
- data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
883
- return true
884
- }) {}
885
- var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
886
- CC_SHA256_Final(&hash, &context)
887
- return hash.map { String(format: "%02x", $0) }.joined()
888
- }
1424
+ // calculateSHA256 (§5.5) now lives on `RangeDownloadLogic`.
889
1425
  }
890
1426
 
891
1427
  private extension NSLock {