@onekeyfe/react-native-range-downloader 3.0.66 → 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/OcdsBadTotalRejectTest.kt
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import org.junit.After
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertFalse
|
|
6
|
+
import org.junit.Assert.assertNotEquals
|
|
7
|
+
import org.junit.Assert.assertTrue
|
|
8
|
+
import org.junit.Before
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
11
|
+
import java.io.File
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OCDS gap A4 (SPEC #5 — OCDS §5): a 206 whose `Content-Range` total (the
|
|
15
|
+
* `/<total>` tail) DISAGREES with the probe total → PERMANENT FALLBACK
|
|
16
|
+
* (single-stream), never silent corruption. This is the "object changed size
|
|
17
|
+
* behind us" case: the byte window we asked for is served correctly, but the
|
|
18
|
+
* server now advertises a different whole-object size, so concurrency is
|
|
19
|
+
* unreconcilable and the core must bail to single-stream rather than splice in
|
|
20
|
+
* bytes from a different build.
|
|
21
|
+
*
|
|
22
|
+
* Drives the REAL [ConcurrentRangeDownloader.download] against the
|
|
23
|
+
* [RangeFaultServer] MockWebServer harness, using the same conventions as
|
|
24
|
+
* [ConcurrentRangeDownloaderOcdsTest] (newDownloader(), planParts, arm(...),
|
|
25
|
+
* sha256(File), assertNoArtifactsLeft).
|
|
26
|
+
*
|
|
27
|
+
* Core path under test (ConcurrentRangeDownloader.kt):
|
|
28
|
+
* - parseContentRangeBounds (542-548) returns Triple(start, end, total+1);
|
|
29
|
+
* - the bounds check at 494 PASSES (start/end are correct);
|
|
30
|
+
* - the total check at 506 (`parsedTotal != total`) FIRES → FallbackException;
|
|
31
|
+
* - the worker sets fallback=true (234-237), download() wipeArtifacts() (252)
|
|
32
|
+
* and returns Outcome.FALLBACK (253).
|
|
33
|
+
*
|
|
34
|
+
* The harness BAD_TOTAL_206 fault (FaultServer.kt:155-164) is the only mode that
|
|
35
|
+
* produces this: it keeps the correct start/end bounds and the correct body, but
|
|
36
|
+
* advertises `/<total+1>` in the `/<total>` tail — so it exercises the total
|
|
37
|
+
* branch in isolation (the bounds branch never trips).
|
|
38
|
+
*/
|
|
39
|
+
class OcdsBadTotalRejectTest {
|
|
40
|
+
|
|
41
|
+
private lateinit var server: MockWebServer
|
|
42
|
+
private lateinit var tmpDir: File
|
|
43
|
+
|
|
44
|
+
// 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path,
|
|
45
|
+
// and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts.
|
|
46
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
47
|
+
private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
|
|
48
|
+
|
|
49
|
+
@Before
|
|
50
|
+
fun setUp() {
|
|
51
|
+
tmpDir = File.createTempFile("crd-ocds-a4", "").let {
|
|
52
|
+
it.delete(); it.mkdirs(); it
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@After
|
|
57
|
+
fun tearDown() {
|
|
58
|
+
if (this::server.isInitialized) server.shutdown()
|
|
59
|
+
tmpDir.deleteRecursively()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) ------------------
|
|
63
|
+
|
|
64
|
+
private fun startServer(faultServer: RangeFaultServer) {
|
|
65
|
+
server = MockWebServer()
|
|
66
|
+
server.dispatcher = faultServer.dispatcher
|
|
67
|
+
server.start()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private fun url() = server.url("/asset.bin").toString()
|
|
71
|
+
|
|
72
|
+
private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
|
|
73
|
+
|
|
74
|
+
private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
|
|
75
|
+
|
|
76
|
+
private fun assertNoArtifactsLeft(partial: String) {
|
|
77
|
+
assertFalse(".partial must be wiped", File(partial).exists())
|
|
78
|
+
for (i in 0 until 8) {
|
|
79
|
+
assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ------------------------------------------------------------------------
|
|
84
|
+
// A4: a single segment's 206 advertises a Content-Range total of (total+1).
|
|
85
|
+
// The probe window (bytes=0-0) is unarmed, so the probe still reports the
|
|
86
|
+
// true total and supportsRange=true → the concurrent path runs. When the
|
|
87
|
+
// armed segment returns its (correct-window, correct-body) 206 with the
|
|
88
|
+
// wrong `/<total>` tail, the core's total check fires FallbackException →
|
|
89
|
+
// Outcome.FALLBACK and every artifact is wiped. No corrupt assembly.
|
|
90
|
+
// ------------------------------------------------------------------------
|
|
91
|
+
@Test
|
|
92
|
+
fun ocdsA4_badContentRangeTotalFallsBackAndWipesArtifacts() {
|
|
93
|
+
val target = parts[3]
|
|
94
|
+
val fs = RangeFaultServer(content)
|
|
95
|
+
// Fire forever so the bad total never "expires" into a good 206 — the
|
|
96
|
+
// window must be permanently unusable for concurrency.
|
|
97
|
+
.arm(RangeFaultServer.FaultMode.BAD_TOTAL_206, target.start, target.end, times = Int.MAX_VALUE)
|
|
98
|
+
startServer(fs)
|
|
99
|
+
|
|
100
|
+
val partial = partialPath()
|
|
101
|
+
val outcome = newDownloader().download(
|
|
102
|
+
url = url(),
|
|
103
|
+
partialFilePath = partial,
|
|
104
|
+
onProgress = { _, _ -> },
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
// Load-bearing: a disagreeing concrete total is permanent for this window,
|
|
108
|
+
// so the core must signal single-stream fallback (NOT COMPLETED, NOT a
|
|
109
|
+
// transient throw). This assertion fails if the line-506 total check is
|
|
110
|
+
// removed/weakened or mis-classified as transient.
|
|
111
|
+
assertEquals(
|
|
112
|
+
"Content-Range total mismatch must yield FALLBACK",
|
|
113
|
+
ConcurrentRangeDownloader.Outcome.FALLBACK,
|
|
114
|
+
outcome,
|
|
115
|
+
)
|
|
116
|
+
assertNotEquals(
|
|
117
|
+
"bad-total 206 must NOT complete the concurrent download",
|
|
118
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
119
|
+
outcome,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Load-bearing: no corruption — the fallback path wipes every `.partial` /
|
|
123
|
+
// `.segN`, so the caller's single-stream retry starts from a clean slate and
|
|
124
|
+
// no half-written segment from a differently-sized object survives.
|
|
125
|
+
assertNoArtifactsLeft(partial)
|
|
126
|
+
|
|
127
|
+
// Defensive: even if a `.partial` were (incorrectly) left behind, it must
|
|
128
|
+
// never be a full-length (i.e. silently-assembled / corrupt) file.
|
|
129
|
+
if (File(partial).exists()) {
|
|
130
|
+
assertNotEquals(
|
|
131
|
+
"a leftover `.partial` must NOT be a complete (corrupt) file",
|
|
132
|
+
content.size.toLong(),
|
|
133
|
+
File(partial).length(),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Load-bearing: the offending window WAS actually fetched (so the fallback
|
|
138
|
+
// is provably driven by the bad-total 206, not by an earlier probe bail).
|
|
139
|
+
// The probe issues bytes=0-0; the target window 3 is a real segment fetch.
|
|
140
|
+
val ranges = fs.requestedRanges.toList()
|
|
141
|
+
val targetReqs = ranges.filter { it.first == target.start && it.second == target.end }
|
|
142
|
+
assertTrue(
|
|
143
|
+
"the bad-total segment window must have been requested (got ${targetReqs.size})",
|
|
144
|
+
targetReqs.isNotEmpty(),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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.Before
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
9
|
+
import java.io.File
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* OCDS §5 conformance — gap A3 (SPEC #5): a `multipart/byteranges` body served
|
|
13
|
+
* to a SINGLE Range request must be REJECTED as a PERMANENT fallback (never
|
|
14
|
+
* spliced raw into the segment file), and every on-disk artifact must be wiped.
|
|
15
|
+
*
|
|
16
|
+
* This is the multipart TWIN of [ConcurrentRangeDownloaderOcdsTest]'s
|
|
17
|
+
* `ocdsT3_status200OnSegmentFallsBackAndWipesArtifacts`: same
|
|
18
|
+
* FallbackException → wipeArtifacts → Outcome.FALLBACK contract, different
|
|
19
|
+
* trigger. Here the core's branch at ConcurrentRangeDownloader.kt:480-483
|
|
20
|
+
* (Content-Type startsWith "multipart/byteranges" → FallbackException) is the
|
|
21
|
+
* one under test.
|
|
22
|
+
*
|
|
23
|
+
* It drives the REAL [ConcurrentRangeDownloader.download] against the
|
|
24
|
+
* [RangeFaultServer] MockWebServer harness — no re-implementation of core
|
|
25
|
+
* logic. The probe (bytes=0-0) is NOT armed, so it returns a normal 206 and the
|
|
26
|
+
* concurrent path actually starts; only the targeted segment window is armed
|
|
27
|
+
* with [RangeFaultServer.FaultMode.MULTIPART_BYTERANGES], so the worker hits the
|
|
28
|
+
* multipart body, throws FallbackException, and the run falls back + wipes.
|
|
29
|
+
*/
|
|
30
|
+
class OcdsMultipartRejectTest {
|
|
31
|
+
|
|
32
|
+
private lateinit var server: MockWebServer
|
|
33
|
+
private lateinit var tmpDir: File
|
|
34
|
+
|
|
35
|
+
// 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path,
|
|
36
|
+
// and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts.
|
|
37
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
38
|
+
private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
|
|
39
|
+
|
|
40
|
+
@Before
|
|
41
|
+
fun setUp() {
|
|
42
|
+
tmpDir = File.createTempFile("crd-ocds-multipart", "").let {
|
|
43
|
+
it.delete(); it.mkdirs(); it
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@After
|
|
48
|
+
fun tearDown() {
|
|
49
|
+
if (this::server.isInitialized) server.shutdown()
|
|
50
|
+
tmpDir.deleteRecursively()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- helpers (identical conventions to ConcurrentRangeDownloaderOcdsTest) --
|
|
54
|
+
|
|
55
|
+
private fun startServer(faultServer: RangeFaultServer) {
|
|
56
|
+
server = MockWebServer()
|
|
57
|
+
server.dispatcher = faultServer.dispatcher
|
|
58
|
+
server.start()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private fun url() = server.url("/asset.bin").toString()
|
|
62
|
+
|
|
63
|
+
private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
|
|
64
|
+
|
|
65
|
+
private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
|
|
66
|
+
|
|
67
|
+
private fun assertNoArtifactsLeft(partial: String) {
|
|
68
|
+
assertFalse(".partial must be wiped", File(partial).exists())
|
|
69
|
+
for (i in 0 until 8) {
|
|
70
|
+
assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ------------------------------------------------------------------------
|
|
75
|
+
// OCDS §5 / gap A3: multipart/byteranges body on a SEGMENT fetch.
|
|
76
|
+
// The probe succeeds (206), the concurrent path starts, then the armed
|
|
77
|
+
// segment fetch receives a 206 whose Content-Type is
|
|
78
|
+
// "multipart/byteranges; boundary=THISISABOUNDARY". The core rejects it as
|
|
79
|
+
// a PERMANENT fallback (ConcurrentRangeDownloader.kt:480-483 →
|
|
80
|
+
// FallbackException), the worker sets fallback=true + aborted=true
|
|
81
|
+
// (lines 234-237), and after the futures join fallback.get() is true →
|
|
82
|
+
// wipeArtifacts (lines 250-253) → Outcome.FALLBACK. No boundary bytes are
|
|
83
|
+
// spliced into any segment; every `.segN` / `.partial` is wiped.
|
|
84
|
+
// ------------------------------------------------------------------------
|
|
85
|
+
@Test
|
|
86
|
+
fun ocdsA3_multipartByterangesOnSegmentFallsBackAndWipesArtifacts() {
|
|
87
|
+
val target = parts[2]
|
|
88
|
+
val fs = RangeFaultServer(content)
|
|
89
|
+
// Fire forever so the multipart body never "expires" into a good 206 —
|
|
90
|
+
// the segment can never complete, the only resolution is fallback.
|
|
91
|
+
.arm(RangeFaultServer.FaultMode.MULTIPART_BYTERANGES, target.start, target.end, times = Int.MAX_VALUE)
|
|
92
|
+
startServer(fs)
|
|
93
|
+
|
|
94
|
+
val partial = partialPath()
|
|
95
|
+
val outcome = newDownloader().download(
|
|
96
|
+
url = url(),
|
|
97
|
+
partialFilePath = partial,
|
|
98
|
+
onProgress = { _, _ -> },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Load-bearing: PERMANENT fallback (not COMPLETED, not a transient throw).
|
|
102
|
+
// If the multipart branch regressed (e.g. the body was streamed raw into
|
|
103
|
+
// the segment file), this would assemble corrupt bytes and report COMPLETED
|
|
104
|
+
// instead — the equality fails.
|
|
105
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome)
|
|
106
|
+
|
|
107
|
+
// Load-bearing: no corruption left behind. The `.partial` and ALL eight
|
|
108
|
+
// `.segN` files MUST be wiped. A raw multipart body spliced into `.seg2`
|
|
109
|
+
// (the regression) would leave a boundary-contaminated artifact here, and
|
|
110
|
+
// the assembled `.partial` (if it ever formed) would survive — both would
|
|
111
|
+
// fail assertNoArtifactsLeft.
|
|
112
|
+
assertNoArtifactsLeft(partial)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
4
|
+
import org.junit.After
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
7
|
+
import org.junit.Assert.assertNotEquals
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Before
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
import java.io.File
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OCDS gap A6 (SPEC §5.2 disk) — the segment-file design exists SPECIFICALLY to
|
|
15
|
+
* survive near-full / read-only filesystems: there is NO `RandomAccessFile.
|
|
16
|
+
* setLength(total)` pre-allocation (which fails EROFS/ENOSPC up front on f2fs),
|
|
17
|
+
* only plain O_WRONLY sequential appends + O_RDONLY reads. This suite drives the
|
|
18
|
+
* REAL [ConcurrentRangeDownloader.download] against a directory we flip to
|
|
19
|
+
* read-only with [File.setWritable] and asserts the download fails CLEANLY:
|
|
20
|
+
*
|
|
21
|
+
* - it throws / returns a NON-COMPLETED outcome (never a corrupt COMPLETED),
|
|
22
|
+
* - it leaves NO partial/corrupt assembled artifact (`.partial` is not created
|
|
23
|
+
* in the read-only dir, so nothing half-written can be promoted),
|
|
24
|
+
* - it does NOT data-loss the resumable `.segN` inputs (the read-only dir
|
|
25
|
+
* blocks NEW-file creation but the already-present segment files survive,
|
|
26
|
+
* readable, full-length — so a later attempt on a writable dir resumes), and
|
|
27
|
+
* - it does NOT crash-loop: once the dir is writable again the SAME inputs
|
|
28
|
+
* assemble idempotently to the correct whole-file SHA-256.
|
|
29
|
+
*
|
|
30
|
+
* Conventions mirror [ConcurrentRangeDownloaderOcdsTest]: 3 MiB content over 8
|
|
31
|
+
* segments, [newDownloader] / [planParts] / [seedSegments] / [RangeFaultServer]
|
|
32
|
+
* / [sha256] / [assertNoArtifactsLeft]. The probe (`bytes=0-0`) is READ-only, so
|
|
33
|
+
* a read-only dir does not block it — the failure must come from the concat
|
|
34
|
+
* write, exactly as §5.2 describes.
|
|
35
|
+
*
|
|
36
|
+
* Out of scope (asserted/explained, never forced): the fsync DURABILITY guarantee
|
|
37
|
+
* itself (concatenate(): `out.fd.sync()`) is not directly observable from the JVM
|
|
38
|
+
* — a JVM test cannot prove the bytes reached the platter, only that the assemble
|
|
39
|
+
* → size-guard → (would-be) rename pipeline is correct and crash-safe. What §5.2
|
|
40
|
+
* IS testable for is asserted below: concat over a read-only dir fails cleanly,
|
|
41
|
+
* and committed-prefix concat is idempotent.
|
|
42
|
+
*/
|
|
43
|
+
class OcdsReadOnlyFsTest {
|
|
44
|
+
|
|
45
|
+
private lateinit var server: MockWebServer
|
|
46
|
+
private lateinit var tmpDir: File
|
|
47
|
+
|
|
48
|
+
// Same shape as the core OCDS suite: 3 MiB > minConcurrentBytes (2 MiB) so the
|
|
49
|
+
// concurrent path is taken, ceil-chunked over 8 non-empty segments.
|
|
50
|
+
private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
|
|
51
|
+
private val parts: List<PlannedPart> by lazy { planParts(content.size.toLong(), 8) }
|
|
52
|
+
|
|
53
|
+
@Before
|
|
54
|
+
fun setUp() {
|
|
55
|
+
tmpDir = File.createTempFile("crd-a6-rofs", "").let {
|
|
56
|
+
it.delete(); it.mkdirs(); it
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@After
|
|
61
|
+
fun tearDown() {
|
|
62
|
+
// Always restore writability so deleteRecursively can clean up even if a test
|
|
63
|
+
// body asserts before flipping it back.
|
|
64
|
+
tmpDir.setWritable(true)
|
|
65
|
+
if (this::server.isInitialized) server.shutdown()
|
|
66
|
+
tmpDir.deleteRecursively()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- helpers (identical conventions to ConcurrentRangeDownloaderOcdsTest) ----
|
|
70
|
+
|
|
71
|
+
private fun startServer(faultServer: RangeFaultServer) {
|
|
72
|
+
server = MockWebServer()
|
|
73
|
+
server.dispatcher = faultServer.dispatcher
|
|
74
|
+
server.start()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun url() = server.url("/asset.bin").toString()
|
|
78
|
+
|
|
79
|
+
private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath
|
|
80
|
+
|
|
81
|
+
private fun segFile(partial: String, index: Int) = File("$partial.seg$index")
|
|
82
|
+
|
|
83
|
+
private fun assertNoArtifactsLeft(partial: String) {
|
|
84
|
+
assertFalse(".partial must be wiped", File(partial).exists())
|
|
85
|
+
for (i in 0 until 8) {
|
|
86
|
+
assertFalse(".seg$i must be wiped", segFile(partial, i).exists())
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------------
|
|
91
|
+
// A6(a): concat into a READ-ONLY directory fails CLEANLY.
|
|
92
|
+
// Seed all 8 `.segN` (so download() skips fetching and goes straight to the
|
|
93
|
+
// concat — concatenate() at :332), then flip the dir read-only. The concat's
|
|
94
|
+
// `FileOutputStream(partialFile, append=true)` (:344) cannot CREATE the
|
|
95
|
+
// `.partial` in a read-only dir → FileNotFoundException propagates out of
|
|
96
|
+
// download(). Load-bearing assertions: it THREW, NO COMPLETED outcome, and NO
|
|
97
|
+
// `.partial` artifact exists (nothing half-written to promote). Crucially the
|
|
98
|
+
// resumable `.segN` inputs survive full-length (a read-only dir blocks NEW
|
|
99
|
+
// files, not reads of existing ones), so no data is lost.
|
|
100
|
+
// ------------------------------------------------------------------------
|
|
101
|
+
@Test
|
|
102
|
+
fun a6a_concatIntoReadOnlyDirFailsCleanlyNoArtifactNoDataLoss() {
|
|
103
|
+
val fs = RangeFaultServer(content)
|
|
104
|
+
startServer(fs)
|
|
105
|
+
|
|
106
|
+
val partial = partialPath()
|
|
107
|
+
// All 8 segments present and full-length → planning marks none pending, the
|
|
108
|
+
// worker pool never runs, and download() proceeds directly to concatenate().
|
|
109
|
+
seedSegments(partial, content, completedThrough = 7, segments = 8)
|
|
110
|
+
for (i in 0 until 8) {
|
|
111
|
+
assertTrue("seeded segment $i must exist before RO flip", segFile(partial, i).exists())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Make the destination directory read-only. The probe (bytes=0-0) is a pure
|
|
115
|
+
// read, so it still succeeds; the ONLY thing that fails is the concat write.
|
|
116
|
+
assertTrue("test precondition: setWritable(false) must succeed", tmpDir.setWritable(false))
|
|
117
|
+
assertFalse("test precondition: dir must now be read-only", tmpDir.canWrite())
|
|
118
|
+
|
|
119
|
+
var threw = false
|
|
120
|
+
var outcome: ConcurrentRangeDownloader.Outcome? = null
|
|
121
|
+
try {
|
|
122
|
+
outcome = newDownloader().download(
|
|
123
|
+
url = url(),
|
|
124
|
+
partialFilePath = partial,
|
|
125
|
+
onProgress = { _, _ -> },
|
|
126
|
+
)
|
|
127
|
+
} catch (e: Exception) {
|
|
128
|
+
threw = true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// CLEAN failure: a read-only filesystem must surface as a thrown error, never
|
|
132
|
+
// a corrupt success. (No setLength pre-allocation crash; the plain O_WRONLY
|
|
133
|
+
// open simply fails and the exception propagates.)
|
|
134
|
+
assertTrue("read-only concat must throw (clean failure, not a crash-loop)", threw)
|
|
135
|
+
assertNotEquals(
|
|
136
|
+
"read-only filesystem must NOT yield a COMPLETED outcome",
|
|
137
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
138
|
+
outcome,
|
|
139
|
+
)
|
|
140
|
+
// NO partial/corrupt assembled artifact: the read-only dir refuses to create
|
|
141
|
+
// the `.partial`, so there is nothing half-written for the caller to promote.
|
|
142
|
+
assertFalse(
|
|
143
|
+
"no `.partial` may be created in a read-only dir",
|
|
144
|
+
File(partial).exists(),
|
|
145
|
+
)
|
|
146
|
+
// NO data loss: the resumable inputs survive, each at its exact planned
|
|
147
|
+
// length, so a later attempt on a writable dir resumes (no from-0 restart).
|
|
148
|
+
for (p in parts) {
|
|
149
|
+
val seg = segFile(partial, p.index)
|
|
150
|
+
assertTrue("segment ${p.index} must survive the read-only failure", seg.exists())
|
|
151
|
+
assertEquals(
|
|
152
|
+
"surviving segment ${p.index} must keep its full planned length",
|
|
153
|
+
p.length,
|
|
154
|
+
seg.length(),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// No crash-loop: restoring writability lets the SAME inputs assemble cleanly.
|
|
159
|
+
// This drives the REAL concat over the surviving `.segN` and proves the prior
|
|
160
|
+
// read-only failure left a perfectly resumable state.
|
|
161
|
+
assertTrue("restore: setWritable(true) must succeed", tmpDir.setWritable(true))
|
|
162
|
+
val retryOutcome = newDownloader().download(
|
|
163
|
+
url = url(),
|
|
164
|
+
partialFilePath = partial,
|
|
165
|
+
onProgress = { _, _ -> },
|
|
166
|
+
)
|
|
167
|
+
assertEquals(
|
|
168
|
+
"retry on a writable dir must COMPLETE from the surviving segments",
|
|
169
|
+
ConcurrentRangeDownloader.Outcome.COMPLETED,
|
|
170
|
+
retryOutcome,
|
|
171
|
+
)
|
|
172
|
+
// Load-bearing: a REAL SHA-256 over the REAL assembled file vs the source. A
|
|
173
|
+
// from-0 restart, a corrupt concat, or a misordered splice would fail here.
|
|
174
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
175
|
+
// Concat consumed every segment on success.
|
|
176
|
+
for (i in 0 until 8) assertFalse(segFile(partial, i).exists())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------------------
|
|
180
|
+
// A6(b): COMMITTED-PREFIX idempotent concat (§5.2 "assemble is idempotent /
|
|
181
|
+
// committed-prefix resume"). Model a concat that was interrupted PART-WAY:
|
|
182
|
+
// all 8 `.segN` are present AND the `.partial` already holds the first K
|
|
183
|
+
// segments' combined extent (a mid-concat-interrupt snapshot). On a writable
|
|
184
|
+
// dir, download() must NOT re-append the already-committed prefix (append
|
|
185
|
+
// writes at EOF; concatenate() skips bytes below `written` at :348-352) and
|
|
186
|
+
// must finish to the exact correct whole-file SHA — i.e. running concat over a
|
|
187
|
+
// partially-committed `.partial` is idempotent and never double-writes the
|
|
188
|
+
// prefix or restarts from 0.
|
|
189
|
+
// ------------------------------------------------------------------------
|
|
190
|
+
@Test
|
|
191
|
+
fun a6b_committedPrefixConcatIsIdempotentToCorrectSha() {
|
|
192
|
+
val fs = RangeFaultServer(content)
|
|
193
|
+
startServer(fs)
|
|
194
|
+
|
|
195
|
+
val partial = partialPath()
|
|
196
|
+
// All 8 segments full-length on disk (a clean "all fetched" state).
|
|
197
|
+
seedSegments(partial, content, completedThrough = 7, segments = 8)
|
|
198
|
+
|
|
199
|
+
// Pre-write a `.partial` equal to the first 3 segments' combined extent — the
|
|
200
|
+
// exact byte image an interrupted concat would have left (the first K segments
|
|
201
|
+
// already appended, in order, into `.partial`). Use the genuine source bytes
|
|
202
|
+
// so the committed prefix is correct; concatenate() must extend, not rewrite.
|
|
203
|
+
val k = 3
|
|
204
|
+
val committedExtent = parts.take(k).sumOf { it.length }
|
|
205
|
+
File(partial).writeBytes(content.copyOfRange(0, committedExtent.toInt()))
|
|
206
|
+
assertEquals(
|
|
207
|
+
"precondition: pre-committed `.partial` spans the first $k segments",
|
|
208
|
+
committedExtent,
|
|
209
|
+
File(partial).length(),
|
|
210
|
+
)
|
|
211
|
+
// The committed-prefix model also allows the consumed segments' `.segN` to be
|
|
212
|
+
// gone (an interrupted concat deletes each seg after appending it). Delete the
|
|
213
|
+
// first K to exercise the "committed prefix segment whose `.segN` is gone is
|
|
214
|
+
// still DONE" branch (:201-223) — it must NOT be re-fetched (the probe is the
|
|
215
|
+
// only network the server should see beyond none).
|
|
216
|
+
for (i in 0 until k) segFile(partial, i).delete()
|
|
217
|
+
|
|
218
|
+
val outcome = newDownloader().download(
|
|
219
|
+
url = url(),
|
|
220
|
+
partialFilePath = partial,
|
|
221
|
+
onProgress = { _, _ -> },
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
|
|
225
|
+
// Load-bearing: idempotent concat must land EXACTLY the source bytes. A
|
|
226
|
+
// double-appended prefix would overshoot total (caught by the :376 size guard
|
|
227
|
+
// → delete+throw, so we would NOT be COMPLETED) or, if it slipped through,
|
|
228
|
+
// mismatch this SHA.
|
|
229
|
+
assertEquals(content.size.toLong(), File(partial).length())
|
|
230
|
+
assertEquals(sha256(content), sha256(File(partial)))
|
|
231
|
+
|
|
232
|
+
// The committed-prefix segments (whose `.segN` we deleted) must NOT have been
|
|
233
|
+
// re-fetched: the only request the server should ever see is the probe
|
|
234
|
+
// (bytes=0-0). Any real segment fetch here would prove the committed prefix
|
|
235
|
+
// was wrongly treated as missing (a from-disk restart, the §4 cardinal sin).
|
|
236
|
+
val PROBE = 0L to 0L
|
|
237
|
+
val segmentRequests = fs.requestedRanges.toList().filterNot { it == PROBE }
|
|
238
|
+
assertTrue(
|
|
239
|
+
"no segment may be re-fetched when all are on disk / committed (got $segmentRequests)",
|
|
240
|
+
segmentRequests.isEmpty(),
|
|
241
|
+
)
|
|
242
|
+
// Concat consumed every remaining segment on success. (The `.partial` is the
|
|
243
|
+
// assembled result the caller promotes — it is NOT wiped on COMPLETED; only
|
|
244
|
+
// the `.segN` inputs are consumed. Its full-length correctness is already
|
|
245
|
+
// asserted above via the size + SHA-256 checks.)
|
|
246
|
+
for (i in 0 until 8) {
|
|
247
|
+
assertFalse(".seg$i must be consumed on success", segFile(partial, i).exists())
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|