@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.
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,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
+ }
@@ -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
+ }