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