@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.
- package/README.md +15 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
- package/ios/RangeDownloadLogic.swift +187 -0
- package/ios/ReactNativeRangeDownloader.swift +669 -133
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
- package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
- package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
- package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
- package/package.json +1 -1
- 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
|
|
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 (
|
|
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:
|
|
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
|
|
328
|
-
///
|
|
329
|
-
///
|
|
330
|
-
/// `.segN` files
|
|
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
|
-
|
|
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 (.
|
|
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 (.
|
|
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
|
-
|
|
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 (.
|
|
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 =
|
|
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 (.
|
|
576
|
+
return (fb.kind.failureClass, filePath, fb.reason, fb.kind)
|
|
406
577
|
} catch {
|
|
407
|
-
//
|
|
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 (.
|
|
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 =
|
|
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 (.
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 =
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
|
620
|
-
//
|
|
621
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
let
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
724
|
-
// Otherwise
|
|
725
|
-
//
|
|
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
|
-
|
|
744
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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 {
|