@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,554 @@
|
|
|
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
|
+
import java.util.concurrent.CountDownLatch
|
|
14
|
+
import java.util.concurrent.Executors
|
|
15
|
+
import java.util.concurrent.TimeUnit
|
|
16
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OCDS §6 conformance scenarios driving the REAL
|
|
20
|
+
* [ConcurrentRangeDownloader.download] against the [RangeFaultServer]
|
|
21
|
+
* MockWebServer harness. Each test is named with its OCDS-T number and asserts
|
|
22
|
+
* on the recorded ranges, the on-disk artifacts (`.partial` / `.segN`), and the
|
|
23
|
+
* assembled bytes (SHA-256 vs source).
|
|
24
|
+
*
|
|
25
|
+
* Out of scope of this CORE suite (asserted/explained, never forced):
|
|
26
|
+
* - OCDS-T6 (whole-file SHA mismatch → single-stream retry once → terminal):
|
|
27
|
+
* the core does NOT compute a whole-file checksum. On COMPLETED it leaves the
|
|
28
|
+
* assembled bytes at `partialFilePath`; the CALLER renames + SHA-256/GPG
|
|
29
|
+
* verifies and drives the retry-once-then-terminal policy. Not a core test.
|
|
30
|
+
* - OCDS-T11 (two concurrent download() for the same dest → serialized / one
|
|
31
|
+
* fails fast, artifacts never co-written): single-flight is a CALLER-level
|
|
32
|
+
* per-taskId registry concern (the adapter), not in this dependency-free core.
|
|
33
|
+
* - OCDS-T9 (cross-relaunch attempt budget / bounded terminal): the budget that
|
|
34
|
+
* spans process relaunches lives in the shared-JS caller and is tested there.
|
|
35
|
+
* The core's in-process bound (maxPartRetry) is exercised indirectly by T4.
|
|
36
|
+
*
|
|
37
|
+
* Threading note (from FaultServer.kt): the dispatcher runs concurrently on
|
|
38
|
+
* MockWebServer reader threads, so range assertions read the synchronized
|
|
39
|
+
* `requestedRanges` only after the download() call has fully returned.
|
|
40
|
+
*/
|
|
41
|
+
class ConcurrentRangeDownloaderOcdsTest {
|
|
42
|
+
|
|
43
|
+
private lateinit var server: MockWebServer
|
|
44
|
+
private lateinit var tmpDir: File
|
|
45
|
+
|
|
46
|
+
// 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path,
|
|
47
|
+
// and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts.
|
|
48
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
49
|
+
private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
|
|
50
|
+
|
|
51
|
+
@Before
|
|
52
|
+
fun setUp() {
|
|
53
|
+
tmpDir = File.createTempFile("crd-ocds", "").let {
|
|
54
|
+
it.delete(); it.mkdirs(); it
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@After
|
|
59
|
+
fun tearDown() {
|
|
60
|
+
if (this::server.isInitialized) server.shutdown()
|
|
61
|
+
tmpDir.deleteRecursively()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- helpers -------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
private fun startServer(faultServer: RangeFaultServer) {
|
|
67
|
+
server = MockWebServer()
|
|
68
|
+
server.dispatcher = faultServer.dispatcher
|
|
69
|
+
server.start()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun url() = server.url("/asset.bin").toString()
|
|
73
|
+
|
|
74
|
+
private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
|
|
75
|
+
|
|
76
|
+
private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
|
|
77
|
+
|
|
78
|
+
private fun assertNoArtifactsLeft(partial: String) {
|
|
79
|
+
assertFalse(".partial must be wiped", File(partial).exists())
|
|
80
|
+
for (i in 0 until 8) {
|
|
81
|
+
assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ------------------------------------------------------------------------
|
|
86
|
+
// OCDS-T1: drop one segment's connection mid-body a couple of times.
|
|
87
|
+
// Only that segment is re-fetched (retries stay in its own window); the
|
|
88
|
+
// other segments are requested exactly once; assembled SHA is correct; no
|
|
89
|
+
// from-0 restart.
|
|
90
|
+
// ------------------------------------------------------------------------
|
|
91
|
+
@Test
|
|
92
|
+
fun ocdsT1_midBodyDropOnlyRefetchesAffectedSegment() {
|
|
93
|
+
val target = parts[3]
|
|
94
|
+
val fs = RangeFaultServer(content)
|
|
95
|
+
.arm(RangeFaultServer.FaultMode.DROP_DURING_BODY, target.start, target.end, times = 2)
|
|
96
|
+
startServer(fs)
|
|
97
|
+
|
|
98
|
+
val partial = partialPath()
|
|
99
|
+
val outcome = newDownloader().download(
|
|
100
|
+
url = url(),
|
|
101
|
+
partialFilePath = partial,
|
|
102
|
+
onProgress = { _, _ -> },
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
106
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
107
|
+
|
|
108
|
+
val ranges = fs.requestedRanges.toList()
|
|
109
|
+
// The dropped segment is fetched: initial + 2 retries = 3 times (its window).
|
|
110
|
+
// (Resume requests only the not-yet-on-disk tail, but the Range start stays
|
|
111
|
+
// inside the segment window — never byte 0 of the file.)
|
|
112
|
+
val targetReqs = ranges.filter { it.first in target.start..target.end }
|
|
113
|
+
assertTrue(
|
|
114
|
+
"dropped segment must be re-fetched at least twice (got ${targetReqs.size})",
|
|
115
|
+
targetReqs.size >= 2,
|
|
116
|
+
)
|
|
117
|
+
// No from-0 restart: every retry of the target segment starts within its own
|
|
118
|
+
// window, never at file byte 0 (unless segment 0 itself is the target — it
|
|
119
|
+
// is not here, target is part[3]).
|
|
120
|
+
assertTrue(
|
|
121
|
+
"no retry of the affected segment may restart at file byte 0",
|
|
122
|
+
targetReqs.all { it.first >= target.start },
|
|
123
|
+
)
|
|
124
|
+
// Every OTHER segment is requested exactly once.
|
|
125
|
+
for (p in parts) {
|
|
126
|
+
if (p.index == target.index) continue
|
|
127
|
+
val reqs = ranges.filter { it.first == p.start && it.second == p.end }
|
|
128
|
+
assertEquals(
|
|
129
|
+
"segment ${p.index} must be requested exactly once",
|
|
130
|
+
1,
|
|
131
|
+
reqs.size,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
// All segments consumed on success.
|
|
135
|
+
for (i in 0 until 8) assertFalse(segFile(partial, i).exists())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ------------------------------------------------------------------------
|
|
139
|
+
// OCDS-T2: (a) full download completes; (b) resume re-fetches only the tail.
|
|
140
|
+
// ------------------------------------------------------------------------
|
|
141
|
+
@Test
|
|
142
|
+
fun ocdsT2a_fullDownloadRequestsAllEightRanges() {
|
|
143
|
+
val fs = RangeFaultServer(content)
|
|
144
|
+
startServer(fs)
|
|
145
|
+
|
|
146
|
+
val partial = partialPath()
|
|
147
|
+
val outcome = newDownloader().download(
|
|
148
|
+
url = url(),
|
|
149
|
+
partialFilePath = partial,
|
|
150
|
+
onProgress = { _, _ -> },
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
154
|
+
assertEquals(content.size.toLong(), File(partial).length())
|
|
155
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
156
|
+
|
|
157
|
+
val ranges = fs.requestedRanges.toList()
|
|
158
|
+
for (p in parts) {
|
|
159
|
+
val reqs = ranges.filter { it.first == p.start && it.second == p.end }
|
|
160
|
+
assertEquals("segment ${p.index} must be requested once", 1, reqs.size)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@Test
|
|
165
|
+
fun ocdsT2b_resumeRefetchesOnlyMissingSegments() {
|
|
166
|
+
val fs = RangeFaultServer(content)
|
|
167
|
+
startServer(fs)
|
|
168
|
+
|
|
169
|
+
val partial = partialPath()
|
|
170
|
+
// Seed parts 0..4 as already-completed `.segN` files (mid-run kill model).
|
|
171
|
+
seedSegments(partial, content, completedThrough = 4, segments = 8)
|
|
172
|
+
|
|
173
|
+
val outcome = newDownloader().download(
|
|
174
|
+
url = url(),
|
|
175
|
+
partialFilePath = partial,
|
|
176
|
+
onProgress = { _, _ -> },
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
180
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
181
|
+
|
|
182
|
+
// The probe always issues a "bytes=0-0" request (size/Range capability
|
|
183
|
+
// check); it is NOT a segment fetch, so exclude it from the segment counts.
|
|
184
|
+
val PROBE = 0L to 0L
|
|
185
|
+
val ranges = fs.requestedRanges.toList().filterNot { it == PROBE }
|
|
186
|
+
// No SEGMENT request for the seeded windows (parts 0..4) beyond the probe.
|
|
187
|
+
for (idx in 0..4) {
|
|
188
|
+
val p = parts[idx]
|
|
189
|
+
val reqs = ranges.filter { it.first in p.start..p.end }
|
|
190
|
+
assertTrue("seeded segment $idx must NOT be re-fetched (got $reqs)", reqs.isEmpty())
|
|
191
|
+
}
|
|
192
|
+
// Only the tail (parts 5..7) is fetched, once each.
|
|
193
|
+
for (idx in 5..7) {
|
|
194
|
+
val p = parts[idx]
|
|
195
|
+
val reqs = ranges.filter { it.first == p.start && it.second == p.end }
|
|
196
|
+
assertEquals("tail segment $idx must be requested once", 1, reqs.size)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ------------------------------------------------------------------------
|
|
201
|
+
// OCDS-T3: status 200 to a Range request (probe) → FALLBACK + artifacts wiped.
|
|
202
|
+
// The probe itself sends Range "bytes=0-0"; a 200 there makes the probe
|
|
203
|
+
// report supportsRange=false → FALLBACK before any segment is written, so
|
|
204
|
+
// there are no artifacts to wipe (and the test asserts none exist).
|
|
205
|
+
// ------------------------------------------------------------------------
|
|
206
|
+
@Test
|
|
207
|
+
fun ocdsT3_status200ToRangeFallsBackAndWipesArtifacts() {
|
|
208
|
+
// Arm 200 on the probe window (bytes=0-0). The probe's 200 short-circuits to
|
|
209
|
+
// FALLBACK; no concurrent path runs.
|
|
210
|
+
val fs = RangeFaultServer(content)
|
|
211
|
+
.arm(RangeFaultServer.FaultMode.STATUS_200, 0L, 0L, times = Int.MAX_VALUE)
|
|
212
|
+
startServer(fs)
|
|
213
|
+
|
|
214
|
+
val partial = partialPath()
|
|
215
|
+
val outcome = newDownloader().download(
|
|
216
|
+
url = url(),
|
|
217
|
+
partialFilePath = partial,
|
|
218
|
+
onProgress = { _, _ -> },
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
|
|
222
|
+
assertNoArtifactsLeft(partial)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// OCDS-T3 (segment-level 200): the probe succeeds (206) but a segment fetch
|
|
226
|
+
// gets a 200 (server ignored Range on that connection) → core throws
|
|
227
|
+
// FallbackException → Outcome.FALLBACK and wipeArtifacts() clears every `.segN`
|
|
228
|
+
// / `.partial`.
|
|
229
|
+
@Test
|
|
230
|
+
fun ocdsT3_status200OnSegmentFallsBackAndWipesArtifacts() {
|
|
231
|
+
val target = parts[2]
|
|
232
|
+
val fs = RangeFaultServer(content)
|
|
233
|
+
.arm(RangeFaultServer.FaultMode.STATUS_200, target.start, target.end, times = Int.MAX_VALUE)
|
|
234
|
+
startServer(fs)
|
|
235
|
+
|
|
236
|
+
val partial = partialPath()
|
|
237
|
+
val outcome = newDownloader().download(
|
|
238
|
+
url = url(),
|
|
239
|
+
partialFilePath = partial,
|
|
240
|
+
onProgress = { _, _ -> },
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
|
|
244
|
+
assertNoArtifactsLeft(partial)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ------------------------------------------------------------------------
|
|
248
|
+
// OCDS-T4: 429 (+Retry-After: 0) once on a segment → transient retry succeeds.
|
|
249
|
+
// SHA correct; the affected segment requested twice; no from-0 restart.
|
|
250
|
+
// ------------------------------------------------------------------------
|
|
251
|
+
@Test
|
|
252
|
+
fun ocdsT4_status429ThenOkRetriesInPlace() {
|
|
253
|
+
val target = parts[5]
|
|
254
|
+
val fs = RangeFaultServer(content)
|
|
255
|
+
.arm(RangeFaultServer.FaultMode.STATUS_429_THEN_OK, target.start, target.end, times = 1)
|
|
256
|
+
startServer(fs)
|
|
257
|
+
|
|
258
|
+
val partial = partialPath()
|
|
259
|
+
val outcome = newDownloader().download(
|
|
260
|
+
url = url(),
|
|
261
|
+
partialFilePath = partial,
|
|
262
|
+
onProgress = { _, _ -> },
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
266
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
267
|
+
|
|
268
|
+
val ranges = fs.requestedRanges.toList()
|
|
269
|
+
// 429 returned an empty body, so the segment has 0 bytes on disk → the retry
|
|
270
|
+
// re-requests the SAME full window (start == part.start). Exactly two hits.
|
|
271
|
+
val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
|
|
272
|
+
assertEquals("affected segment must be requested twice (429 then OK)", 2, targetReqs.size)
|
|
273
|
+
assertTrue(
|
|
274
|
+
"retry must not restart at file byte 0",
|
|
275
|
+
targetReqs.all { it.first == target.start },
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ------------------------------------------------------------------------
|
|
280
|
+
// OCDS-T5(a): mis-aligned Content-Range → rejected, never silent corruption.
|
|
281
|
+
// The core treats a mismatched Content-Range as transient (retry); the
|
|
282
|
+
// harness's misaligned fault always shifts the window, so every retry is
|
|
283
|
+
// rejected → the segment never completes → download() throws after the
|
|
284
|
+
// per-segment budget. The KEY assertion: no corruption — assembly never
|
|
285
|
+
// COMPLETED with wrong bytes.
|
|
286
|
+
// ------------------------------------------------------------------------
|
|
287
|
+
@Test
|
|
288
|
+
fun ocdsT5a_misalignedContentRangeIsRejectedNeverCorrupts() {
|
|
289
|
+
val target = parts[1]
|
|
290
|
+
val fs = RangeFaultServer(content)
|
|
291
|
+
// Fire forever so the misalignment never "expires" into a good 206.
|
|
292
|
+
.arm(RangeFaultServer.FaultMode.MISALIGNED_CONTENT_RANGE, target.start, target.end, times = Int.MAX_VALUE)
|
|
293
|
+
startServer(fs)
|
|
294
|
+
|
|
295
|
+
val partial = partialPath()
|
|
296
|
+
var threw = false
|
|
297
|
+
var outcome: ConcurrentRangeDownloader.Outcome? = null
|
|
298
|
+
try {
|
|
299
|
+
outcome = newDownloader().download(
|
|
300
|
+
url = url(),
|
|
301
|
+
partialFilePath = partial,
|
|
302
|
+
onProgress = { _, _ -> },
|
|
303
|
+
)
|
|
304
|
+
} catch (e: Exception) {
|
|
305
|
+
threw = true
|
|
306
|
+
}
|
|
307
|
+
// Never a corrupt COMPLETED. Either it threw (transient give-up, segments
|
|
308
|
+
// kept for resume) or — if a future core maps it to fallback — FALLBACK.
|
|
309
|
+
assertTrue(
|
|
310
|
+
"misaligned 206 must not produce a COMPLETED outcome",
|
|
311
|
+
outcome != ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
312
|
+
)
|
|
313
|
+
assertTrue("misaligned 206 must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK)
|
|
314
|
+
// No assembled final file with the wrong window was produced.
|
|
315
|
+
if (File(partial).exists()) {
|
|
316
|
+
assertNotEquals(
|
|
317
|
+
"a `.partial` left on disk must NOT be a complete (corrupt) file",
|
|
318
|
+
content.size.toLong(),
|
|
319
|
+
File(partial).length(),
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// OCDS-T5(b): over-long body → not written past segment end; rejected.
|
|
325
|
+
// The harness fires the over-long body forever, so the segment is dropped on
|
|
326
|
+
// each attempt and never completes → throw after budget; crucially the
|
|
327
|
+
// neighbour segments are NOT corrupted (each writes its own `.segN`).
|
|
328
|
+
@Test
|
|
329
|
+
fun ocdsT5b_overLongBodyIsRejectedNoNeighbourCorruption() {
|
|
330
|
+
val target = parts[4]
|
|
331
|
+
val fs = RangeFaultServer(content)
|
|
332
|
+
.arm(RangeFaultServer.FaultMode.OVER_LONG_BODY, target.start, target.end, times = Int.MAX_VALUE)
|
|
333
|
+
startServer(fs)
|
|
334
|
+
|
|
335
|
+
val partial = partialPath()
|
|
336
|
+
var outcome: ConcurrentRangeDownloader.Outcome? = null
|
|
337
|
+
var threw = false
|
|
338
|
+
try {
|
|
339
|
+
outcome = newDownloader().download(
|
|
340
|
+
url = url(),
|
|
341
|
+
partialFilePath = partial,
|
|
342
|
+
onProgress = { _, _ -> },
|
|
343
|
+
)
|
|
344
|
+
} catch (e: Exception) {
|
|
345
|
+
threw = true
|
|
346
|
+
}
|
|
347
|
+
assertTrue(
|
|
348
|
+
"over-long 206 must not produce a COMPLETED outcome",
|
|
349
|
+
outcome != ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
350
|
+
)
|
|
351
|
+
assertTrue("over-long body must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK)
|
|
352
|
+
// The over-long segment is dropped (deleted) on overrun, so neighbours are
|
|
353
|
+
// never spliced. Any neighbour `.segN` that exists must be exactly its
|
|
354
|
+
// planned length — no extra bytes leaked across windows.
|
|
355
|
+
for (p in parts) {
|
|
356
|
+
if (p.index == target.index) continue
|
|
357
|
+
val f = segFile(partial, p.index)
|
|
358
|
+
if (f.exists()) {
|
|
359
|
+
assertTrue(
|
|
360
|
+
"neighbour segment ${p.index} must not exceed its planned length",
|
|
361
|
+
f.length() <= p.length,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// OCDS-T5(c): short body → the segment ends short and the tail is resumed
|
|
368
|
+
// (Range start advanced, NOT from 0) → final SHA correct.
|
|
369
|
+
//
|
|
370
|
+
// CORE BEHAVIOUR (verified): a clean short read (a complete 206 whose body is
|
|
371
|
+
// shorter than the requested window, no socket error) is NOT retried inside
|
|
372
|
+
// the same `download()` call — `downloadSegment` returns after a successful
|
|
373
|
+
// `fetchSegment`, so a short segment makes that invocation throw
|
|
374
|
+
// "incomplete", leaving the short `.segN` on disk. The resume is
|
|
375
|
+
// CROSS-INVOCATION (next relaunch): the second `download()` sees a short
|
|
376
|
+
// `.segN`, requests only the missing tail (`Range` start = current seg
|
|
377
|
+
// length, which is advanced past part.start), and completes. This test drives
|
|
378
|
+
// both invocations to assert the tail-resume + correct final SHA + no from-0
|
|
379
|
+
// restart.
|
|
380
|
+
@Test
|
|
381
|
+
fun ocdsT5c_shortBodyResumesTailToCorrectSha() {
|
|
382
|
+
val target = parts[6]
|
|
383
|
+
val fs = RangeFaultServer(content)
|
|
384
|
+
.arm(RangeFaultServer.FaultMode.SHORT_BODY, target.start, target.end, times = 1)
|
|
385
|
+
startServer(fs)
|
|
386
|
+
|
|
387
|
+
val partial = partialPath()
|
|
388
|
+
// First invocation: the short body leaves segment 6 one byte short, so this
|
|
389
|
+
// call throws "incomplete" and keeps the `.segN` for resume.
|
|
390
|
+
var firstThrew = false
|
|
391
|
+
try {
|
|
392
|
+
newDownloader().download(
|
|
393
|
+
url = url(),
|
|
394
|
+
partialFilePath = partial,
|
|
395
|
+
onProgress = { _, _ -> },
|
|
396
|
+
)
|
|
397
|
+
} catch (e: Exception) {
|
|
398
|
+
firstThrew = true
|
|
399
|
+
}
|
|
400
|
+
assertTrue("short body must leave the first invocation incomplete", firstThrew)
|
|
401
|
+
// The short segment is kept on disk, one byte under its planned length.
|
|
402
|
+
val seg = segFile(partial, target.index)
|
|
403
|
+
assertTrue("short `.segN` must be kept for resume", seg.exists())
|
|
404
|
+
assertEquals("short segment is exactly one byte short", target.length - 1, seg.length())
|
|
405
|
+
|
|
406
|
+
val rangesAfterFirst = fs.requestedRanges.toList()
|
|
407
|
+
|
|
408
|
+
// Second invocation (relaunch): the fault has expired (times=1), so the tail
|
|
409
|
+
// is served normally and the download completes.
|
|
410
|
+
val outcome = newDownloader().download(
|
|
411
|
+
url = url(),
|
|
412
|
+
partialFilePath = partial,
|
|
413
|
+
onProgress = { _, _ -> },
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
417
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
418
|
+
|
|
419
|
+
// The RESUME requested only the missing tail of segment 6: Range start
|
|
420
|
+
// advanced past part.start (= part.end here, the single missing byte), end ==
|
|
421
|
+
// part.end. Never a from-0 (file byte 0) restart.
|
|
422
|
+
val resumeRanges = fs.requestedRanges.toList().drop(rangesAfterFirst.size)
|
|
423
|
+
val tailResume = resumeRanges.filter {
|
|
424
|
+
it.first > target.start && it.first <= target.end && it.second == target.end
|
|
425
|
+
}
|
|
426
|
+
assertTrue(
|
|
427
|
+
"short body must trigger a tail-only resume (start advanced, not from 0)",
|
|
428
|
+
tailResume.isNotEmpty(),
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ------------------------------------------------------------------------
|
|
433
|
+
// OCDS-T8: cancel mid-download → run stops promptly; cancel-then-delete works
|
|
434
|
+
// (artifacts can be cleaned without a straggler resurrecting them).
|
|
435
|
+
// Strategy: stall every segment (1 byte/sec) so the download is in-flight,
|
|
436
|
+
// then cancel() from another thread. cancel() shuts the pool down and waits
|
|
437
|
+
// (bounded) for workers to terminate, so a subsequent delete is durable.
|
|
438
|
+
// ------------------------------------------------------------------------
|
|
439
|
+
@Test
|
|
440
|
+
fun ocdsT8_cancelMidDownloadStopsAndArtifactsStayDeleted() {
|
|
441
|
+
val fs = RangeFaultServer(content)
|
|
442
|
+
// Stall ALL eight segment windows so the workers are parked in body reads.
|
|
443
|
+
for (p in parts) {
|
|
444
|
+
fs.arm(RangeFaultServer.FaultMode.STALL, p.start, p.end, times = Int.MAX_VALUE)
|
|
445
|
+
}
|
|
446
|
+
startServer(fs)
|
|
447
|
+
|
|
448
|
+
val partial = partialPath()
|
|
449
|
+
val handle = ConcurrentRangeDownloader.CancelHandle()
|
|
450
|
+
val downloader = newDownloader()
|
|
451
|
+
|
|
452
|
+
val resultRef = AtomicReference<Any?>(null)
|
|
453
|
+
val started = CountDownLatch(1)
|
|
454
|
+
val worker = Executors.newSingleThreadExecutor()
|
|
455
|
+
val future = worker.submit {
|
|
456
|
+
started.countDown()
|
|
457
|
+
try {
|
|
458
|
+
resultRef.set(
|
|
459
|
+
downloader.download(
|
|
460
|
+
url = url(),
|
|
461
|
+
partialFilePath = partial,
|
|
462
|
+
cancelHandle = handle,
|
|
463
|
+
onProgress = { _, _ -> },
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
} catch (e: Exception) {
|
|
467
|
+
resultRef.set(e)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Wait until the download has begun, give it a beat to open the segment
|
|
472
|
+
// sockets, then cancel.
|
|
473
|
+
started.await(5, TimeUnit.SECONDS)
|
|
474
|
+
Thread.sleep(400)
|
|
475
|
+
val cancelStart = System.currentTimeMillis()
|
|
476
|
+
handle.cancel()
|
|
477
|
+
|
|
478
|
+
// download() must return promptly after cancel (well under the 60s the stall
|
|
479
|
+
// would otherwise take). cancel() awaits worker termination (<=3s) + the
|
|
480
|
+
// download() loop joins futures; allow a generous bound.
|
|
481
|
+
future.get(15, TimeUnit.SECONDS)
|
|
482
|
+
val elapsed = System.currentTimeMillis() - cancelStart
|
|
483
|
+
worker.shutdownNow()
|
|
484
|
+
|
|
485
|
+
assertTrue("cancel must abort promptly (took ${elapsed}ms)", elapsed < 12_000)
|
|
486
|
+
|
|
487
|
+
// cancel-then-delete: now that workers are terminated, deleting the artifacts
|
|
488
|
+
// must STICK — no straggler worker resurrects a `.segN`.
|
|
489
|
+
File(partial).delete()
|
|
490
|
+
for (i in 0 until 8) segFile(partial, i).delete()
|
|
491
|
+
// Give any (forbidden) straggler a window to (incorrectly) recreate a file.
|
|
492
|
+
Thread.sleep(500)
|
|
493
|
+
assertNoArtifactsLeft(partial)
|
|
494
|
+
|
|
495
|
+
// The aborted run did not produce a COMPLETED outcome.
|
|
496
|
+
assertNotEquals(
|
|
497
|
+
"cancelled run must not report COMPLETED",
|
|
498
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
499
|
+
resultRef.get(),
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ------------------------------------------------------------------------
|
|
504
|
+
// OCDS-T10: stalled socket (bytes trickle past the read timeout) → the
|
|
505
|
+
// read-timeout surfaces as a transient error → retry succeeds.
|
|
506
|
+
// The default OkHttpClient read timeout is 10s, too slow for a fast unit
|
|
507
|
+
// test, so we inject a client with a 600ms read timeout. The harness stalls
|
|
508
|
+
// the segment ONCE (times=1): the first attempt times out mid-body, the core
|
|
509
|
+
// retries, and the second attempt (fault expired) streams the full window.
|
|
510
|
+
// ------------------------------------------------------------------------
|
|
511
|
+
@Test
|
|
512
|
+
fun ocdsT10_stalledSocketTimesOutAsTransientThenRetrySucceeds() {
|
|
513
|
+
val target = parts[7]
|
|
514
|
+
val fs = RangeFaultServer(content)
|
|
515
|
+
.arm(RangeFaultServer.FaultMode.STALL, target.start, target.end, times = 1)
|
|
516
|
+
startServer(fs)
|
|
517
|
+
|
|
518
|
+
// Inject a small read timeout so the stall (1 byte/sec) trips it fast.
|
|
519
|
+
val fastClient = OkHttpClient.Builder()
|
|
520
|
+
.readTimeout(600, TimeUnit.MILLISECONDS)
|
|
521
|
+
.build()
|
|
522
|
+
val downloader = ConcurrentRangeDownloader(
|
|
523
|
+
httpClient = fastClient,
|
|
524
|
+
segmentCount = 8,
|
|
525
|
+
// Tiny backoff so the single retry runs fast.
|
|
526
|
+
retryBaseDelayMillis = 50L,
|
|
527
|
+
retryMaxDelayMillis = 200L,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
val partial = partialPath()
|
|
531
|
+
val outcome = downloader.download(
|
|
532
|
+
url = url(),
|
|
533
|
+
partialFilePath = partial,
|
|
534
|
+
onProgress = { _, _ -> },
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
538
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
539
|
+
|
|
540
|
+
val ranges = fs.requestedRanges.toList()
|
|
541
|
+
// The stalled segment is requested more than once (timeout → transient retry).
|
|
542
|
+
// The retry may resume from whatever partial bytes the stall delivered
|
|
543
|
+
// (start >= part.start), never from file byte 0.
|
|
544
|
+
val targetReqs = ranges.filter { it.first in target.start..target.end && it.second == target.end }
|
|
545
|
+
assertTrue(
|
|
546
|
+
"stalled segment must be retried (>=2 requests, got ${targetReqs.size})",
|
|
547
|
+
targetReqs.size >= 2,
|
|
548
|
+
)
|
|
549
|
+
assertTrue(
|
|
550
|
+
"stall retry must not restart at file byte 0",
|
|
551
|
+
targetReqs.all { it.first >= target.start },
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
}
|