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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +15 -0
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
  10. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
  11. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
  12. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
  13. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
  14. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
  15. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
  16. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
  17. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
  18. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
  19. package/ios/RangeDownloadLogic.swift +187 -0
  20. package/ios/ReactNativeRangeDownloader.swift +669 -133
  21. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
  22. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
  24. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
  25. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
  26. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
  27. package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
  32. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
  33. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
  34. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
  35. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
  36. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
  37. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
  38. package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
  39. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
  40. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
  41. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
  42. package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
  43. package/package.json +1 -1
  44. package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
@@ -0,0 +1,554 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import okhttp3.OkHttpClient
4
+ import okhttp3.mockwebserver.MockWebServer
5
+ import org.junit.After
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Assert.assertNotEquals
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+ import java.io.File
13
+ import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.Executors
15
+ import java.util.concurrent.TimeUnit
16
+ import java.util.concurrent.atomic.AtomicReference
17
+
18
+ /**
19
+ * OCDS §6 conformance scenarios driving the REAL
20
+ * [ConcurrentRangeDownloader.download] against the [RangeFaultServer]
21
+ * MockWebServer harness. Each test is named with its OCDS-T number and asserts
22
+ * on the recorded ranges, the on-disk artifacts (`.partial` / `.segN`), and the
23
+ * assembled bytes (SHA-256 vs source).
24
+ *
25
+ * Out of scope of this CORE suite (asserted/explained, never forced):
26
+ * - OCDS-T6 (whole-file SHA mismatch → single-stream retry once → terminal):
27
+ * the core does NOT compute a whole-file checksum. On COMPLETED it leaves the
28
+ * assembled bytes at `partialFilePath`; the CALLER renames + SHA-256/GPG
29
+ * verifies and drives the retry-once-then-terminal policy. Not a core test.
30
+ * - OCDS-T11 (two concurrent download() for the same dest → serialized / one
31
+ * fails fast, artifacts never co-written): single-flight is a CALLER-level
32
+ * per-taskId registry concern (the adapter), not in this dependency-free core.
33
+ * - OCDS-T9 (cross-relaunch attempt budget / bounded terminal): the budget that
34
+ * spans process relaunches lives in the shared-JS caller and is tested there.
35
+ * The core's in-process bound (maxPartRetry) is exercised indirectly by T4.
36
+ *
37
+ * Threading note (from FaultServer.kt): the dispatcher runs concurrently on
38
+ * MockWebServer reader threads, so range assertions read the synchronized
39
+ * `requestedRanges` only after the download() call has fully returned.
40
+ */
41
+ class ConcurrentRangeDownloaderOcdsTest {
42
+
43
+ private lateinit var server: MockWebServer
44
+ private lateinit var tmpDir: File
45
+
46
+ // 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path,
47
+ // and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts.
48
+ private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
49
+ private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
50
+
51
+ @Before
52
+ fun setUp() {
53
+ tmpDir = File.createTempFile("crd-ocds", "").let {
54
+ it.delete(); it.mkdirs(); it
55
+ }
56
+ }
57
+
58
+ @After
59
+ fun tearDown() {
60
+ if (this::server.isInitialized) server.shutdown()
61
+ tmpDir.deleteRecursively()
62
+ }
63
+
64
+ // --- helpers -------------------------------------------------------------
65
+
66
+ private fun startServer(faultServer: RangeFaultServer) {
67
+ server = MockWebServer()
68
+ server.dispatcher = faultServer.dispatcher
69
+ server.start()
70
+ }
71
+
72
+ private fun url() = server.url("/asset.bin").toString()
73
+
74
+ private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
75
+
76
+ private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
77
+
78
+ private fun assertNoArtifactsLeft(partial: String) {
79
+ assertFalse(".partial must be wiped", File(partial).exists())
80
+ for (i in 0 until 8) {
81
+ assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
82
+ }
83
+ }
84
+
85
+ // ------------------------------------------------------------------------
86
+ // OCDS-T1: drop one segment's connection mid-body a couple of times.
87
+ // Only that segment is re-fetched (retries stay in its own window); the
88
+ // other segments are requested exactly once; assembled SHA is correct; no
89
+ // from-0 restart.
90
+ // ------------------------------------------------------------------------
91
+ @Test
92
+ fun ocdsT1_midBodyDropOnlyRefetchesAffectedSegment() {
93
+ val target = parts[3]
94
+ val fs = RangeFaultServer(content)
95
+ .arm(RangeFaultServer.FaultMode.DROP_DURING_BODY, target.start, target.end, times = 2)
96
+ startServer(fs)
97
+
98
+ val partial = partialPath()
99
+ val outcome = newDownloader().download(
100
+ url = url(),
101
+ partialFilePath = partial,
102
+ onProgress = { _, _ -> },
103
+ )
104
+
105
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
106
+ assertEquals(sha256(content), sha256(File(partial)))
107
+
108
+ val ranges = fs.requestedRanges.toList()
109
+ // The dropped segment is fetched: initial + 2 retries = 3 times (its window).
110
+ // (Resume requests only the not-yet-on-disk tail, but the Range start stays
111
+ // inside the segment window — never byte 0 of the file.)
112
+ val targetReqs = ranges.filter { it.first in target.start..target.end }
113
+ assertTrue(
114
+ "dropped segment must be re-fetched at least twice (got ${targetReqs.size})",
115
+ targetReqs.size >= 2,
116
+ )
117
+ // No from-0 restart: every retry of the target segment starts within its own
118
+ // window, never at file byte 0 (unless segment 0 itself is the target — it
119
+ // is not here, target is part[3]).
120
+ assertTrue(
121
+ "no retry of the affected segment may restart at file byte 0",
122
+ targetReqs.all { it.first >= target.start },
123
+ )
124
+ // Every OTHER segment is requested exactly once.
125
+ for (p in parts) {
126
+ if (p.index == target.index) continue
127
+ val reqs = ranges.filter { it.first == p.start && it.second == p.end }
128
+ assertEquals(
129
+ "segment ${p.index} must be requested exactly once",
130
+ 1,
131
+ reqs.size,
132
+ )
133
+ }
134
+ // All segments consumed on success.
135
+ for (i in 0 until 8) assertFalse(segFile(partial, i).exists())
136
+ }
137
+
138
+ // ------------------------------------------------------------------------
139
+ // OCDS-T2: (a) full download completes; (b) resume re-fetches only the tail.
140
+ // ------------------------------------------------------------------------
141
+ @Test
142
+ fun ocdsT2a_fullDownloadRequestsAllEightRanges() {
143
+ val fs = RangeFaultServer(content)
144
+ startServer(fs)
145
+
146
+ val partial = partialPath()
147
+ val outcome = newDownloader().download(
148
+ url = url(),
149
+ partialFilePath = partial,
150
+ onProgress = { _, _ -> },
151
+ )
152
+
153
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
154
+ assertEquals(content.size.toLong(), File(partial).length())
155
+ assertEquals(sha256(content), sha256(File(partial)))
156
+
157
+ val ranges = fs.requestedRanges.toList()
158
+ for (p in parts) {
159
+ val reqs = ranges.filter { it.first == p.start && it.second == p.end }
160
+ assertEquals("segment ${p.index} must be requested once", 1, reqs.size)
161
+ }
162
+ }
163
+
164
+ @Test
165
+ fun ocdsT2b_resumeRefetchesOnlyMissingSegments() {
166
+ val fs = RangeFaultServer(content)
167
+ startServer(fs)
168
+
169
+ val partial = partialPath()
170
+ // Seed parts 0..4 as already-completed `.segN` files (mid-run kill model).
171
+ seedSegments(partial, content, completedThrough = 4, segments = 8)
172
+
173
+ val outcome = newDownloader().download(
174
+ url = url(),
175
+ partialFilePath = partial,
176
+ onProgress = { _, _ -> },
177
+ )
178
+
179
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
180
+ assertEquals(sha256(content), sha256(File(partial)))
181
+
182
+ // The probe always issues a "bytes=0-0" request (size/Range capability
183
+ // check); it is NOT a segment fetch, so exclude it from the segment counts.
184
+ val PROBE = 0L to 0L
185
+ val ranges = fs.requestedRanges.toList().filterNot { it == PROBE }
186
+ // No SEGMENT request for the seeded windows (parts 0..4) beyond the probe.
187
+ for (idx in 0..4) {
188
+ val p = parts[idx]
189
+ val reqs = ranges.filter { it.first in p.start..p.end }
190
+ assertTrue("seeded segment $idx must NOT be re-fetched (got $reqs)", reqs.isEmpty())
191
+ }
192
+ // Only the tail (parts 5..7) is fetched, once each.
193
+ for (idx in 5..7) {
194
+ val p = parts[idx]
195
+ val reqs = ranges.filter { it.first == p.start && it.second == p.end }
196
+ assertEquals("tail segment $idx must be requested once", 1, reqs.size)
197
+ }
198
+ }
199
+
200
+ // ------------------------------------------------------------------------
201
+ // OCDS-T3: status 200 to a Range request (probe) → FALLBACK + artifacts wiped.
202
+ // The probe itself sends Range "bytes=0-0"; a 200 there makes the probe
203
+ // report supportsRange=false → FALLBACK before any segment is written, so
204
+ // there are no artifacts to wipe (and the test asserts none exist).
205
+ // ------------------------------------------------------------------------
206
+ @Test
207
+ fun ocdsT3_status200ToRangeFallsBackAndWipesArtifacts() {
208
+ // Arm 200 on the probe window (bytes=0-0). The probe's 200 short-circuits to
209
+ // FALLBACK; no concurrent path runs.
210
+ val fs = RangeFaultServer(content)
211
+ .arm(RangeFaultServer.FaultMode.STATUS_200, 0L, 0L, times = Int.MAX_VALUE)
212
+ startServer(fs)
213
+
214
+ val partial = partialPath()
215
+ val outcome = newDownloader().download(
216
+ url = url(),
217
+ partialFilePath = partial,
218
+ onProgress = { _, _ -> },
219
+ )
220
+
221
+ assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
222
+ assertNoArtifactsLeft(partial)
223
+ }
224
+
225
+ // OCDS-T3 (segment-level 200): the probe succeeds (206) but a segment fetch
226
+ // gets a 200 (server ignored Range on that connection) → core throws
227
+ // FallbackException → Outcome.FALLBACK and wipeArtifacts() clears every `.segN`
228
+ // / `.partial`.
229
+ @Test
230
+ fun ocdsT3_status200OnSegmentFallsBackAndWipesArtifacts() {
231
+ val target = parts[2]
232
+ val fs = RangeFaultServer(content)
233
+ .arm(RangeFaultServer.FaultMode.STATUS_200, target.start, target.end, times = Int.MAX_VALUE)
234
+ startServer(fs)
235
+
236
+ val partial = partialPath()
237
+ val outcome = newDownloader().download(
238
+ url = url(),
239
+ partialFilePath = partial,
240
+ onProgress = { _, _ -> },
241
+ )
242
+
243
+ assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
244
+ assertNoArtifactsLeft(partial)
245
+ }
246
+
247
+ // ------------------------------------------------------------------------
248
+ // OCDS-T4: 429 (+Retry-After: 0) once on a segment → transient retry succeeds.
249
+ // SHA correct; the affected segment requested twice; no from-0 restart.
250
+ // ------------------------------------------------------------------------
251
+ @Test
252
+ fun ocdsT4_status429ThenOkRetriesInPlace() {
253
+ val target = parts[5]
254
+ val fs = RangeFaultServer(content)
255
+ .arm(RangeFaultServer.FaultMode.STATUS_429_THEN_OK, target.start, target.end, times = 1)
256
+ startServer(fs)
257
+
258
+ val partial = partialPath()
259
+ val outcome = newDownloader().download(
260
+ url = url(),
261
+ partialFilePath = partial,
262
+ onProgress = { _, _ -> },
263
+ )
264
+
265
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
266
+ assertEquals(sha256(content), sha256(File(partial)))
267
+
268
+ val ranges = fs.requestedRanges.toList()
269
+ // 429 returned an empty body, so the segment has 0 bytes on disk → the retry
270
+ // re-requests the SAME full window (start == part.start). Exactly two hits.
271
+ val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
272
+ assertEquals("affected segment must be requested twice (429 then OK)", 2, targetReqs.size)
273
+ assertTrue(
274
+ "retry must not restart at file byte 0",
275
+ targetReqs.all { it.first == target.start },
276
+ )
277
+ }
278
+
279
+ // ------------------------------------------------------------------------
280
+ // OCDS-T5(a): mis-aligned Content-Range → rejected, never silent corruption.
281
+ // The core treats a mismatched Content-Range as transient (retry); the
282
+ // harness's misaligned fault always shifts the window, so every retry is
283
+ // rejected → the segment never completes → download() throws after the
284
+ // per-segment budget. The KEY assertion: no corruption — assembly never
285
+ // COMPLETED with wrong bytes.
286
+ // ------------------------------------------------------------------------
287
+ @Test
288
+ fun ocdsT5a_misalignedContentRangeIsRejectedNeverCorrupts() {
289
+ val target = parts[1]
290
+ val fs = RangeFaultServer(content)
291
+ // Fire forever so the misalignment never "expires" into a good 206.
292
+ .arm(RangeFaultServer.FaultMode.MISALIGNED_CONTENT_RANGE, target.start, target.end, times = Int.MAX_VALUE)
293
+ startServer(fs)
294
+
295
+ val partial = partialPath()
296
+ var threw = false
297
+ var outcome: ConcurrentRangeDownloader.Outcome? = null
298
+ try {
299
+ outcome = newDownloader().download(
300
+ url = url(),
301
+ partialFilePath = partial,
302
+ onProgress = { _, _ -> },
303
+ )
304
+ } catch (e: Exception) {
305
+ threw = true
306
+ }
307
+ // Never a corrupt COMPLETED. Either it threw (transient give-up, segments
308
+ // kept for resume) or — if a future core maps it to fallback — FALLBACK.
309
+ assertTrue(
310
+ "misaligned 206 must not produce a COMPLETED outcome",
311
+ outcome != ConcurrentRangeDownloader.Outcome.COMPLETED,
312
+ )
313
+ assertTrue("misaligned 206 must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK)
314
+ // No assembled final file with the wrong window was produced.
315
+ if (File(partial).exists()) {
316
+ assertNotEquals(
317
+ "a `.partial` left on disk must NOT be a complete (corrupt) file",
318
+ content.size.toLong(),
319
+ File(partial).length(),
320
+ )
321
+ }
322
+ }
323
+
324
+ // OCDS-T5(b): over-long body → not written past segment end; rejected.
325
+ // The harness fires the over-long body forever, so the segment is dropped on
326
+ // each attempt and never completes → throw after budget; crucially the
327
+ // neighbour segments are NOT corrupted (each writes its own `.segN`).
328
+ @Test
329
+ fun ocdsT5b_overLongBodyIsRejectedNoNeighbourCorruption() {
330
+ val target = parts[4]
331
+ val fs = RangeFaultServer(content)
332
+ .arm(RangeFaultServer.FaultMode.OVER_LONG_BODY, target.start, target.end, times = Int.MAX_VALUE)
333
+ startServer(fs)
334
+
335
+ val partial = partialPath()
336
+ var outcome: ConcurrentRangeDownloader.Outcome? = null
337
+ var threw = false
338
+ try {
339
+ outcome = newDownloader().download(
340
+ url = url(),
341
+ partialFilePath = partial,
342
+ onProgress = { _, _ -> },
343
+ )
344
+ } catch (e: Exception) {
345
+ threw = true
346
+ }
347
+ assertTrue(
348
+ "over-long 206 must not produce a COMPLETED outcome",
349
+ outcome != ConcurrentRangeDownloader.Outcome.COMPLETED,
350
+ )
351
+ assertTrue("over-long body must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK)
352
+ // The over-long segment is dropped (deleted) on overrun, so neighbours are
353
+ // never spliced. Any neighbour `.segN` that exists must be exactly its
354
+ // planned length — no extra bytes leaked across windows.
355
+ for (p in parts) {
356
+ if (p.index == target.index) continue
357
+ val f = segFile(partial, p.index)
358
+ if (f.exists()) {
359
+ assertTrue(
360
+ "neighbour segment ${p.index} must not exceed its planned length",
361
+ f.length() <= p.length,
362
+ )
363
+ }
364
+ }
365
+ }
366
+
367
+ // OCDS-T5(c): short body → the segment ends short and the tail is resumed
368
+ // (Range start advanced, NOT from 0) → final SHA correct.
369
+ //
370
+ // CORE BEHAVIOUR (verified): a clean short read (a complete 206 whose body is
371
+ // shorter than the requested window, no socket error) is NOT retried inside
372
+ // the same `download()` call — `downloadSegment` returns after a successful
373
+ // `fetchSegment`, so a short segment makes that invocation throw
374
+ // "incomplete", leaving the short `.segN` on disk. The resume is
375
+ // CROSS-INVOCATION (next relaunch): the second `download()` sees a short
376
+ // `.segN`, requests only the missing tail (`Range` start = current seg
377
+ // length, which is advanced past part.start), and completes. This test drives
378
+ // both invocations to assert the tail-resume + correct final SHA + no from-0
379
+ // restart.
380
+ @Test
381
+ fun ocdsT5c_shortBodyResumesTailToCorrectSha() {
382
+ val target = parts[6]
383
+ val fs = RangeFaultServer(content)
384
+ .arm(RangeFaultServer.FaultMode.SHORT_BODY, target.start, target.end, times = 1)
385
+ startServer(fs)
386
+
387
+ val partial = partialPath()
388
+ // First invocation: the short body leaves segment 6 one byte short, so this
389
+ // call throws "incomplete" and keeps the `.segN` for resume.
390
+ var firstThrew = false
391
+ try {
392
+ newDownloader().download(
393
+ url = url(),
394
+ partialFilePath = partial,
395
+ onProgress = { _, _ -> },
396
+ )
397
+ } catch (e: Exception) {
398
+ firstThrew = true
399
+ }
400
+ assertTrue("short body must leave the first invocation incomplete", firstThrew)
401
+ // The short segment is kept on disk, one byte under its planned length.
402
+ val seg = segFile(partial, target.index)
403
+ assertTrue("short `.segN` must be kept for resume", seg.exists())
404
+ assertEquals("short segment is exactly one byte short", target.length - 1, seg.length())
405
+
406
+ val rangesAfterFirst = fs.requestedRanges.toList()
407
+
408
+ // Second invocation (relaunch): the fault has expired (times=1), so the tail
409
+ // is served normally and the download completes.
410
+ val outcome = newDownloader().download(
411
+ url = url(),
412
+ partialFilePath = partial,
413
+ onProgress = { _, _ -> },
414
+ )
415
+
416
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
417
+ assertEquals(sha256(content), sha256(File(partial)))
418
+
419
+ // The RESUME requested only the missing tail of segment 6: Range start
420
+ // advanced past part.start (= part.end here, the single missing byte), end ==
421
+ // part.end. Never a from-0 (file byte 0) restart.
422
+ val resumeRanges = fs.requestedRanges.toList().drop(rangesAfterFirst.size)
423
+ val tailResume = resumeRanges.filter {
424
+ it.first > target.start && it.first <= target.end && it.second == target.end
425
+ }
426
+ assertTrue(
427
+ "short body must trigger a tail-only resume (start advanced, not from 0)",
428
+ tailResume.isNotEmpty(),
429
+ )
430
+ }
431
+
432
+ // ------------------------------------------------------------------------
433
+ // OCDS-T8: cancel mid-download → run stops promptly; cancel-then-delete works
434
+ // (artifacts can be cleaned without a straggler resurrecting them).
435
+ // Strategy: stall every segment (1 byte/sec) so the download is in-flight,
436
+ // then cancel() from another thread. cancel() shuts the pool down and waits
437
+ // (bounded) for workers to terminate, so a subsequent delete is durable.
438
+ // ------------------------------------------------------------------------
439
+ @Test
440
+ fun ocdsT8_cancelMidDownloadStopsAndArtifactsStayDeleted() {
441
+ val fs = RangeFaultServer(content)
442
+ // Stall ALL eight segment windows so the workers are parked in body reads.
443
+ for (p in parts) {
444
+ fs.arm(RangeFaultServer.FaultMode.STALL, p.start, p.end, times = Int.MAX_VALUE)
445
+ }
446
+ startServer(fs)
447
+
448
+ val partial = partialPath()
449
+ val handle = ConcurrentRangeDownloader.CancelHandle()
450
+ val downloader = newDownloader()
451
+
452
+ val resultRef = AtomicReference<Any?>(null)
453
+ val started = CountDownLatch(1)
454
+ val worker = Executors.newSingleThreadExecutor()
455
+ val future = worker.submit {
456
+ started.countDown()
457
+ try {
458
+ resultRef.set(
459
+ downloader.download(
460
+ url = url(),
461
+ partialFilePath = partial,
462
+ cancelHandle = handle,
463
+ onProgress = { _, _ -> },
464
+ )
465
+ )
466
+ } catch (e: Exception) {
467
+ resultRef.set(e)
468
+ }
469
+ }
470
+
471
+ // Wait until the download has begun, give it a beat to open the segment
472
+ // sockets, then cancel.
473
+ started.await(5, TimeUnit.SECONDS)
474
+ Thread.sleep(400)
475
+ val cancelStart = System.currentTimeMillis()
476
+ handle.cancel()
477
+
478
+ // download() must return promptly after cancel (well under the 60s the stall
479
+ // would otherwise take). cancel() awaits worker termination (<=3s) + the
480
+ // download() loop joins futures; allow a generous bound.
481
+ future.get(15, TimeUnit.SECONDS)
482
+ val elapsed = System.currentTimeMillis() - cancelStart
483
+ worker.shutdownNow()
484
+
485
+ assertTrue("cancel must abort promptly (took ${elapsed}ms)", elapsed < 12_000)
486
+
487
+ // cancel-then-delete: now that workers are terminated, deleting the artifacts
488
+ // must STICK — no straggler worker resurrects a `.segN`.
489
+ File(partial).delete()
490
+ for (i in 0 until 8) segFile(partial, i).delete()
491
+ // Give any (forbidden) straggler a window to (incorrectly) recreate a file.
492
+ Thread.sleep(500)
493
+ assertNoArtifactsLeft(partial)
494
+
495
+ // The aborted run did not produce a COMPLETED outcome.
496
+ assertNotEquals(
497
+ "cancelled run must not report COMPLETED",
498
+ ConcurrentRangeDownloader.Outcome.COMPLETED,
499
+ resultRef.get(),
500
+ )
501
+ }
502
+
503
+ // ------------------------------------------------------------------------
504
+ // OCDS-T10: stalled socket (bytes trickle past the read timeout) → the
505
+ // read-timeout surfaces as a transient error → retry succeeds.
506
+ // The default OkHttpClient read timeout is 10s, too slow for a fast unit
507
+ // test, so we inject a client with a 600ms read timeout. The harness stalls
508
+ // the segment ONCE (times=1): the first attempt times out mid-body, the core
509
+ // retries, and the second attempt (fault expired) streams the full window.
510
+ // ------------------------------------------------------------------------
511
+ @Test
512
+ fun ocdsT10_stalledSocketTimesOutAsTransientThenRetrySucceeds() {
513
+ val target = parts[7]
514
+ val fs = RangeFaultServer(content)
515
+ .arm(RangeFaultServer.FaultMode.STALL, target.start, target.end, times = 1)
516
+ startServer(fs)
517
+
518
+ // Inject a small read timeout so the stall (1 byte/sec) trips it fast.
519
+ val fastClient = OkHttpClient.Builder()
520
+ .readTimeout(600, TimeUnit.MILLISECONDS)
521
+ .build()
522
+ val downloader = ConcurrentRangeDownloader(
523
+ httpClient = fastClient,
524
+ segmentCount = 8,
525
+ // Tiny backoff so the single retry runs fast.
526
+ retryBaseDelayMillis = 50L,
527
+ retryMaxDelayMillis = 200L,
528
+ )
529
+
530
+ val partial = partialPath()
531
+ val outcome = downloader.download(
532
+ url = url(),
533
+ partialFilePath = partial,
534
+ onProgress = { _, _ -> },
535
+ )
536
+
537
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
538
+ assertEquals(sha256(content), sha256(File(partial)))
539
+
540
+ val ranges = fs.requestedRanges.toList()
541
+ // The stalled segment is requested more than once (timeout → transient retry).
542
+ // The retry may resume from whatever partial bytes the stall delivered
543
+ // (start >= part.start), never from file byte 0.
544
+ val targetReqs = ranges.filter { it.first in target.start..target.end && it.second == target.end }
545
+ assertTrue(
546
+ "stalled segment must be retried (>=2 requests, got ${targetReqs.size})",
547
+ targetReqs.size >= 2,
548
+ )
549
+ assertTrue(
550
+ "stall retry must not restart at file byte 0",
551
+ targetReqs.all { it.first >= target.start },
552
+ )
553
+ }
554
+ }