@onekeyfe/react-native-range-downloader 3.0.67 → 3.0.69

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,368 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import org.junit.After
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertNotEquals
7
+ import org.junit.Assert.assertNull
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Before
10
+ import org.junit.Test
11
+ import java.io.File
12
+ import java.util.concurrent.ConcurrentHashMap
13
+ import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.Executors
15
+ import java.util.concurrent.TimeUnit
16
+ import java.util.concurrent.atomic.AtomicInteger
17
+
18
+ /**
19
+ * Focused JVM unit coverage for the **MonotonicProgressGate** — the
20
+ * `AtomicInteger(-1)` + CAS emit-gating idiom the Nitro adapter
21
+ * ([ReactNativeRangeDownloader]) wraps around its concurrent progress callback.
22
+ *
23
+ * IMPORTANT: this gate is NOT a standalone extracted symbol — it is the inline
24
+ * adapter expression at `ReactNativeRangeDownloader.kt`:
25
+ * - line 94: `val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1)`
26
+ * - lines 104-105:
27
+ * `val prev = lastProgress.get()`
28
+ * `if (p > prev && lastProgress.compareAndSet(prev, p)) { …emit… }`
29
+ *
30
+ * So "driving the real code" here means driving the REAL JDK
31
+ * [java.util.concurrent.atomic.AtomicInteger] (the exact type the adapter
32
+ * holds) through the verbatim adapter expression. [tryAdvance] below is NOT a
33
+ * re-implementation of business logic — it is the literal `p > prev && CAS(prev,p)`
34
+ * line copied from the adapter, kept in one place so the concurrency tests can
35
+ * call it. The 0..100 clamp and the `total > 0` guard are the CALLER's job and
36
+ * live in [RangeDownloadLogic.progressPercent] (driven directly here too); the
37
+ * gate is purely "advance to a strictly-higher Int, lock-free".
38
+ *
39
+ * The RunRegistry / single-flight and segment-artifact-path assertions drive the
40
+ * genuinely-extracted [RangeDownloadLogic.runKey] and
41
+ * [RangeDownloadLogic.sweepPartialArtifacts] against a real [ConcurrentHashMap]
42
+ * (mirroring the adapter's `activeDownloads` put / `remove(key, value)` at lines
43
+ * 87 / 112). Pure JVM JUnit — no Nitro / HybridObject / Context, matching the
44
+ * established module pattern (see RangeDownloadLogicTest / IsPermanentHttpStatusTest).
45
+ */
46
+ class MonotonicProgressGateTest {
47
+
48
+ private lateinit var tmpDir: File
49
+
50
+ @Before
51
+ fun setUp() {
52
+ tmpDir = File.createTempFile("crd-gate", "").let {
53
+ it.delete(); it.mkdirs(); it
54
+ }
55
+ }
56
+
57
+ @After
58
+ fun tearDown() {
59
+ tmpDir.deleteRecursively()
60
+ }
61
+
62
+ // The VERBATIM adapter gate expression (ReactNativeRangeDownloader.kt:104-105)
63
+ // driven against the REAL java.util.concurrent.atomic.AtomicInteger. Returns
64
+ // true iff `p` strictly exceeds the current max AND this thread won the CAS.
65
+ private fun tryAdvance(gate: AtomicInteger, p: Int): Boolean {
66
+ val prev = gate.get()
67
+ return p > prev && gate.compareAndSet(prev, p)
68
+ }
69
+
70
+ // ---- MonotonicProgressGate: single-threaded behavior ----
71
+
72
+ @Test
73
+ fun firstCallWithZeroAdvancesFromInitialMinusOne() {
74
+ // Initial state is -1 (adapter line 94), so the very first 0% must emit.
75
+ val gate = AtomicInteger(-1)
76
+ assertTrue("0 must beat the -1 initial and emit", tryAdvance(gate, 0))
77
+ assertEquals(0, gate.get())
78
+ }
79
+
80
+ @Test
81
+ fun ascendingZeroToHundredEveryCallEmits() {
82
+ val gate = AtomicInteger(-1)
83
+ for (p in 0..100) {
84
+ assertTrue("ascending $p must emit", tryAdvance(gate, p))
85
+ assertEquals(p, gate.get())
86
+ }
87
+ assertEquals(100, gate.get())
88
+ }
89
+
90
+ @Test
91
+ fun duplicateValueDoesNotReEmit() {
92
+ val gate = AtomicInteger(-1)
93
+ assertTrue("first 5 emits", tryAdvance(gate, 5))
94
+ assertFalse("equal value must NOT re-emit (strictly-greater only)", tryAdvance(gate, 5))
95
+ assertEquals("state unchanged on a no-emit", 5, gate.get())
96
+ }
97
+
98
+ @Test
99
+ fun outOfOrderLowerValueIsDroppedAndStateHolds() {
100
+ val gate = AtomicInteger(-1)
101
+ assertTrue("10 emits", tryAdvance(gate, 10))
102
+ assertFalse("a later 7 (< current max 10) must be dropped", tryAdvance(gate, 7))
103
+ assertEquals("state must stay at the high-water mark 10", 10, gate.get())
104
+ // And a subsequent strictly-higher value still advances normally.
105
+ assertTrue("11 still emits after a dropped 7", tryAdvance(gate, 11))
106
+ assertEquals(11, gate.get())
107
+ }
108
+
109
+ @Test
110
+ fun outOfOrderRepeatingStreamEmitsOnlyMonotonicHighWaterMarks() {
111
+ // Mirrors the real worker callback shape: out-of-order, repeating percentages.
112
+ val gate = AtomicInteger(-1)
113
+ val emitted = mutableListOf<Int>()
114
+ for (p in listOf(10, 10, 25, 25, 20, 50, 50, 49, 100, 100)) {
115
+ if (tryAdvance(gate, p)) emitted.add(p)
116
+ }
117
+ assertEquals(listOf(10, 25, 50, 100), emitted)
118
+ assertEquals(100, gate.get())
119
+ }
120
+
121
+ // ---- MonotonicProgressGate: concurrency (the load-bearing CAS contract) ----
122
+
123
+ @Test
124
+ fun concurrentSameTargetExactlyOneWinsCas() {
125
+ // K threads all try to advance -1 -> the SAME target. CAS guarantees exactly
126
+ // one winner; all losers see a no-emit; final state == that target.
127
+ val k = 64
128
+ val target = 42
129
+ val gate = AtomicInteger(-1)
130
+ val ready = CountDownLatch(k)
131
+ val go = CountDownLatch(1)
132
+ val wins = AtomicInteger(0)
133
+ val pool = Executors.newFixedThreadPool(k)
134
+ repeat(k) {
135
+ pool.submit {
136
+ ready.countDown()
137
+ go.await(5, TimeUnit.SECONDS)
138
+ if (tryAdvance(gate, target)) wins.incrementAndGet()
139
+ }
140
+ }
141
+ ready.await(5, TimeUnit.SECONDS)
142
+ go.countDown()
143
+ pool.shutdown()
144
+ assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS))
145
+
146
+ assertEquals("exactly ONE thread may win the CAS to the same target", 1, wins.get())
147
+ assertEquals("state must equal the advanced target", target, gate.get())
148
+ }
149
+
150
+ @Test
151
+ fun concurrentRacingIncrementsEmitOncePerDistinctHighWaterUpdate() {
152
+ // Many threads each push the SAME ascending sequence 0..100 concurrently.
153
+ // The gate is lock-free + monotonic, so across ALL threads each distinct
154
+ // high-water value (0..100 -> 101 values) advances exactly once: total true
155
+ // returns == 101, regardless of thread count or interleaving. Final == 100.
156
+ val threads = 16
157
+ val maxPercent = 100
158
+ val gate = AtomicInteger(-1)
159
+ val totalWins = AtomicInteger(0)
160
+ val ready = CountDownLatch(threads)
161
+ val go = CountDownLatch(1)
162
+ val pool = Executors.newFixedThreadPool(threads)
163
+ repeat(threads) {
164
+ pool.submit {
165
+ ready.countDown()
166
+ go.await(5, TimeUnit.SECONDS)
167
+ for (p in 0..maxPercent) {
168
+ if (tryAdvance(gate, p)) totalWins.incrementAndGet()
169
+ }
170
+ }
171
+ }
172
+ ready.await(5, TimeUnit.SECONDS)
173
+ go.countDown()
174
+ pool.shutdown()
175
+ assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS))
176
+
177
+ assertEquals(
178
+ "true returns must equal the count of distinct strictly-increasing updates (0..100)",
179
+ maxPercent + 1,
180
+ totalWins.get(),
181
+ )
182
+ assertEquals("final state must be the max seen", maxPercent, gate.get())
183
+ }
184
+
185
+ @Test
186
+ fun concurrentMonotonicityNeverObservesARegression() {
187
+ // Under concurrent advancement the gate must never let `get()` go backwards.
188
+ // An observer thread samples the gate while writers race it forward; every
189
+ // sample must be >= the previous sample.
190
+ val gate = AtomicInteger(-1)
191
+ val writers = 8
192
+ val maxPercent = 100
193
+ val ready = CountDownLatch(writers)
194
+ val go = CountDownLatch(1)
195
+ val regressionObserved = AtomicInteger(0)
196
+ val pool = Executors.newFixedThreadPool(writers + 1)
197
+
198
+ val observer = pool.submit {
199
+ var last = Int.MIN_VALUE
200
+ // Sample aggressively until the gate reaches the terminal value.
201
+ while (gate.get() < maxPercent) {
202
+ val now = gate.get()
203
+ if (now < last) regressionObserved.incrementAndGet()
204
+ last = now
205
+ }
206
+ if (gate.get() < last) regressionObserved.incrementAndGet()
207
+ }
208
+
209
+ repeat(writers) {
210
+ pool.submit {
211
+ ready.countDown()
212
+ go.await(5, TimeUnit.SECONDS)
213
+ for (p in 0..maxPercent) tryAdvance(gate, p)
214
+ }
215
+ }
216
+ ready.await(5, TimeUnit.SECONDS)
217
+ go.countDown()
218
+ observer.get(10, TimeUnit.SECONDS)
219
+ pool.shutdown()
220
+ assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS))
221
+
222
+ assertEquals("observer must never see progress go backwards", 0, regressionObserved.get())
223
+ assertEquals(maxPercent, gate.get())
224
+ }
225
+
226
+ // ---- Caller-owned clamp/guard feeding the gate (RangeDownloadLogic.progressPercent) ----
227
+
228
+ @Test
229
+ fun callerClampAndGuardOwnTheZeroToHundredRangeNotTheGate() {
230
+ // total <= 0 yields no percentage at all (guarded by the caller), so the gate
231
+ // is never even consulted.
232
+ assertNull("non-positive total emits nothing", RangeDownloadLogic.progressPercent(10, 0))
233
+ assertNull(RangeDownloadLogic.progressPercent(10, -1))
234
+ // Over-100 transferred (momentary cross-segment overshoot) is clamped to 100
235
+ // BEFORE the gate, so the gate only ever sees values in 0..100.
236
+ assertEquals(100, RangeDownloadLogic.progressPercent(150, 100))
237
+ // The full pipeline: clamped percentages flow into the gate monotonically.
238
+ val gate = AtomicInteger(-1)
239
+ val emitted = mutableListOf<Int>()
240
+ for ((transferred, total) in listOf(0L to 100L, 50L to 100L, 49L to 100L, 150L to 100L)) {
241
+ val p = RangeDownloadLogic.progressPercent(transferred, total) ?: continue
242
+ if (tryAdvance(gate, p)) emitted.add(p)
243
+ }
244
+ assertEquals(listOf(0, 50, 100), emitted)
245
+ }
246
+
247
+ // ---- RunRegistry / single-flight (RangeDownloadLogic.runKey + activeDownloads map) ----
248
+
249
+ @Test
250
+ fun runKeyJoinsChannelAndTaskStablyAndCollisionFree() {
251
+ // Stable: same inputs -> same key.
252
+ assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1"))
253
+ assertEquals(
254
+ RangeDownloadLogic.runKey("MAIN", "task-1"),
255
+ RangeDownloadLogic.runKey("MAIN", "task-1"),
256
+ )
257
+ // Collision-free across channel and across taskId.
258
+ assertNotEquals(
259
+ RangeDownloadLogic.runKey("MAIN", "t"),
260
+ RangeDownloadLogic.runKey("OTHER", "t"),
261
+ )
262
+ assertNotEquals(
263
+ RangeDownloadLogic.runKey("MAIN", "a"),
264
+ RangeDownloadLogic.runKey("MAIN", "b"),
265
+ )
266
+ }
267
+
268
+ @Test
269
+ fun singleFlightRegistryDedupesSecondRegisterForSameKey() {
270
+ // Models the adapter's `activeDownloads[runKey] = handle` (line 87) single-flight
271
+ // map. putIfAbsent rejects a second register for the same key.
272
+ val registry = ConcurrentHashMap<String, Any>()
273
+ val key = RangeDownloadLogic.runKey("MAIN", "task-7")
274
+ val first = Any()
275
+ val second = Any()
276
+ assertNull("first register wins", registry.putIfAbsent(key, first))
277
+ assertTrue("second register for same key is rejected (returns the incumbent)",
278
+ registry.putIfAbsent(key, second) === first)
279
+ assertTrue("incumbent handle must remain", registry[key] === first)
280
+ // A different key (different taskId) co-exists, not deduped.
281
+ val otherKey = RangeDownloadLogic.runKey("MAIN", "task-8")
282
+ assertNull(registry.putIfAbsent(otherKey, second))
283
+ assertEquals(2, registry.size)
284
+ }
285
+
286
+ @Test
287
+ fun cancelRemovesOnlyTheMatchingHandleNotAConcurrentReplacement() {
288
+ // Mirrors `activeDownloads.remove(runKey, cancelHandle)` (line 112): the
289
+ // value-conditional remove only clears OUR handle, so a concurrent cancel that
290
+ // already replaced the handle for the same key is left intact.
291
+ val registry = ConcurrentHashMap<String, Any>()
292
+ val key = RangeDownloadLogic.runKey("MAIN", "task-9")
293
+ val ours = Any()
294
+ val replacement = Any()
295
+ registry[key] = ours
296
+ // Simulate a concurrent cancel replacing the handle under the same key.
297
+ registry[key] = replacement
298
+ // Our finally-block remove must NOT evict the replacement.
299
+ assertFalse("value-conditional remove must not evict a replaced handle",
300
+ registry.remove(key, ours))
301
+ assertTrue("replacement handle survives", registry[key] === replacement)
302
+ // Removing the actual current value succeeds and clears the key.
303
+ assertTrue(registry.remove(key, replacement))
304
+ assertNull(registry[key])
305
+ }
306
+
307
+ @Test
308
+ fun concurrentRegistersForSameKeyOnlyOneWins() {
309
+ val registry = ConcurrentHashMap<String, Any>()
310
+ val key = RangeDownloadLogic.runKey("MAIN", "race")
311
+ val k = 32
312
+ val winners = AtomicInteger(0)
313
+ val ready = CountDownLatch(k)
314
+ val go = CountDownLatch(1)
315
+ val pool = Executors.newFixedThreadPool(k)
316
+ repeat(k) {
317
+ pool.submit {
318
+ val mine = Any()
319
+ ready.countDown()
320
+ go.await(5, TimeUnit.SECONDS)
321
+ if (registry.putIfAbsent(key, mine) == null) winners.incrementAndGet()
322
+ }
323
+ }
324
+ ready.await(5, TimeUnit.SECONDS)
325
+ go.countDown()
326
+ pool.shutdown()
327
+ assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS))
328
+ assertEquals("exactly one concurrent register may win the single-flight slot", 1, winners.get())
329
+ assertEquals(1, registry.size)
330
+ }
331
+
332
+ // ---- segment artifact paths / sweep (RangeDownloadLogic.sweepPartialArtifacts) ----
333
+
334
+ @Test
335
+ fun segmentArtifactPathsAreDotSegNUnderTheDotPartialPrefix() {
336
+ // Drive the REAL sweep: it deletes `<dest>.partial` + every
337
+ // `<dest>.partial.seg<N>` matched by filename prefix, for ANY segment count.
338
+ val dest = File(tmpDir, "asset.bin").absolutePath
339
+ val partial = File("$dest.partial").apply { writeText("p") }
340
+ val count = 8
341
+ val segs = (0 until count).map { File("$dest.partial.seg$it").apply { writeText("$it") } }
342
+ // Verify the path shape the adapter/helper assembles for a given filePath+count.
343
+ segs.forEachIndexed { i, f ->
344
+ assertEquals("seg path must be <dest>.partial.seg$i", "$dest.partial.seg$i", f.absolutePath)
345
+ }
346
+ // A sibling NOT under the `.partial` prefix must survive the sweep.
347
+ val unrelated = File("$dest.keep").apply { writeText("k") }
348
+
349
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
350
+
351
+ assertFalse("`.partial` must be swept", partial.exists())
352
+ segs.forEach { assertFalse("${it.name} must be swept", it.exists()) }
353
+ assertTrue("unrelated sibling must survive", unrelated.exists())
354
+ }
355
+
356
+ @Test
357
+ fun sweepClearsCustomSegmentCountByPrefixNotJustDefaultEight() {
358
+ val dest = File(tmpDir, "big.bin").absolutePath
359
+ File("$dest.partial").writeText("p")
360
+ // A custom (non-default) 16-segment run must be fully cleared by prefix.
361
+ val segs = (0 until 16).map { File("$dest.partial.seg$it").apply { writeText("$it") } }
362
+
363
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
364
+
365
+ segs.forEach { assertFalse("${it.name} (custom count) must be swept", it.exists()) }
366
+ assertFalse(File("$dest.partial").exists())
367
+ }
368
+ }
@@ -0,0 +1,272 @@
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.assertTrue
9
+ import org.junit.Before
10
+ import org.junit.Test
11
+ import java.io.File
12
+ import java.util.concurrent.TimeUnit
13
+
14
+ /**
15
+ * OCDS gap A2 (SPEC #4 — 416 Range Not Satisfiable kept TRANSIENT).
16
+ *
17
+ * Guards the single most-damaging §4 mistake called out in the core comment
18
+ * (ConcurrentRangeDownloader.kt lines 590-593): 416 MUST classify as transient
19
+ * (`isPermanentHttpStatus` line 593 `416 -> false`, placed ABOVE the
20
+ * `in 400..499 -> true` catch-all at line 594). If 416 were permanent the core
21
+ * would discard the resumable `.segN` bytes and restart the whole object from
22
+ * byte 0 — exactly the "do not discard resumable bytes" rule this test pins.
23
+ *
24
+ * Scenario: a RESUME run where parts 0..4 are already full on disk (seeded
25
+ * `.segN`), and the part[5] window hits a single 416 (`STATUS_416` →
26
+ * `416 + Content-Range: bytes star-slash-total`, FaultServer.kt:67,139-142) that then
27
+ * succeeds on retry. The load-bearing claims:
28
+ * - outcome == COMPLETED and the assembled SHA matches the source (so the 416
29
+ * was recovered, not fatal, and nothing was corrupted);
30
+ * - the seeded windows 0..4 are NOT re-fetched — proving the 416 did NOT
31
+ * trigger a from-0 wipe/restart of already-downloaded segments;
32
+ * - the seeded `.segN` for parts 0..4 are NOT deleted/re-created — their
33
+ * on-disk length stays exactly full (the "do not discard resumable bytes"
34
+ * guarantee, asserted on the bytes on disk, not just the request log);
35
+ * - part[5] is requested exactly TWICE (416 then OK), and every request for
36
+ * it starts inside its own window (>= part[5].start), never at file byte 0.
37
+ *
38
+ * Drives the REAL [ConcurrentRangeDownloader.download] over the
39
+ * [RangeFaultServer] harness — no behavior is re-implemented here.
40
+ */
41
+ class Ocds416ResumeTest {
42
+
43
+ private lateinit var server: MockWebServer
44
+ private lateinit var tmpDir: File
45
+
46
+ // 3 MiB > minConcurrentBytes (2 MiB) → concurrent path, 8 non-empty parts.
47
+ private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
48
+ private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
49
+
50
+ @Before
51
+ fun setUp() {
52
+ tmpDir = File.createTempFile("crd-ocds-416", "").let {
53
+ it.delete(); it.mkdirs(); it
54
+ }
55
+ }
56
+
57
+ @After
58
+ fun tearDown() {
59
+ if (this::server.isInitialized) server.shutdown()
60
+ tmpDir.deleteRecursively()
61
+ }
62
+
63
+ // --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) ------------------
64
+
65
+ private fun startServer(faultServer: RangeFaultServer) {
66
+ server = MockWebServer()
67
+ server.dispatcher = faultServer.dispatcher
68
+ server.start()
69
+ }
70
+
71
+ private fun url() = server.url("/asset.bin").toString()
72
+
73
+ private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
74
+
75
+ private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
76
+
77
+ // ------------------------------------------------------------------------
78
+ // OCDS gap A2: 416 on a resume is TRANSIENT — recovered in place, resumable
79
+ // bytes preserved, no from-0 restart.
80
+ // ------------------------------------------------------------------------
81
+ @Test
82
+ fun ocds416OnResumeIsTransientKeepsResumableBytesAndCompletes() {
83
+ val target = parts[5]
84
+ val fs = RangeFaultServer(content)
85
+ // One 416 on the part[5] window, then the retry is served normally.
86
+ .arm(RangeFaultServer.FaultMode.STATUS_416, target.start, target.end, times = 1)
87
+ startServer(fs)
88
+
89
+ val partial = partialPath()
90
+
91
+ // Seed parts 0..4 as already-completed `.segN` files (mid-run resume model).
92
+ // These full-length segments must survive the 416 untouched.
93
+ seedSegments(partial, content, completedThrough = 4, segments = 8)
94
+ val seededLengths: Map<Int, Long> = (0..4).associateWith { idx ->
95
+ val f = segFile(partial, idx)
96
+ assertTrue("seed for part $idx must exist before run", f.exists())
97
+ f.length()
98
+ }
99
+ // Sanity: each seed is exactly its planned full length going in.
100
+ for (idx in 0..4) {
101
+ assertEquals(
102
+ "seed for part $idx must start at full planned length",
103
+ parts[idx].length,
104
+ seededLengths.getValue(idx),
105
+ )
106
+ }
107
+
108
+ // Fast backoff so the single 416 retry runs quickly (mirrors ocdsT10).
109
+ val downloader = ConcurrentRangeDownloader(
110
+ httpClient = OkHttpClient(),
111
+ segmentCount = 8,
112
+ retryBaseDelayMillis = 50L,
113
+ retryMaxDelayMillis = 200L,
114
+ )
115
+
116
+ val outcome = downloader.download(
117
+ url = url(),
118
+ partialFilePath = partial,
119
+ onProgress = { _, _ -> },
120
+ )
121
+
122
+ // (1) The 416 was recovered (not fatal) and assembly is byte-correct.
123
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
124
+ assertEquals(content.size.toLong(), File(partial).length())
125
+ assertEquals(sha256(content), sha256(File(partial)))
126
+
127
+ // The probe always issues a "bytes=0-0" capability check; exclude it from the
128
+ // segment-window counts (mirrors ocdsT2b lines 184-191).
129
+ val PROBE = 0L to 0L
130
+ val ranges = fs.requestedRanges.toList().filterNot { it == PROBE }
131
+
132
+ // (2) No from-0 wipe/restart: the seeded windows 0..4 are NOT re-fetched.
133
+ // A permanent-416 regression would discard `.segN` and re-request these.
134
+ for (idx in 0..4) {
135
+ val p = parts[idx]
136
+ val reqs = ranges.filter { it.first in p.start..p.end }
137
+ assertTrue("seeded segment $idx must NOT be re-fetched after a 416 (got $reqs)", reqs.isEmpty())
138
+ }
139
+ // And specifically nothing ever re-requested file byte 0 as a segment fetch.
140
+ assertTrue(
141
+ "no segment fetch may restart at file byte 0 after a 416",
142
+ ranges.none { it.first == 0L && it.second == 0L },
143
+ )
144
+
145
+ // (3) The direct on-disk proof that a 416 never discards resumable `.segN`
146
+ // bytes lives in `ocds416NeverDiscardsSeededSegmentsOnDisk` (a perpetual 416
147
+ // is held mid-run so the seeded segments can be length-checked on disk). On
148
+ // this COMPLETED path the segments are correctly consumed, so the load-bearing
149
+ // proof here is the SHA + the request log: seeded windows 0..4 were never
150
+ // re-fetched (asserted above) and part[5] recovered in place (asserted next).
151
+
152
+ // (4) part[5] is requested exactly TWICE (416 then OK). The 416 has an empty
153
+ // body, so part[5] has 0 bytes on disk → the retry re-requests the SAME full
154
+ // window (start == part.start, end == part.end), never advancing past it and
155
+ // never collapsing to file byte 0.
156
+ val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
157
+ assertEquals(
158
+ "part[5] must be requested exactly twice (416 then OK)",
159
+ 2,
160
+ targetReqs.size,
161
+ )
162
+ assertTrue(
163
+ "every part[5] request must start inside its own window (>= part.start), never file byte 0",
164
+ targetReqs.all { it.first >= target.start },
165
+ )
166
+
167
+ // (5) The tail beyond part[5] (parts 6..7) was fetched once each — the run
168
+ // proceeded normally around the recovered 416 with no global restart.
169
+ for (idx in 6..7) {
170
+ val p = parts[idx]
171
+ val reqs = ranges.filter { it.first == p.start && it.second == p.end }
172
+ assertEquals("tail segment $idx must be requested once", 1, reqs.size)
173
+ }
174
+
175
+ // (6) On COMPLETED every `.segN` is consumed (no straggler artifacts).
176
+ for (i in 0 until 8) {
177
+ assertFalse(".seg$i must be wiped on success", segFile(partial, i).exists())
178
+ }
179
+ }
180
+
181
+ // ------------------------------------------------------------------------
182
+ // OCDS gap A2 (resumable-bytes guard, no from-0 wipe): drive a run that does
183
+ // NOT complete past the 416 so we can observe the seeded `.segN` ON DISK and
184
+ // prove a 416 leaves them intact (rather than discarding them and restarting
185
+ // from byte 0). We arm 416 FOREVER on part[5] so it never recovers; the run
186
+ // exhausts the per-segment retry budget and throws, leaving the seeded
187
+ // segments 0..4 untouched on disk for direct length inspection.
188
+ // ------------------------------------------------------------------------
189
+ @Test
190
+ fun ocds416NeverDiscardsSeededSegmentsOnDisk() {
191
+ val target = parts[5]
192
+ val fs = RangeFaultServer(content)
193
+ // Fire 416 forever so part[5] never recovers and the run gives up — but a
194
+ // transient classification must keep the OTHER segments' bytes on disk.
195
+ .arm(RangeFaultServer.FaultMode.STATUS_416, target.start, target.end, times = Int.MAX_VALUE)
196
+ startServer(fs)
197
+
198
+ val partial = partialPath()
199
+ seedSegments(partial, content, completedThrough = 4, segments = 8)
200
+ val seededLengths: Map<Int, Long> = (0..4).associateWith { idx ->
201
+ segFile(partial, idx).length()
202
+ }
203
+
204
+ // Fast backoff so the bounded retries exhaust quickly.
205
+ val downloader = ConcurrentRangeDownloader(
206
+ httpClient = OkHttpClient(),
207
+ segmentCount = 8,
208
+ retryBaseDelayMillis = 20L,
209
+ retryMaxDelayMillis = 80L,
210
+ )
211
+
212
+ var threw = false
213
+ var outcome: ConcurrentRangeDownloader.Outcome? = null
214
+ try {
215
+ outcome = downloader.download(
216
+ url = url(),
217
+ partialFilePath = partial,
218
+ onProgress = { _, _ -> },
219
+ )
220
+ } catch (e: Exception) {
221
+ threw = true
222
+ }
223
+
224
+ // A perpetual 416 is transient → retried then given up (throws), NEVER a
225
+ // permanent FALLBACK that wipes artifacts, and NEVER a corrupt COMPLETED.
226
+ assertTrue(
227
+ "a perpetual 416 must give up via a thrown transient exhaustion, not COMPLETED/FALLBACK",
228
+ threw,
229
+ )
230
+ assertFalse(
231
+ "a 416 must not produce a COMPLETED outcome",
232
+ outcome == ConcurrentRangeDownloader.Outcome.COMPLETED,
233
+ )
234
+
235
+ // THE load-bearing on-disk assertion: the seeded `.segN` for parts 0..4 are
236
+ // still present AND still exactly their full planned length. A permanent-416
237
+ // regression (discard resumable bytes + restart from byte 0) would have
238
+ // deleted or truncated these — this fails loudly if that ever happens.
239
+ for (idx in 0..4) {
240
+ val f = segFile(partial, idx)
241
+ assertTrue("seeded `.seg$idx` must NOT be discarded by a 416", f.exists())
242
+ assertEquals(
243
+ "seeded `.seg$idx` must keep its full resumable length after a 416 (no from-0 wipe)",
244
+ seededLengths.getValue(idx),
245
+ f.length(),
246
+ )
247
+ assertEquals(
248
+ "seeded `.seg$idx` length must equal its planned window length",
249
+ parts[idx].length,
250
+ f.length(),
251
+ )
252
+ }
253
+
254
+ // The 416 part window was actually exercised (the fault fired), and no
255
+ // request collapsed to a from-0 restart of an already-seeded window.
256
+ val PROBE = 0L to 0L
257
+ val ranges = fs.requestedRanges.toList().filterNot { it == PROBE }
258
+ val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
259
+ assertTrue("part[5] (the 416 window) must have been requested", targetReqs.isNotEmpty())
260
+ assertTrue(
261
+ "every part[5] request stays inside its window; no from-0 restart",
262
+ targetReqs.all { it.first >= target.start },
263
+ )
264
+ for (idx in 0..4) {
265
+ val p = parts[idx]
266
+ assertTrue(
267
+ "seeded window $idx must never be re-fetched because of a 416",
268
+ ranges.none { it.first in p.start..p.end },
269
+ )
270
+ }
271
+ }
272
+ }