@onekeyfe/react-native-range-downloader 3.0.67 → 3.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
- package/ios/RangeDownloadLogic.swift +187 -0
- package/ios/ReactNativeRangeDownloader.swift +669 -133
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
- package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
- package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
- package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
- package/package.json +1 -1
- package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
|
@@ -0,0 +1,350 @@
|
|
|
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
|
+
* Unit coverage for the extracted "segmentArtifactPaths + sweepPartialArtifacts
|
|
20
|
+
* (.segN glob / delete)" behavior plus the two adapter invariants it ships with
|
|
21
|
+
* (single-flight run-key, monotonic/de-duped progress gate).
|
|
22
|
+
*
|
|
23
|
+
* IMPORTANT — drives the REAL extracted code, never re-implements it:
|
|
24
|
+
* - The Android extraction lives in [RangeDownloadLogic] (the dependency-free
|
|
25
|
+
* pieces of `ReactNativeRangeDownloader`). On Android the path computation and
|
|
26
|
+
* the delete side-effect are NOT two separate public functions — the verbatim
|
|
27
|
+
* glob predicate `f.name.startsWith(partial.name + ".seg")` and the
|
|
28
|
+
* `.forEach { it.delete() }` live INSIDE [RangeDownloadLogic.sweepPartialArtifacts]
|
|
29
|
+
* (RangeDownloadLogic.kt:46-52, mirroring adapter:209-213). So the glob is
|
|
30
|
+
* asserted the only way it is observable here: by which sibling files survive
|
|
31
|
+
* vs. vanish on disk after the REAL sweep runs. We deliberately do not
|
|
32
|
+
* reproduce the predicate in the test.
|
|
33
|
+
* - The "RunRegistry / single-flight" key is [RangeDownloadLogic.runKey]; the
|
|
34
|
+
* `activeDownloads` map that dedupes by it is a `ConcurrentHashMap` in the
|
|
35
|
+
* adapter, so the single-flight / cancel-removes-only-matching / no-co-exist
|
|
36
|
+
* semantics are exercised against the REAL key over that same map type.
|
|
37
|
+
* - The "MonotonicProgressGate" is [RangeDownloadLogic.progressPercent] feeding
|
|
38
|
+
* the adapter's verbatim `p > prev && compareAndSet` loop
|
|
39
|
+
* (ReactNativeRangeDownloader.kt:102-108). We drive the REAL percent fn and the
|
|
40
|
+
* REAL `AtomicInteger` CAS, never a hand-rolled monotone helper.
|
|
41
|
+
*
|
|
42
|
+
* Pure JVM JUnit4 — no Nitro / HybridObject / Context — matching the established
|
|
43
|
+
* convention in this module (see RangeDownloadLogicTest / IsPermanentHttpStatusTest).
|
|
44
|
+
*/
|
|
45
|
+
class SegmentArtifactSweepTest {
|
|
46
|
+
|
|
47
|
+
private lateinit var tmpDir: File
|
|
48
|
+
|
|
49
|
+
@Before
|
|
50
|
+
fun setUp() {
|
|
51
|
+
tmpDir = File.createTempFile("crd-sweep", "").let {
|
|
52
|
+
it.delete(); it.mkdirs(); it
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@After
|
|
57
|
+
fun tearDown() {
|
|
58
|
+
tmpDir.deleteRecursively()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- helpers -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
private fun destPath(name: String = "asset.bin") = File(tmpDir, name).absolutePath
|
|
64
|
+
private fun partial(dest: String) = File("$dest.partial")
|
|
65
|
+
private fun seg(dest: String, index: Int) = File("$dest.partial.seg$index")
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------------------
|
|
68
|
+
// segmentArtifactPaths + sweepPartialArtifacts (.segN glob / delete)
|
|
69
|
+
// ------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
// Idea 1: <dest>.partial.seg0..seg11 (custom count > shipped 8) + <dest>.partial
|
|
72
|
+
// + an unrelated sibling → the sweep removes exactly the 12 seg files and the
|
|
73
|
+
// .partial, and leaves the unrelated sibling (proving the glob set = every
|
|
74
|
+
// per-segment file regardless of segmentCount, PLUS the .partial, and nothing
|
|
75
|
+
// else).
|
|
76
|
+
@Test
|
|
77
|
+
fun sweepGlobsEverySegRegardlessOfCountAndThePartialButNotUnrelated() {
|
|
78
|
+
val dest = destPath()
|
|
79
|
+
val p = partial(dest).apply { writeText("concatenated") }
|
|
80
|
+
// 12 > the shipped default of 8 — the prefix glob must catch all of them.
|
|
81
|
+
val segs = (0 until 12).map { seg(dest, it).apply { writeText("s$it") } }
|
|
82
|
+
// A sibling that shares the <dest> stem but is NOT a .partial / .segN artifact.
|
|
83
|
+
val unrelated = File(tmpDir, "asset.bin.keep").apply { writeText("keep") }
|
|
84
|
+
// A sibling that does not share the stem at all.
|
|
85
|
+
val foreign = File(tmpDir, "other.txt").apply { writeText("other") }
|
|
86
|
+
|
|
87
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
88
|
+
|
|
89
|
+
assertFalse(".partial must be deleted", p.exists())
|
|
90
|
+
segs.forEach { assertFalse("${it.name} must be deleted", it.exists()) }
|
|
91
|
+
assertTrue("unrelated <dest>.keep sibling must survive", unrelated.exists())
|
|
92
|
+
assertTrue("foreign sibling must survive", foreign.exists())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Idea 2: no parent dir / parentFile resolvable to null → the safe-call `?.`
|
|
96
|
+
// on listFiles means the seg-glob phase is skipped, and the bare `partial.delete()`
|
|
97
|
+
// still runs without throwing (mirrors the `?.listFiles` null-guard). We assert
|
|
98
|
+
// on the observable contract: no crash, and the call is a no-op for a path whose
|
|
99
|
+
// parent holds nothing.
|
|
100
|
+
@Test
|
|
101
|
+
fun sweepDoesNotCrashWhenNoSegArtifactsExist() {
|
|
102
|
+
// A real path with a real parent dir but zero artifacts: the listFiles glob
|
|
103
|
+
// returns an empty array and partial.delete() is a no-op — no throw.
|
|
104
|
+
val dest = destPath("nothing-here.bin")
|
|
105
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
106
|
+
assertFalse(partial(dest).exists())
|
|
107
|
+
|
|
108
|
+
// A relative bare filename ("foo.bin") makes File("foo.bin.partial").parentFile
|
|
109
|
+
// null; the `?.` short-circuits the glob and only the .partial delete is
|
|
110
|
+
// attempted. Must not throw (the load-bearing null-safety assertion).
|
|
111
|
+
RangeDownloadLogic.sweepPartialArtifacts("crd-sweep-nonexistent-bare-name.bin")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Idea 3: the sweep deletes all .segN and the .partial; the final <dest> and
|
|
115
|
+
// unrelated siblings are untouched (the sweep never touches the promoted final).
|
|
116
|
+
@Test
|
|
117
|
+
fun sweepLeavesFinalDestAndSiblingsUntouched() {
|
|
118
|
+
val dest = destPath()
|
|
119
|
+
val finalFile = File(dest).apply { writeText("final promoted bytes") }
|
|
120
|
+
val p = partial(dest).apply { writeText("p") }
|
|
121
|
+
val segs = (0 until 4).map { seg(dest, it).apply { writeText("$it") } }
|
|
122
|
+
val sibling = File(tmpDir, "asset.bin.metadata").apply { writeText("m") }
|
|
123
|
+
|
|
124
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
125
|
+
|
|
126
|
+
assertFalse(".partial must be gone", p.exists())
|
|
127
|
+
segs.forEach { assertFalse("${it.name} must be gone", it.exists()) }
|
|
128
|
+
assertTrue("the final <dest> must NOT be swept", finalFile.exists())
|
|
129
|
+
assertEquals("final <dest> content must be intact", "final promoted bytes", finalFile.readText())
|
|
130
|
+
assertTrue("unrelated sibling must survive", sibling.exists())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Idea 4: prefix-boundary — a file named "<dest>.partial.segment-notes" still
|
|
134
|
+
// matches because the predicate is prefix-only (startsWith ".partial.seg"), not
|
|
135
|
+
// "<dest>.partial.seg<digits>". This documents (behavior-preserving) that today's
|
|
136
|
+
// glob would sweep it too.
|
|
137
|
+
@Test
|
|
138
|
+
fun sweepPrefixIsBoundaryLooseAndAlsoMatchesSegmentNotes() {
|
|
139
|
+
val dest = destPath()
|
|
140
|
+
partial(dest).writeText("p")
|
|
141
|
+
val numericSeg = seg(dest, 0).apply { writeText("0") }
|
|
142
|
+
// Shares the "<partial>.seg" prefix but is not a numeric segment.
|
|
143
|
+
val prefixLookalike = File("$dest.partial.segment-notes").apply { writeText("notes") }
|
|
144
|
+
// Does NOT start with "<basename>.partial.seg" (the stem is "...partialX...")
|
|
145
|
+
// → must survive, proving the match is anchored on that exact prefix.
|
|
146
|
+
val nearMiss = File("$dest.partialX.seg0").apply { writeText("x") }
|
|
147
|
+
|
|
148
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
149
|
+
|
|
150
|
+
assertFalse("numeric .seg0 must be swept", numericSeg.exists())
|
|
151
|
+
assertFalse(
|
|
152
|
+
"prefix-only predicate also sweeps '<partial>.segment-notes' (behavior-preserving)",
|
|
153
|
+
prefixLookalike.exists(),
|
|
154
|
+
)
|
|
155
|
+
assertTrue("a sibling not sharing the '.partial.seg' prefix must survive", nearMiss.exists())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Idea 5: idempotent — sweeping twice (artifacts already gone) does not throw and
|
|
159
|
+
// is a no-op.
|
|
160
|
+
@Test
|
|
161
|
+
fun sweepIsIdempotentAndSafeToRepeat() {
|
|
162
|
+
val dest = destPath()
|
|
163
|
+
partial(dest).writeText("p")
|
|
164
|
+
(0 until 3).forEach { seg(dest, it).writeText("$it") }
|
|
165
|
+
|
|
166
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
167
|
+
// Everything is already gone; a second sweep must be a clean no-op.
|
|
168
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
169
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
170
|
+
|
|
171
|
+
assertFalse(partial(dest).exists())
|
|
172
|
+
(0 until 3).forEach { assertFalse(seg(dest, it).exists()) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ------------------------------------------------------------------------
|
|
176
|
+
// RunRegistry / single-flight (RangeDownloadLogic.runKey over the adapter's
|
|
177
|
+
// ConcurrentHashMap<String, CancelHandle> activeDownloads map).
|
|
178
|
+
// ------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
// runKey is stable + collision-free across channel/taskId.
|
|
181
|
+
@Test
|
|
182
|
+
fun runKeyIsStableAndCollisionFreeAcrossChannelAndTask() {
|
|
183
|
+
// Stable: same inputs → same key, every call.
|
|
184
|
+
assertEquals(
|
|
185
|
+
RangeDownloadLogic.runKey("MAIN", "task-1"),
|
|
186
|
+
RangeDownloadLogic.runKey("MAIN", "task-1"),
|
|
187
|
+
)
|
|
188
|
+
assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1"))
|
|
189
|
+
// Collision-free across channel.
|
|
190
|
+
assertNotEquals(
|
|
191
|
+
RangeDownloadLogic.runKey("MAIN", "t"),
|
|
192
|
+
RangeDownloadLogic.runKey("OTHER", "t"),
|
|
193
|
+
)
|
|
194
|
+
// Collision-free across taskId.
|
|
195
|
+
assertNotEquals(
|
|
196
|
+
RangeDownloadLogic.runKey("MAIN", "a"),
|
|
197
|
+
RangeDownloadLogic.runKey("MAIN", "b"),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A second register for the same key dedupes (the map holds a single handle per
|
|
202
|
+
// key); cancel/discard removes only the matching key; concurrent registers for
|
|
203
|
+
// distinct keys co-exist while the same key never does.
|
|
204
|
+
@Test
|
|
205
|
+
fun singleFlightRegistryDedupesPerKeyAndRemovesOnlyTheMatchingKey() {
|
|
206
|
+
// Mirror the adapter's registry type exactly (ReactNativeRangeDownloader.kt:41-42).
|
|
207
|
+
val active = ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
|
|
208
|
+
|
|
209
|
+
val keyA = RangeDownloadLogic.runKey("MAIN", "task-A")
|
|
210
|
+
val keyB = RangeDownloadLogic.runKey("OTHER", "task-A")
|
|
211
|
+
|
|
212
|
+
val h1 = ConcurrentRangeDownloader.CancelHandle()
|
|
213
|
+
val h2 = ConcurrentRangeDownloader.CancelHandle()
|
|
214
|
+
active[keyA] = h1
|
|
215
|
+
// A second register for the SAME key replaces (does not co-exist) — only ever
|
|
216
|
+
// one live handle per "channel|taskId".
|
|
217
|
+
active[keyA] = h2
|
|
218
|
+
assertEquals("same-key register must not co-exist; map holds exactly one", 1, active.keys.count { it == keyA })
|
|
219
|
+
assertEquals("the latest handle wins for the same key", h2, active[keyA])
|
|
220
|
+
|
|
221
|
+
// A distinct key (different channel, same taskId) co-exists.
|
|
222
|
+
val hB = ConcurrentRangeDownloader.CancelHandle()
|
|
223
|
+
active[keyB] = hB
|
|
224
|
+
assertEquals("distinct keys co-exist", 2, active.size)
|
|
225
|
+
|
|
226
|
+
// cancel/discard for keyA removes ONLY keyA, leaving keyB intact.
|
|
227
|
+
active.remove(keyA)
|
|
228
|
+
assertNull("cancelled key must be gone", active[keyA])
|
|
229
|
+
assertEquals("only the matching key is removed", hB, active[keyB])
|
|
230
|
+
assertEquals(1, active.size)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The adapter deregisters with the value-checked remove(key, value) so a
|
|
234
|
+
// concurrent cancel that already replaced the handle is NOT clobbered
|
|
235
|
+
// (ReactNativeRangeDownloader.kt:112). Drive that exact 2-arg remove contract.
|
|
236
|
+
@Test
|
|
237
|
+
fun valueCheckedRemoveOnlyDeregistersOwnHandle() {
|
|
238
|
+
val active = ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
|
|
239
|
+
val key = RangeDownloadLogic.runKey("MAIN", "task-X")
|
|
240
|
+
val mine = ConcurrentRangeDownloader.CancelHandle()
|
|
241
|
+
active[key] = mine
|
|
242
|
+
// A concurrent cancel replaced our handle with a newer one.
|
|
243
|
+
val newer = ConcurrentRangeDownloader.CancelHandle()
|
|
244
|
+
active[key] = newer
|
|
245
|
+
// Our finally-block remove(key, mine) must be a no-op because the value moved on.
|
|
246
|
+
assertFalse("value-checked remove must not evict the replacement", active.remove(key, mine))
|
|
247
|
+
assertEquals("the newer handle survives the stale deregister", newer, active[key])
|
|
248
|
+
// The owner of `newer` removes its own handle successfully.
|
|
249
|
+
assertTrue(active.remove(key, newer))
|
|
250
|
+
assertNull(active[key])
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------------
|
|
254
|
+
// MonotonicProgressGate (RangeDownloadLogic.progressPercent + the adapter's
|
|
255
|
+
// verbatim AtomicInteger `p > prev && compareAndSet` loop).
|
|
256
|
+
// ------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
// progressPercent floors, clamps to [0,100], and returns null for a non-positive
|
|
259
|
+
// total (the gate's input contract).
|
|
260
|
+
@Test
|
|
261
|
+
fun progressPercentFloorsClampsAndNullsNonPositiveTotal() {
|
|
262
|
+
assertEquals(0, RangeDownloadLogic.progressPercent(0, 100))
|
|
263
|
+
assertEquals(33, RangeDownloadLogic.progressPercent(1, 3)) // floor, never rounds up
|
|
264
|
+
assertEquals(100, RangeDownloadLogic.progressPercent(100, 100))
|
|
265
|
+
assertEquals(100, RangeDownloadLogic.progressPercent(150, 100)) // clamp overshoot
|
|
266
|
+
assertNull(RangeDownloadLogic.progressPercent(10, 0))
|
|
267
|
+
assertNull(RangeDownloadLogic.progressPercent(10, -1))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Progress only advances (CAS), never emits below the prior max even with
|
|
271
|
+
// out-of-order callbacks, and equal values do not re-emit. Single-threaded
|
|
272
|
+
// reproduction of the adapter's gate loop.
|
|
273
|
+
@Test
|
|
274
|
+
fun progressGateIsMonotonicAndDeduped() {
|
|
275
|
+
val last = AtomicInteger(-1)
|
|
276
|
+
val emitted = mutableListOf<Int>()
|
|
277
|
+
// total fixed at 100; transferred arrives out of order with repeats + a regress.
|
|
278
|
+
for (transferred in listOf(10L, 10L, 25L, 25L, 20L, 50L, 50L, 49L, 100L, 100L)) {
|
|
279
|
+
val p = RangeDownloadLogic.progressPercent(transferred, 100) ?: continue
|
|
280
|
+
val prev = last.get()
|
|
281
|
+
if (p > prev && last.compareAndSet(prev, p)) {
|
|
282
|
+
emitted.add(p)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// 20 (<25) and 49 (<50) never emit; repeats are de-duped; sequence is monotone.
|
|
286
|
+
assertEquals(listOf(10, 25, 50, 100), emitted)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Under genuine concurrency the CAS gate still never emits a value below the
|
|
290
|
+
// running max, and emits each percentage at most once. Many threads race the
|
|
291
|
+
// same gate driven by the REAL progressPercent + AtomicInteger CAS.
|
|
292
|
+
@Test
|
|
293
|
+
fun progressGateNeverGoesBackwardUnderConcurrency() {
|
|
294
|
+
val last = AtomicInteger(-1)
|
|
295
|
+
val emissions = java.util.concurrent.ConcurrentLinkedQueue<Int>()
|
|
296
|
+
val total = 1_000L
|
|
297
|
+
val threads = 16
|
|
298
|
+
val pool = Executors.newFixedThreadPool(threads)
|
|
299
|
+
val start = CountDownLatch(1)
|
|
300
|
+
val done = CountDownLatch(threads)
|
|
301
|
+
val regress = AtomicInteger(0)
|
|
302
|
+
|
|
303
|
+
repeat(threads) { t ->
|
|
304
|
+
pool.submit {
|
|
305
|
+
start.await()
|
|
306
|
+
// Each thread fires the full 0..1000 transferred sweep (heavy overlap), so
|
|
307
|
+
// the gate sees the same percentages racing from many threads at once.
|
|
308
|
+
for (transferred in 0L..total) {
|
|
309
|
+
val p = RangeDownloadLogic.progressPercent(transferred, total) ?: continue
|
|
310
|
+
while (true) {
|
|
311
|
+
val prev = last.get()
|
|
312
|
+
if (p <= prev) break // not an advance → no emit (de-dup / monotone)
|
|
313
|
+
if (last.compareAndSet(prev, p)) {
|
|
314
|
+
emissions.add(p)
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// A reader must NEVER observe the shared published max regress.
|
|
319
|
+
if (prevMaxRegressed(last)) regress.incrementAndGet()
|
|
320
|
+
}
|
|
321
|
+
done.countDown()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
start.countDown()
|
|
325
|
+
assertTrue("workers must finish", done.await(20, TimeUnit.SECONDS))
|
|
326
|
+
pool.shutdownNow()
|
|
327
|
+
|
|
328
|
+
// The published max ends at exactly 100 and never regressed.
|
|
329
|
+
assertEquals("gate must settle at 100%", 100, last.get())
|
|
330
|
+
assertEquals("published max must never regress", 0, regress.get())
|
|
331
|
+
// Each emitted percentage is unique (CAS de-dup) and the stream is sorted
|
|
332
|
+
// ascending (monotone): emissions are appended only on a successful advance.
|
|
333
|
+
val list = emissions.toList()
|
|
334
|
+
assertEquals("every emission must be unique (no re-emit of an equal value)", list.toSet().size, list.size)
|
|
335
|
+
assertEquals("emissions must be monotone non-decreasing", list.sorted(), list)
|
|
336
|
+
assertTrue("the terminal 100% must be emitted exactly once", list.count { it == 100 } == 1)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Tracks the running max in a thread-confined way to detect any regression of the
|
|
340
|
+
// shared published value. Returns true if the AtomicInteger's value ever dropped
|
|
341
|
+
// below a previously observed value on this thread.
|
|
342
|
+
private val perThreadSeenMax = ThreadLocal.withInitial { -1 }
|
|
343
|
+
private fun prevMaxRegressed(last: AtomicInteger): Boolean {
|
|
344
|
+
val now = last.get()
|
|
345
|
+
val seen = perThreadSeenMax.get()
|
|
346
|
+
if (now < seen) return true
|
|
347
|
+
if (now > seen) perThreadSeenMax.set(now)
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
4
|
+
import org.junit.After
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Before
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
import java.io.File
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Harness smoke test: confirms the [RangeFaultServer] dispatcher + MockWebServer
|
|
13
|
+
* + a real [ConcurrentRangeDownloader] over a plain OkHttpClient compile and run
|
|
14
|
+
* a clean full download. A green run here proves the JVM test harness works end
|
|
15
|
+
* to end (8 parallel range requests served concurrently, assembled `.partial`
|
|
16
|
+
* byte-identical to the source).
|
|
17
|
+
*/
|
|
18
|
+
class SmokeTest {
|
|
19
|
+
|
|
20
|
+
private lateinit var server: MockWebServer
|
|
21
|
+
private lateinit var tmpDir: File
|
|
22
|
+
|
|
23
|
+
// 3 MiB > minConcurrentBytes (2 MiB) so the core uses the concurrent path.
|
|
24
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
25
|
+
|
|
26
|
+
@Before
|
|
27
|
+
fun setUp() {
|
|
28
|
+
server = MockWebServer()
|
|
29
|
+
server.dispatcher = RangeFaultServer(content).dispatcher
|
|
30
|
+
server.start()
|
|
31
|
+
tmpDir = File.createTempFile("crd-smoke", "").let {
|
|
32
|
+
it.delete(); it.mkdirs(); it
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@After
|
|
37
|
+
fun tearDown() {
|
|
38
|
+
server.shutdown()
|
|
39
|
+
tmpDir.deleteRecursively()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun fullDownloadCompletesWithMatchingSha() {
|
|
44
|
+
val partial = File(tmpDir, "asset.bin.partial").absolutePath
|
|
45
|
+
val outcome = newDownloader().download(
|
|
46
|
+
url = server.url("/asset.bin").toString(),
|
|
47
|
+
partialFilePath = partial,
|
|
48
|
+
onProgress = { _, _ -> },
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
52
|
+
val partialFile = File(partial)
|
|
53
|
+
assertTrue("partial should exist", partialFile.exists())
|
|
54
|
+
assertEquals(content.size.toLong(), partialFile.length())
|
|
55
|
+
assertEquals(
|
|
56
|
+
"assembled .partial SHA must match source",
|
|
57
|
+
sha256(content),
|
|
58
|
+
sha256(partialFile),
|
|
59
|
+
)
|
|
60
|
+
// Every `.segN` is consumed by concatenate() on success.
|
|
61
|
+
for (i in 0 until 8) {
|
|
62
|
+
assertTrue(".seg$i should be gone", !File("$partial.seg$i").exists())
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CommonCrypto
|
|
3
|
+
|
|
4
|
+
// MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5)
|
|
5
|
+
//
|
|
6
|
+
// This file holds the DETERMINISTIC, dependency-light pieces of the range
|
|
7
|
+
// downloader: HTTP-status classification, range planning, Content-Range parsing,
|
|
8
|
+
// Retry-After / backoff math, and the SHA-256 integrity hash. They were extracted
|
|
9
|
+
// VERBATIM (bodies unchanged) from `ReactNativeRangeDownloader.swift` so they can
|
|
10
|
+
// be compiled and unit-tested WITHOUT the NitroModules / ReactNativeNativeLogger
|
|
11
|
+
// dependencies or the background `URLSession` (which is device-only).
|
|
12
|
+
//
|
|
13
|
+
// `RangeDownloader` keeps using them via `RangeDownloadLogic.<fn>`. The Nitro wire
|
|
14
|
+
// projections (`wireOutcome` / `wireKind`) stay in the main module file because
|
|
15
|
+
// they depend on the codegen enums; everything here is pure Swift + Foundation +
|
|
16
|
+
// CommonCrypto.
|
|
17
|
+
|
|
18
|
+
// MARK: - Typed failure model (OCDS §4)
|
|
19
|
+
//
|
|
20
|
+
// The IN-PROCESS core returns this Swift-native typed class to its in-process
|
|
21
|
+
// caller (BundleUpdate); the Nitro shim maps it onto the regenerated wire enum
|
|
22
|
+
// `RangeDownloadOutcome` (completed | fallbackTransient | fallbackPermanent) so
|
|
23
|
+
// the failure class crosses the JS boundary as an EXPLICIT value, never inferred
|
|
24
|
+
// from incidental on-disk side effects (which §4 forbids). The core enum is a
|
|
25
|
+
// SEPARATE type from the generated wire `RangeFallbackKind` (same case set) so
|
|
26
|
+
// the core can carry an extra `failureClass` projection without the module-scope
|
|
27
|
+
// name colliding with the codegen typealias; `wireOutcome` / `wireKind` (in the
|
|
28
|
+
// main module file) do the 1:1 translation onto the generated enums.
|
|
29
|
+
public enum RangeDownloadClass: Equatable {
|
|
30
|
+
case completed
|
|
31
|
+
/// Resumable interruption — keep `.segN`, the concurrent path may resume.
|
|
32
|
+
case fallbackTransient
|
|
33
|
+
/// Concurrency fundamentally unusable for this object — segments discarded.
|
|
34
|
+
case fallbackPermanent
|
|
35
|
+
|
|
36
|
+
var isFallback: Bool { self != .completed }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Typed sub-classification of a fallback (in-process mirror of the generated
|
|
40
|
+
/// wire `RangeFallbackKind`). Used by the caller/analytics to know WHY without
|
|
41
|
+
/// parsing the reason string, and by the core to drive keep-vs-discard. Named
|
|
42
|
+
/// distinctly from the codegen `RangeFallbackKind` typealias to avoid a
|
|
43
|
+
/// module-scope name clash; `wireKind` (in the main module file) translates onto
|
|
44
|
+
/// the wire enum.
|
|
45
|
+
public enum RangeFallbackClass: String {
|
|
46
|
+
case serverIgnoredRange
|
|
47
|
+
case rangeUnsupported
|
|
48
|
+
case authExpired
|
|
49
|
+
case notFound
|
|
50
|
+
case redirectRejected
|
|
51
|
+
case checksumMismatch
|
|
52
|
+
case multipartOrBadTotal
|
|
53
|
+
case transientNetwork
|
|
54
|
+
case throttled
|
|
55
|
+
case budgetExhausted
|
|
56
|
+
|
|
57
|
+
/// The §4 recovery class implied by this kind.
|
|
58
|
+
var failureClass: RangeDownloadClass {
|
|
59
|
+
switch self {
|
|
60
|
+
case .transientNetwork, .throttled, .budgetExhausted:
|
|
61
|
+
return .fallbackTransient
|
|
62
|
+
case .serverIgnoredRange, .rangeUnsupported, .authExpired, .notFound,
|
|
63
|
+
.redirectRejected, .checksumMismatch, .multipartOrBadTotal:
|
|
64
|
+
return .fallbackPermanent
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - Pure logic namespace
|
|
70
|
+
|
|
71
|
+
/// Dependency-free static logic for the range downloader. Bodies are a verbatim
|
|
72
|
+
/// move from `RangeDownloader`; callsites there now call `RangeDownloadLogic.<fn>`.
|
|
73
|
+
public enum RangeDownloadLogic {
|
|
74
|
+
|
|
75
|
+
// §5.4 backoff knobs (moved here because only `backoffDelay` consumes them).
|
|
76
|
+
static let retryBaseDelaySeconds: Double = 1.0
|
|
77
|
+
static let retryMaxDelaySeconds: Double = 30.0
|
|
78
|
+
|
|
79
|
+
// MARK: - Range planning / probing
|
|
80
|
+
|
|
81
|
+
static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] {
|
|
82
|
+
var out: [(Int64, Int64)] = []
|
|
83
|
+
let chunk = (total + Int64(segments) - 1) / Int64(segments)
|
|
84
|
+
var i = 0
|
|
85
|
+
while i < segments {
|
|
86
|
+
let start = Int64(i) * chunk
|
|
87
|
+
if start >= total { break }
|
|
88
|
+
let end = min(start + chunk - 1, total - 1)
|
|
89
|
+
out.append((start, end))
|
|
90
|
+
i += 1
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// MARK: - HTTP status classification (OCDS §4 table + catch-all)
|
|
96
|
+
|
|
97
|
+
/// Maps an HTTP status on a Range request to a §4 fallback kind. Used by the
|
|
98
|
+
/// download-finish delegate to decide keep-vs-discard. Status 206 is handled by
|
|
99
|
+
/// the caller (validated, not a fallback); 200 is `serverIgnoredRange`.
|
|
100
|
+
static func classifyStatus(_ status: Int) -> RangeFallbackClass {
|
|
101
|
+
switch status {
|
|
102
|
+
case 200:
|
|
103
|
+
return .serverIgnoredRange
|
|
104
|
+
case 416:
|
|
105
|
+
// §4: 416 to a resume request → Transient (re-evaluate size, keep segments).
|
|
106
|
+
return .transientNetwork
|
|
107
|
+
case 401, 403:
|
|
108
|
+
return .authExpired
|
|
109
|
+
case 404, 410:
|
|
110
|
+
return .notFound
|
|
111
|
+
case 408, 429:
|
|
112
|
+
return .throttled
|
|
113
|
+
case 501, 505:
|
|
114
|
+
// Explicit Permanent carve-outs from the 5xx → Transient default.
|
|
115
|
+
return .rangeUnsupported
|
|
116
|
+
case 500...599:
|
|
117
|
+
return .throttled
|
|
118
|
+
case 400...499:
|
|
119
|
+
// Default 4xx → Permanent (408/429 handled above).
|
|
120
|
+
return .rangeUnsupported
|
|
121
|
+
default:
|
|
122
|
+
// Anything else / unknown → Permanent per the §4 catch-all.
|
|
123
|
+
return .rangeUnsupported
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Parses a `Retry-After` header value (delta-seconds form only; HTTP-date form
|
|
128
|
+
/// is treated as absent). Returns nil when missing/unparseable.
|
|
129
|
+
static func parseRetryAfterSeconds(_ value: String?) -> Double? {
|
|
130
|
+
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty,
|
|
131
|
+
let seconds = Double(value), seconds >= 0 else { return nil }
|
|
132
|
+
return seconds
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static func parseContentRangeTotal(_ header: String) -> Int64? {
|
|
136
|
+
// "bytes 0-0/65226095"
|
|
137
|
+
guard let slash = header.lastIndex(of: "/") else { return nil }
|
|
138
|
+
let tail = header[header.index(after: slash)...]
|
|
139
|
+
return Int64(tail.trimmingCharacters(in: .whitespaces))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Parses the start/end of a "bytes <start>-<end>/<total>" Content-Range.
|
|
143
|
+
static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? {
|
|
144
|
+
// Drop the leading "bytes " and the trailing "/<total>".
|
|
145
|
+
let trimmed = header.trimmingCharacters(in: .whitespaces)
|
|
146
|
+
guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil }
|
|
147
|
+
var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...])
|
|
148
|
+
if let slash = rangePart.firstIndex(of: "/") {
|
|
149
|
+
rangePart = String(rangePart[..<slash])
|
|
150
|
+
}
|
|
151
|
+
let bounds = rangePart.split(separator: "-", maxSplits: 1).map { String($0) }
|
|
152
|
+
guard bounds.count == 2,
|
|
153
|
+
let start = Int64(bounds[0].trimmingCharacters(in: .whitespaces)),
|
|
154
|
+
let end = Int64(bounds[1].trimmingCharacters(in: .whitespaces)) else { return nil }
|
|
155
|
+
return (start, end)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// §5.4: exponential backoff base*2^(attempt-1), capped, with full jitter so N
|
|
159
|
+
/// segments don't retry in lockstep. A server `Retry-After` overrides it.
|
|
160
|
+
static func backoffDelay(attempt: Int, retryAfter: Double?) -> Double {
|
|
161
|
+
if let retryAfter = retryAfter { return min(retryAfter, retryMaxDelaySeconds * 2) }
|
|
162
|
+
let exp = retryBaseDelaySeconds * pow(2.0, Double(max(0, attempt - 1)))
|
|
163
|
+
let capped = min(exp, retryMaxDelaySeconds)
|
|
164
|
+
// Full jitter in [0, capped].
|
|
165
|
+
return Double.random(in: 0...capped)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// MARK: - Integrity (§5.5)
|
|
169
|
+
|
|
170
|
+
static func calculateSHA256(_ filePath: String) -> String? {
|
|
171
|
+
let fm = FileManager.default
|
|
172
|
+
guard fm.fileExists(atPath: filePath),
|
|
173
|
+
let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
|
|
174
|
+
defer { try? fileHandle.close() }
|
|
175
|
+
var context = CC_SHA256_CTX()
|
|
176
|
+
CC_SHA256_Init(&context)
|
|
177
|
+
while autoreleasepool(invoking: { () -> Bool in
|
|
178
|
+
let data = fileHandle.readData(ofLength: 8192)
|
|
179
|
+
if data.isEmpty { return false }
|
|
180
|
+
data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
|
|
181
|
+
return true
|
|
182
|
+
}) {}
|
|
183
|
+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
184
|
+
CC_SHA256_Final(&hash, &context)
|
|
185
|
+
return hash.map { String(format: "%02x", $0) }.joined()
|
|
186
|
+
}
|
|
187
|
+
}
|