@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.
Files changed (44) hide show
  1. package/README.md +15 -0
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
  10. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
  11. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
  12. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
  13. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
  14. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
  15. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
  16. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
  17. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
  18. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
  19. package/ios/RangeDownloadLogic.swift +187 -0
  20. package/ios/ReactNativeRangeDownloader.swift +669 -133
  21. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
  22. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
  24. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
  25. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
  26. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
  27. package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
  32. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
  33. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
  34. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
  35. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
  36. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
  37. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
  38. package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
  39. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
  40. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
  41. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
  42. package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
  43. package/package.json +1 -1
  44. package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
@@ -0,0 +1,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
+ }
@@ -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
+ }