@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.
- package/README.md +15 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
- package/ios/RangeDownloadLogic.swift +187 -0
- package/ios/ReactNativeRangeDownloader.swift +669 -133
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
- package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
- package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
- package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
- package/package.json +1 -1
- package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
|
@@ -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
|
+
}
|
package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt
ADDED
|
@@ -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
|
+
}
|