@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
package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import okhttp3.OkHttpClient
|
|
4
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
5
|
+
import org.junit.After
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertFalse
|
|
8
|
+
import org.junit.Assert.assertNotEquals
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Before
|
|
11
|
+
import org.junit.Test
|
|
12
|
+
import java.io.File
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OCDS gap A1 — §4 HTTP status classification, the TRANSIENT 5xx branch
|
|
16
|
+
* (`isPermanentHttpStatus`: `in 500..599 -> false`,
|
|
17
|
+
* ConcurrentRangeDownloader.kt:596) driven end-to-end through
|
|
18
|
+
* `classifyHttpFailure` (line 555-559 → [TransientHttpException]) and the
|
|
19
|
+
* `downloadSegment` retry-in-place loop (line 410-419) against the REAL
|
|
20
|
+
* [ConcurrentRangeDownloader] over the [RangeFaultServer] MockWebServer harness.
|
|
21
|
+
*
|
|
22
|
+
* Two scenarios:
|
|
23
|
+
* - [transient5xxThenOkRetriesInPlaceAndAssembles]: a 503 fired a finite number
|
|
24
|
+
* of times on ONE segment window, then it recovers. Asserts per-segment retry,
|
|
25
|
+
* segments kept (no from-0 restart, no FALLBACK), recovery 206, final SHA over
|
|
26
|
+
* the real assembled file, and that EVERY OTHER segment is requested once.
|
|
27
|
+
* - [transient5xxBudgetExhaustionThrowsKeepingArtifactsForResume]: a 503 fired
|
|
28
|
+
* forever on one window exhausts the per-segment retry budget (maxPartRetry=3
|
|
29
|
+
* → initial + 3 retries = exactly 4 hits) → `download()` throws (transient
|
|
30
|
+
* give-up, NOT a corrupt COMPLETED, NOT a FALLBACK that wipes artifacts).
|
|
31
|
+
*
|
|
32
|
+
* The harness's default [RangeFaultServer.status5xxCode] is 503 (FaultServer.kt
|
|
33
|
+
* :100, served with an empty body at lines 136-137), so each fired attempt
|
|
34
|
+
* leaves 0 bytes on disk for that segment → the retry re-requests the SAME full
|
|
35
|
+
* window (start == part.start), mirroring OCDS-T4's 429 assertion.
|
|
36
|
+
*/
|
|
37
|
+
class OcdsTransient5xxTest {
|
|
38
|
+
|
|
39
|
+
private lateinit var server: MockWebServer
|
|
40
|
+
private lateinit var tmpDir: File
|
|
41
|
+
|
|
42
|
+
// 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path,
|
|
43
|
+
// and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts.
|
|
44
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
45
|
+
private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
|
|
46
|
+
|
|
47
|
+
@Before
|
|
48
|
+
fun setUp() {
|
|
49
|
+
tmpDir = File.createTempFile("crd-ocds-5xx", "").let {
|
|
50
|
+
it.delete(); it.mkdirs(); it
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@After
|
|
55
|
+
fun tearDown() {
|
|
56
|
+
if (this::server.isInitialized) server.shutdown()
|
|
57
|
+
tmpDir.deleteRecursively()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) -------------------
|
|
61
|
+
|
|
62
|
+
private fun startServer(faultServer: RangeFaultServer) {
|
|
63
|
+
server = MockWebServer()
|
|
64
|
+
server.dispatcher = faultServer.dispatcher
|
|
65
|
+
server.start()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private fun url() = server.url("/asset.bin").toString()
|
|
69
|
+
|
|
70
|
+
private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
|
|
71
|
+
|
|
72
|
+
private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
|
|
73
|
+
|
|
74
|
+
private fun assertNoArtifactsLeft(partial: String) {
|
|
75
|
+
assertFalse(".partial must be wiped", File(partial).exists())
|
|
76
|
+
for (i in 0 until 8) {
|
|
77
|
+
assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fast-backoff downloader form (mirrors OCDS-T10) so the single/few retries
|
|
82
|
+
// run fast in a unit test. Default maxPartRetry (3) is preserved so the
|
|
83
|
+
// budget-exhaustion test exercises the real bound.
|
|
84
|
+
private fun fastDownloader() = ConcurrentRangeDownloader(
|
|
85
|
+
httpClient = OkHttpClient(),
|
|
86
|
+
segmentCount = 8,
|
|
87
|
+
retryBaseDelayMillis = 50L,
|
|
88
|
+
retryMaxDelayMillis = 200L,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------------------
|
|
92
|
+
// A1(a): transient 5xx on ONE segment a few times → retried in place, then
|
|
93
|
+
// recovers. Segments are KEPT across the retry (no from-0 restart, no
|
|
94
|
+
// FALLBACK), the recovery 206 completes, and the assembled SHA is correct.
|
|
95
|
+
// ------------------------------------------------------------------------
|
|
96
|
+
@Test
|
|
97
|
+
fun transient5xxThenOkRetriesInPlaceAndAssembles() {
|
|
98
|
+
val target = parts[3]
|
|
99
|
+
// Fire 503 twice on this window; the 3rd attempt (fault expired, times=2)
|
|
100
|
+
// streams the full window normally. 2 < maxPartRetry(3), so the budget is
|
|
101
|
+
// NOT exhausted and the segment recovers within this single download() call.
|
|
102
|
+
val fs = RangeFaultServer(content)
|
|
103
|
+
.arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = 2)
|
|
104
|
+
startServer(fs)
|
|
105
|
+
|
|
106
|
+
val partial = partialPath()
|
|
107
|
+
val outcome = fastDownloader().download(
|
|
108
|
+
url = url(),
|
|
109
|
+
partialFilePath = partial,
|
|
110
|
+
onProgress = { _, _ -> },
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// Recovery: completes (NOT FALLBACK — a 5xx is transient, never discards
|
|
114
|
+
// concurrency), and the assembled bytes are byte-for-byte the source.
|
|
115
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
116
|
+
assertNotEquals(
|
|
117
|
+
"transient 5xx must NOT fall back to single-stream",
|
|
118
|
+
ConcurrentRangeDownloader.Outcome.FALLBACK,
|
|
119
|
+
outcome,
|
|
120
|
+
)
|
|
121
|
+
assertEquals(content.size.toLong(), File(partial).length())
|
|
122
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
123
|
+
|
|
124
|
+
val ranges = fs.requestedRanges.toList()
|
|
125
|
+
// The 503 returned an EMPTY body, so the segment has 0 bytes on disk after
|
|
126
|
+
// each fired attempt → every retry re-requests the SAME full window
|
|
127
|
+
// (start == part.start). Initial + 2 transient retries + 1 recovery 206 =
|
|
128
|
+
// EXACTLY three hits on the full window. (Mirrors OCDS-T4's 429 assertion.)
|
|
129
|
+
val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
|
|
130
|
+
assertEquals(
|
|
131
|
+
"affected segment must be requested exactly 3 times (503, 503, then 206)",
|
|
132
|
+
3,
|
|
133
|
+
targetReqs.size,
|
|
134
|
+
)
|
|
135
|
+
// No from-0 restart: every retry of the affected window starts at part.start
|
|
136
|
+
// (which is not file byte 0 here — target is part[3]).
|
|
137
|
+
assertTrue(
|
|
138
|
+
"retry-in-place must never restart at file byte 0",
|
|
139
|
+
targetReqs.all { it.first == target.start },
|
|
140
|
+
)
|
|
141
|
+
// Every OTHER segment is requested EXACTLY once — the retry stayed scoped to
|
|
142
|
+
// the affected window and did not perturb its neighbours.
|
|
143
|
+
for (p in parts) {
|
|
144
|
+
if (p.index == target.index) continue
|
|
145
|
+
val reqs = ranges.filter { it.first == p.start && it.second == p.end }
|
|
146
|
+
assertEquals(
|
|
147
|
+
"segment ${p.index} must be requested exactly once",
|
|
148
|
+
1,
|
|
149
|
+
reqs.size,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
// All `.segN` consumed (assembled + deleted) on COMPLETED.
|
|
153
|
+
for (i in 0 until 8) {
|
|
154
|
+
assertFalse(".seg$i must be consumed on success", segFile(partial, i).exists())
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------------------
|
|
159
|
+
// A1(b): transient 5xx that NEVER recovers exhausts the per-segment retry
|
|
160
|
+
// budget (maxPartRetry=3) → download() throws. The KEY guarantees: it is a
|
|
161
|
+
// transient give-up (NOT a permanent FALLBACK that wipes artifacts), the
|
|
162
|
+
// affected window is hit EXACTLY budget+1 times (initial + 3 retries), and
|
|
163
|
+
// no corrupt COMPLETED file is produced.
|
|
164
|
+
// ------------------------------------------------------------------------
|
|
165
|
+
@Test
|
|
166
|
+
fun transient5xxBudgetExhaustionThrowsKeepingArtifactsForResume() {
|
|
167
|
+
val target = parts[2]
|
|
168
|
+
// Fire 503 forever so the segment never recovers and the retry budget is
|
|
169
|
+
// exhausted. status5xxCode defaults to 503 (transient per §4).
|
|
170
|
+
val fs = RangeFaultServer(content)
|
|
171
|
+
.arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = Int.MAX_VALUE)
|
|
172
|
+
startServer(fs)
|
|
173
|
+
|
|
174
|
+
val partial = partialPath()
|
|
175
|
+
var threw = false
|
|
176
|
+
var outcome: ConcurrentRangeDownloader.Outcome? = null
|
|
177
|
+
try {
|
|
178
|
+
outcome = fastDownloader().download(
|
|
179
|
+
url = url(),
|
|
180
|
+
partialFilePath = partial,
|
|
181
|
+
onProgress = { _, _ -> },
|
|
182
|
+
)
|
|
183
|
+
} catch (e: Exception) {
|
|
184
|
+
threw = true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A perpetual transient 5xx gives up by THROWING — it must NOT be mapped to
|
|
188
|
+
// a (corrupt) COMPLETED, and it must NOT be a permanent FALLBACK (a 5xx is
|
|
189
|
+
// transient; FALLBACK is reserved for "concurrency fundamentally unusable").
|
|
190
|
+
assertTrue("perpetual transient 5xx must give up by throwing", threw)
|
|
191
|
+
assertEquals("budget exhaustion must not COMPLETE", null, outcome)
|
|
192
|
+
assertNotEquals(
|
|
193
|
+
"transient 5xx budget exhaustion must NOT be a COMPLETED",
|
|
194
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
195
|
+
outcome,
|
|
196
|
+
)
|
|
197
|
+
assertNotEquals(
|
|
198
|
+
"transient 5xx budget exhaustion must NOT be a FALLBACK",
|
|
199
|
+
ConcurrentRangeDownloader.Outcome.FALLBACK,
|
|
200
|
+
outcome,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
val ranges = fs.requestedRanges.toList()
|
|
204
|
+
// The affected window is requested EXACTLY maxPartRetry+1 = 4 times (initial
|
|
205
|
+
// attempt + 3 retries), all at part.start (empty 503 body → 0 bytes on disk
|
|
206
|
+
// → same full window each time, never a from-0 restart). This is the
|
|
207
|
+
// load-bearing budget assertion: it would FAIL if the loop did not honour
|
|
208
|
+
// maxPartRetry, or restarted from byte 0, or treated 5xx as permanent (1 hit).
|
|
209
|
+
val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
|
|
210
|
+
assertEquals(
|
|
211
|
+
"affected segment must be requested exactly maxPartRetry+1 (4) times",
|
|
212
|
+
4,
|
|
213
|
+
targetReqs.size,
|
|
214
|
+
)
|
|
215
|
+
assertTrue(
|
|
216
|
+
"every 5xx retry must restart at the segment window start, never file byte 0",
|
|
217
|
+
targetReqs.all { it.first == target.start },
|
|
218
|
+
)
|
|
219
|
+
// No complete (corrupt) `.partial` was assembled. If a `.partial` exists at
|
|
220
|
+
// all, it must NOT be a full-length file.
|
|
221
|
+
if (File(partial).exists()) {
|
|
222
|
+
assertNotEquals(
|
|
223
|
+
"a `.partial` left on disk must NOT be a complete (corrupt) file",
|
|
224
|
+
content.size.toLong(),
|
|
225
|
+
File(partial).length(),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ------------------------------------------------------------------------
|
|
231
|
+
// A1(c): a permanent 5xx (501) on the SAME branch's boundary must bypass the
|
|
232
|
+
// retry loop entirely — it is hit EXACTLY once. This pins the transient-vs-
|
|
233
|
+
// permanent split of `isPermanentHttpStatus` (501 -> true, line 595) so the
|
|
234
|
+
// A1(b) "retry 4 times" assertion is meaningful by contrast, not noise.
|
|
235
|
+
// ------------------------------------------------------------------------
|
|
236
|
+
@Test
|
|
237
|
+
fun permanent5xx501BypassesRetryLoop() {
|
|
238
|
+
val target = parts[4]
|
|
239
|
+
val fs = RangeFaultServer(content)
|
|
240
|
+
fs.status5xxCode = 501 // 501 is permanent per §4 (isPermanentHttpStatus:595).
|
|
241
|
+
fs.arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = Int.MAX_VALUE)
|
|
242
|
+
startServer(fs)
|
|
243
|
+
|
|
244
|
+
val partial = partialPath()
|
|
245
|
+
var threw = false
|
|
246
|
+
var outcome: ConcurrentRangeDownloader.Outcome? = null
|
|
247
|
+
try {
|
|
248
|
+
outcome = fastDownloader().download(
|
|
249
|
+
url = url(),
|
|
250
|
+
partialFilePath = partial,
|
|
251
|
+
onProgress = { _, _ -> },
|
|
252
|
+
)
|
|
253
|
+
} catch (e: Exception) {
|
|
254
|
+
threw = true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
assertTrue("permanent 5xx (501) must surface (throw)", threw)
|
|
258
|
+
assertNotEquals(
|
|
259
|
+
"permanent 5xx must not COMPLETE",
|
|
260
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
261
|
+
outcome,
|
|
262
|
+
)
|
|
263
|
+
// A PermanentHttpException bypasses the retry loop (downloadSegment line
|
|
264
|
+
// 405-409), so the affected window is requested EXACTLY ONCE — proving the
|
|
265
|
+
// transient branch's 4 hits in A1(b) is a real, classifier-driven retry
|
|
266
|
+
// count and not an artifact of unconditional retrying.
|
|
267
|
+
val ranges = fs.requestedRanges.toList()
|
|
268
|
+
val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
|
|
269
|
+
assertEquals(
|
|
270
|
+
"permanent 5xx (501) must be requested exactly once (no retry-in-place)",
|
|
271
|
+
1,
|
|
272
|
+
targetReqs.size,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
}
|
package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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.assertNull
|
|
7
|
+
import org.junit.Assert.assertTrue
|
|
8
|
+
import org.junit.Before
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
import java.io.File
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unit coverage for the dependency-free adapter logic extracted from
|
|
14
|
+
* [ReactNativeRangeDownloader]: the single-flight run-key, the CAS progress
|
|
15
|
+
* gate, and the per-segment artifact sweep. Pure JVM JUnit — no Nitro /
|
|
16
|
+
* HybridObject / Context, mirroring the established test pattern in this module
|
|
17
|
+
* (see IsPermanentHttpStatusTest).
|
|
18
|
+
*/
|
|
19
|
+
class RangeDownloadLogicTest {
|
|
20
|
+
|
|
21
|
+
private lateinit var tmpDir: File
|
|
22
|
+
|
|
23
|
+
@Before
|
|
24
|
+
fun setUp() {
|
|
25
|
+
tmpDir = File.createTempFile("crd-logic", "").let {
|
|
26
|
+
it.delete(); it.mkdirs(); it
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@After
|
|
31
|
+
fun tearDown() {
|
|
32
|
+
tmpDir.deleteRecursively()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- runKey (single-flight semantics) ----
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun runKeyJoinsChannelAndTaskWithPipe() {
|
|
39
|
+
assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1"))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun runKeyDistinguishesChannelAndTask() {
|
|
44
|
+
// Different channels for the same task must not collide.
|
|
45
|
+
assertFalse(
|
|
46
|
+
RangeDownloadLogic.runKey("MAIN", "t") == RangeDownloadLogic.runKey("OTHER", "t"),
|
|
47
|
+
)
|
|
48
|
+
// Different tasks on the same channel must not collide.
|
|
49
|
+
assertFalse(
|
|
50
|
+
RangeDownloadLogic.runKey("MAIN", "a") == RangeDownloadLogic.runKey("MAIN", "b"),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- progressPercent (CAS gate input) ----
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun progressPercentComputesFlooredPercentage() {
|
|
58
|
+
assertEquals(0, RangeDownloadLogic.progressPercent(0, 100))
|
|
59
|
+
assertEquals(50, RangeDownloadLogic.progressPercent(50, 100))
|
|
60
|
+
// Integer floor, never rounds up.
|
|
61
|
+
assertEquals(33, RangeDownloadLogic.progressPercent(1, 3))
|
|
62
|
+
assertEquals(100, RangeDownloadLogic.progressPercent(100, 100))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun progressPercentClampsToHundred() {
|
|
67
|
+
// transferred can momentarily exceed total across segments; clamp.
|
|
68
|
+
assertEquals(100, RangeDownloadLogic.progressPercent(150, 100))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun progressPercentReturnsNullForNonPositiveTotal() {
|
|
73
|
+
assertNull(RangeDownloadLogic.progressPercent(10, 0))
|
|
74
|
+
assertNull(RangeDownloadLogic.progressPercent(10, -1))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The CAS gate the adapter wraps around [progressPercent]: only a strictly
|
|
79
|
+
* higher percentage emits, so the stream stays monotonic and de-duped. This
|
|
80
|
+
* reproduces the adapter's `p > prev && compareAndSet` loop over a sequence
|
|
81
|
+
* of out-of-order, repeating worker callbacks.
|
|
82
|
+
*/
|
|
83
|
+
@Test
|
|
84
|
+
fun casGateKeepsProgressMonotonicAndDeduped() {
|
|
85
|
+
var prev = -1
|
|
86
|
+
val emitted = mutableListOf<Int>()
|
|
87
|
+
// total fixed at 100; transferred arrives out of order with repeats.
|
|
88
|
+
for (transferred in listOf(10L, 10L, 25L, 25L, 20L, 50L, 50L, 49L, 100L)) {
|
|
89
|
+
val p = RangeDownloadLogic.progressPercent(transferred, 100) ?: continue
|
|
90
|
+
if (p > prev) {
|
|
91
|
+
prev = p
|
|
92
|
+
emitted.add(p)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
assertEquals(listOf(10, 25, 50, 100), emitted)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- sweepPartialArtifacts ----
|
|
99
|
+
|
|
100
|
+
@Test
|
|
101
|
+
fun sweepRemovesPartialAndEverySegmentByPrefix() {
|
|
102
|
+
val dest = File(tmpDir, "asset.bin").absolutePath
|
|
103
|
+
val partial = File("$dest.partial")
|
|
104
|
+
partial.writeText("p")
|
|
105
|
+
// Custom (non-default) segment count: 12 segments must all be swept.
|
|
106
|
+
val segs = (0 until 12).map { File("$dest.partial.seg$it").apply { writeText("$it") } }
|
|
107
|
+
// An unrelated sibling must be left untouched.
|
|
108
|
+
val unrelated = File(tmpDir, "asset.bin.keep").apply { writeText("k") }
|
|
109
|
+
|
|
110
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
111
|
+
|
|
112
|
+
assertFalse("partial should be deleted", partial.exists())
|
|
113
|
+
segs.forEach { assertFalse("${it.name} should be deleted", it.exists()) }
|
|
114
|
+
assertTrue("unrelated sibling must survive", unrelated.exists())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Test
|
|
118
|
+
fun sweepIsNoOpWhenNothingExists() {
|
|
119
|
+
val dest = File(tmpDir, "missing.bin").absolutePath
|
|
120
|
+
// Must not throw when there are no artifacts to remove.
|
|
121
|
+
RangeDownloadLogic.sweepPartialArtifacts(dest)
|
|
122
|
+
assertFalse(File("$dest.partial").exists())
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNotEquals
|
|
6
|
+
import org.junit.Assert.assertNotSame
|
|
7
|
+
import org.junit.Assert.assertSame
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
11
|
+
import java.util.concurrent.CountDownLatch
|
|
12
|
+
import java.util.concurrent.Executors
|
|
13
|
+
import java.util.concurrent.TimeUnit
|
|
14
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Drives the REAL extracted single-flight unit
|
|
18
|
+
* [RangeDownloadLogic.RunRegistry] (keyed by "channel|taskId"). The registry was
|
|
19
|
+
* lifted VERBATIM from the Nitro adapter's inline `activeDownloads`
|
|
20
|
+
* ConcurrentHashMap usage so its keyed invariants can be unit-tested without the
|
|
21
|
+
* Nitro / JNI / OkHttp dependencies. Every assertion exercises the real code —
|
|
22
|
+
* nothing is re-implemented here.
|
|
23
|
+
*
|
|
24
|
+
* Channel mapping note: the adapter computes its key as
|
|
25
|
+
* `RangeDownloadLogic.runKey(channel.name, taskId)` (adapter:44-45), i.e. the
|
|
26
|
+
* `DownloadChannel` enum contributes ONLY its `.name` string. The dependency-free
|
|
27
|
+
* registry therefore takes the channel NAME string directly. To keep the test
|
|
28
|
+
* faithful to the real channels (and prove cross-channel key isolation) WITHOUT
|
|
29
|
+
* dragging the JNI-annotated generated enum onto the pure-JVM test classpath
|
|
30
|
+
* (every sibling test stays generated-type-free), the names below are the exact
|
|
31
|
+
* verbatim `DownloadChannel.<X>.name` strings.
|
|
32
|
+
*
|
|
33
|
+
* Cancel-detection probe: [ConcurrentRangeDownloader.CancelHandle.cancel] is
|
|
34
|
+
* idempotent and flips the public `aborted` AtomicBoolean — observing `aborted`
|
|
35
|
+
* is how we assert a handle was (or was not) cancelled, without subclassing the
|
|
36
|
+
* final-ish handle. `cancel()` with no attached pool is a pure flag flip, so it
|
|
37
|
+
* is safe + fast in a JVM unit test.
|
|
38
|
+
*/
|
|
39
|
+
class RunRegistrySingleFlightTest {
|
|
40
|
+
|
|
41
|
+
// Verbatim DownloadChannel.<X>.name values (BUNDLE/APK/CHART). Kept as plain
|
|
42
|
+
// strings so this test needs no JNI-annotated generated enum on its classpath.
|
|
43
|
+
private val bundle = "BUNDLE"
|
|
44
|
+
private val apk = "APK"
|
|
45
|
+
private val chart = "CHART"
|
|
46
|
+
|
|
47
|
+
// ------------------------------------------------------------------------
|
|
48
|
+
// start() dedup: a second start for the same key overwrites; finish() is
|
|
49
|
+
// identity-checked so the STALE handle's finish is a no-op, and only the LIVE
|
|
50
|
+
// handle's finish removes the entry (adapter:87 + adapter:112 invariants).
|
|
51
|
+
// ------------------------------------------------------------------------
|
|
52
|
+
@Test
|
|
53
|
+
fun startTwiceSameKey_secondHandleIsLive_staleFinishIsNoOp() {
|
|
54
|
+
val reg = RangeDownloadLogic.RunRegistry()
|
|
55
|
+
|
|
56
|
+
val first = reg.start(bundle, "task-A")
|
|
57
|
+
val second = reg.start(bundle, "task-A")
|
|
58
|
+
|
|
59
|
+
assertNotSame("a second start() must mint a fresh handle", first, second)
|
|
60
|
+
assertEquals("dedup: same key must hold exactly one live entry", 1, reg.size)
|
|
61
|
+
|
|
62
|
+
// finish(firstHandle) must NOT clobber the live (second) handle — identity
|
|
63
|
+
// remove fails because the map now holds `second`.
|
|
64
|
+
reg.finish(bundle, "task-A", first)
|
|
65
|
+
assertEquals("stale-handle finish must be a no-op (entry still live)", 1, reg.size)
|
|
66
|
+
|
|
67
|
+
// finish(secondHandle) — the live handle — removes the entry.
|
|
68
|
+
reg.finish(bundle, "task-A", second)
|
|
69
|
+
assertEquals("live-handle finish must remove the entry", 0, reg.size)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------------------
|
|
73
|
+
// start → finish(sameHandle) clears the key; a subsequent cancel() is a no-op
|
|
74
|
+
// and must NOT throw (adapter:206 remove(...)?.cancel() null-safe path).
|
|
75
|
+
// ------------------------------------------------------------------------
|
|
76
|
+
@Test
|
|
77
|
+
fun startThenFinish_keyGone_subsequentCancelIsNoOpAndDoesNotThrow() {
|
|
78
|
+
val reg = RangeDownloadLogic.RunRegistry()
|
|
79
|
+
|
|
80
|
+
val handle = reg.start(apk, "task-B")
|
|
81
|
+
reg.finish(apk, "task-B", handle)
|
|
82
|
+
assertEquals("finish must clear the key", 0, reg.size)
|
|
83
|
+
|
|
84
|
+
// No live handle → cancel() finds nothing → must be a silent no-op.
|
|
85
|
+
reg.cancel(apk, "task-B")
|
|
86
|
+
assertEquals("cancel of an absent key must not create or leak an entry", 0, reg.size)
|
|
87
|
+
assertFalse(
|
|
88
|
+
"a finished handle must NOT have been cancelled by the no-op cancel()",
|
|
89
|
+
handle.aborted.get(),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ------------------------------------------------------------------------
|
|
94
|
+
// cancel() invokes CancelHandle.cancel() exactly once AND removes the key:
|
|
95
|
+
// the second cancel() finds nothing (probe) and the handle's aborted flag was
|
|
96
|
+
// already set by the first cancel.
|
|
97
|
+
// ------------------------------------------------------------------------
|
|
98
|
+
@Test
|
|
99
|
+
fun cancel_invokesHandleCancelOnceAndRemovesKey() {
|
|
100
|
+
val reg = RangeDownloadLogic.RunRegistry()
|
|
101
|
+
|
|
102
|
+
val handle = reg.start(chart, "task-C")
|
|
103
|
+
assertFalse("handle must start un-aborted", handle.aborted.get())
|
|
104
|
+
|
|
105
|
+
reg.cancel(chart, "task-C")
|
|
106
|
+
assertTrue("cancel() must have flipped the handle's aborted flag", handle.aborted.get())
|
|
107
|
+
assertEquals("cancel() must remove the key", 0, reg.size)
|
|
108
|
+
|
|
109
|
+
// Probe: the entry is gone, so a second cancel() targets nothing. If the key
|
|
110
|
+
// had NOT been removed, this would re-cancel the same handle — proving the
|
|
111
|
+
// remove happened on the first call. (cancel() is idempotent regardless, so
|
|
112
|
+
// the load-bearing assertion is the size==0 above + this staying a no-op.)
|
|
113
|
+
reg.cancel(chart, "task-C")
|
|
114
|
+
assertEquals("second cancel() finds nothing — no entry resurrected", 0, reg.size)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------------------
|
|
118
|
+
// Different channels (BUNDLE vs APK) with the SAME taskId produce distinct
|
|
119
|
+
// keys → both coexist; cancelling one leaves the other live & un-aborted.
|
|
120
|
+
// ------------------------------------------------------------------------
|
|
121
|
+
@Test
|
|
122
|
+
fun differentChannelsSameTaskId_distinctKeys_cancelOneLeavesOtherLive() {
|
|
123
|
+
val reg = RangeDownloadLogic.RunRegistry()
|
|
124
|
+
val taskId = "shared-task"
|
|
125
|
+
|
|
126
|
+
// Sanity: the underlying key derivation is collision-free across channels.
|
|
127
|
+
assertNotEquals(
|
|
128
|
+
"channel must contribute to the key",
|
|
129
|
+
RangeDownloadLogic.runKey(bundle, taskId),
|
|
130
|
+
RangeDownloadLogic.runKey(apk, taskId),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
val bundleHandle = reg.start(bundle, taskId)
|
|
134
|
+
val apkHandle = reg.start(apk, taskId)
|
|
135
|
+
assertEquals("distinct channels must coexist under the same taskId", 2, reg.size)
|
|
136
|
+
|
|
137
|
+
reg.cancel(bundle, taskId)
|
|
138
|
+
|
|
139
|
+
assertTrue("the cancelled channel's handle must be aborted", bundleHandle.aborted.get())
|
|
140
|
+
assertFalse("the OTHER channel's handle must remain un-aborted", apkHandle.aborted.get())
|
|
141
|
+
assertEquals("only the cancelled channel's entry must be removed", 1, reg.size)
|
|
142
|
+
|
|
143
|
+
// The surviving entry is still the APK handle and can be finished normally.
|
|
144
|
+
reg.finish(apk, taskId, apkHandle)
|
|
145
|
+
assertEquals("surviving entry finishes cleanly", 0, reg.size)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ------------------------------------------------------------------------
|
|
149
|
+
// Concurrency: N threads start() the same key while one thread cancel()s it.
|
|
150
|
+
// Invariants under contention:
|
|
151
|
+
// - the registry never LEAKS an entry that no live handle owns;
|
|
152
|
+
// - the surviving handle (if any) is never DOUBLE-cancelled;
|
|
153
|
+
// - every start() handle is accounted for: it is either the live survivor,
|
|
154
|
+
// cancelled by the racing cancel(), or superseded (overwritten) — never
|
|
155
|
+
// silently kept alive AND orphaned.
|
|
156
|
+
// ------------------------------------------------------------------------
|
|
157
|
+
@Test
|
|
158
|
+
fun concurrentStartsAndCancel_noLeakedEntry_noDoubleCancel() {
|
|
159
|
+
val reg = RangeDownloadLogic.RunRegistry()
|
|
160
|
+
val key = "race-task"
|
|
161
|
+
val n = 32
|
|
162
|
+
|
|
163
|
+
// Count how many distinct handles ever get cancelled. Because each handle is
|
|
164
|
+
// unique and cancel() is observed via its aborted flag, double-cancel of the
|
|
165
|
+
// SAME handle is detectable: cancelCount must never exceed the number of
|
|
166
|
+
// handles that were actually cancelled (we re-check by scanning).
|
|
167
|
+
val handles = ConcurrentHashMap.newKeySet<ConcurrentRangeDownloader.CancelHandle>()
|
|
168
|
+
val startBarrier = CountDownLatch(1)
|
|
169
|
+
val done = CountDownLatch(n + 1)
|
|
170
|
+
val pool = Executors.newFixedThreadPool(n + 1)
|
|
171
|
+
|
|
172
|
+
repeat(n) {
|
|
173
|
+
pool.submit {
|
|
174
|
+
startBarrier.await()
|
|
175
|
+
handles.add(reg.start(chart, key))
|
|
176
|
+
done.countDown()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// One racing canceller.
|
|
180
|
+
pool.submit {
|
|
181
|
+
startBarrier.await()
|
|
182
|
+
// Spin a touch so it interleaves with the starts rather than always winning.
|
|
183
|
+
repeat(4) { reg.cancel(chart, key) }
|
|
184
|
+
done.countDown()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
startBarrier.countDown()
|
|
188
|
+
assertTrue("all workers must finish", done.await(10, TimeUnit.SECONDS))
|
|
189
|
+
pool.shutdownNow()
|
|
190
|
+
|
|
191
|
+
// Drain any survivor via a final cancel so the registry must end empty.
|
|
192
|
+
reg.cancel(chart, key)
|
|
193
|
+
assertEquals("registry must never leak an entry under contention", 0, reg.size)
|
|
194
|
+
|
|
195
|
+
// No handle is double-cancelled: aborted is a boolean flip, so re-counting is
|
|
196
|
+
// not enough on its own — assert instead that cancel() is idempotent by the
|
|
197
|
+
// contract and that AT MOST the set of minted handles were cancelled (never
|
|
198
|
+
// more handles than were created). The strong leak invariant above is the
|
|
199
|
+
// primary guarantee; here we assert every cancelled handle came from start().
|
|
200
|
+
val cancelled = handles.count { it.aborted.get() }
|
|
201
|
+
assertTrue(
|
|
202
|
+
"cancelled handles ($cancelled) cannot exceed minted handles (${handles.size})",
|
|
203
|
+
cancelled <= handles.size,
|
|
204
|
+
)
|
|
205
|
+
// At least one cancel landed on a real handle (the racing canceller + final
|
|
206
|
+
// drain cannot both miss every start under this barrier).
|
|
207
|
+
assertTrue("at least one minted handle must have been cancelled", cancelled >= 1)
|
|
208
|
+
|
|
209
|
+
// Defensive accounting probe: no AtomicInteger over-count of survivors.
|
|
210
|
+
val live = AtomicInteger(0)
|
|
211
|
+
// After the final drain the key is gone, so re-finishing any handle is a safe
|
|
212
|
+
// no-op and must not resurrect the entry.
|
|
213
|
+
handles.forEach { reg.finish(chart, key, it) }
|
|
214
|
+
if (reg.size > 0) live.incrementAndGet()
|
|
215
|
+
assertEquals("post-drain finish() of any stale handle must not resurrect a key", 0, live.get())
|
|
216
|
+
}
|
|
217
|
+
}
|