feat: OCDS v1.1 conformance — concurrent downloader (iOS + Android) + standard#70
Merged
Conversation
Normative, platform-agnostic standard for the concurrent multi-range downloader (iOS/Android/Desktop). Defines the segment-file data model, permanent-vs-transient failure classification, and the resume / retry / integrity / finalization / fallback / termination requirements, plus a trust boundary and conformance scenarios. README links to it.
iOS (range-downloader core + bundle-update caller): - explicit typed permanent/transient failure class (no side-effect inference) - 429/5xx keep .segN — no data loss; in-place per-segment retry + stall watchdog + jittered backoff + Retry-After - per-hop HTTPS redirect guard; multipart/byteranges + total rejection - per-destination single-flight; monotonic progress floor - 416 transient with mandatory size re-evaluation Android (concurrent core + bundle-update caller): - wire CancelHandle from the bundle caller (cancel-then-delete) - exhaustive HTTP-status classification (416 transient); per-segment backoff - multipart/byteranges + total rejection; fsync before rename - per-destination single-flight All four platform classifiers verified consistent against OCDS v1.1. NOTE: nitrogen codegen regen still required to forward the typed outcome and tunable params across the JS bridge; iOS/Android need device/build validation.
…bridge
nitrogen regenerated RangeDownloadOutcome (completed|fallbackTransient|
fallbackPermanent), new RangeFallbackKind, RangeDownloadResult.fallbackKind,
and RangeDownloadParams.{maxSegmentAttempts,requestTimeoutSeconds,
stallTimeoutSeconds,overallDeadlineSeconds}. Shims now map the in-process
typed failure class onto the real wire cases (no legacy collapse) and forward
the tunable params JS->native. Removes the 'codegen regen required' deferral.
Compile-validated: iOS full-workspace xcodebuild BUILD SUCCEEDED; Android
gradle compileDebugKotlin BUILD SUCCESSFUL (both modules).
…§5.8) CancelHandle.cancel() called shutdownNow() (interrupt-only) and returned immediately; a worker blocked in a native write() could straggle past the caller's subsequent .segN delete and resurrect it. Add a bounded awaitTermination(3s) so cancel-then-delete is race-free. Verified: gradle compileDebugKotlin BUILD SUCCESSFUL.
Extract the pure status->permanent/transient decision into a module-visible isPermanentHttpStatus(code) (was buried inside classifyHttpFailure behind an okhttp Response), add junit test infra to the module, and add a matrix test: 401/403/404/410/501/505/other-4xx/unknown -> permanent vs 408/429/416/other-5xx -> transient. Keeps the Kotlin classifier in lockstep with the JS taxonomy and guards the 416-must-be-transient regression. Verified: gradle testDebugUnitTest — 4 tests, 0 failures.
…r (MockWebServer) A Range-aware OkHttp MockWebServer FaultDispatcher harness drives the REAL ConcurrentRangeDownloader.download() over a real socket and asserts on recorded ranges + on-disk .segN/.partial + assembled SHA-256: T1 drop-segment→only-missing refetch · T2 full + resume(seeded segs) · T3 200→FALLBACK+wipe · T4 429/Retry-After→retry-in-place · T5 misaligned/over-long/short 206 → reject/clamp no corruption · T8 cancel→stop+artifacts · T10 stall→read-timeout→transient retry. T6 (whole-file SHA) and T11 (per-dest single-flight) are caller-level (n/a to the core); T9 (cross-restart budget) is the shared-JS layer. Adds mockwebserver testImplementation. 16/16 green via gradle testDebugUnitTest.
…ct pure logic)
Extracts the deterministic, dependency-free logic (planRanges, classifyStatus,
parseContentRange{Bounds,Total}, parseRetryAfterSeconds, backoffDelay,
calculateSHA256 + the RangeDownloadClass/RangeFallbackClass enums) verbatim from
ReactNativeRangeDownloader.swift into ios/RangeDownloadLogic.swift (Foundation +
CommonCrypto only). Pure move — bodies byte-identical; the module delegates via
RangeDownloadLogic.<fn> and keeps the Nitro wire projections as extensions.
A SwiftPM test package (tests/swiftpm, source symlinked to the production file so
no copy can drift) runs 30 XCTests via `swift test`, covering OCDS §4
classification (incl. 416-transient / 501,505-permanent guard), §5.5 Content-Range
parsing, §5.2/§5.3 range planning, §5.4 Retry-After + jittered backoff, and SHA-256.
DEVICE-ONLY (documented in the test header, not faked): the concurrent BACKGROUND
URLSession download + stash/concat (T1/T2), resume across suspend/kill (T8), and
handleEventsForBackgroundURLSession delivery (T10) — they need a real device + the
app-latest.log checklist. Module compile verified (example pod build SUCCEEDED;
swiftc -parse clean). 30/30 green.
…ignore them The earlier 'git add tests' swept tests/swiftpm/.build/ (2298 ModuleCache .pcm binaries) into the commit. Remove from tracking and ignore the SwiftPM build dir; the test sources (Package.swift, symlinked source, tests) stay.
…OS) + iOS sim harness Adds conformance/ documenting how each platform is verified against the OCDS SPEC and housing the runnable code: - conformance/README.md: per-platform method/code/how-to-run table, the latest iOS on-simulator §6 result (9/11 pass, #6 deviation, #8/#11 need a shim), and a 're-test after a code change' guide. - conformance/ios-simulator/: the end-to-end harness (HTTP fault server, simctl driver, per-scenario capture, multi-agent verify workflow) + a README with the temporary local-verify patches needed to drive a Release .app from localhost. Android JVM tests (android/src/test) and iOS SwiftPM tests (tests/swiftpm) already live in this module and are referenced; the Node e2e suite lives in the app-monorepo desktop module and is pointed to. Action item recorded: confirm whether iOS #6 (no single-stream retry-once after a corrupted concurrent assembly) is intentional.
…sim harness Make explicit what is real (the app's native downloader, the silent auto-download trigger, segmentation/resume/retry/fallback/SHA-256) vs synthetic (the localhost update server, manifest, version, and the 16MB filler payload), and that the post-download unzip/signature-verify/install chain is NOT exercised (the synthetic payload fails verifyASC after a successful download — expected, out of OCDS scope), plus how to extend coverage with a real signed test bundle.
A whole-file checksum/signature mismatch after assembly is now simply Permanent -> discard + terminal failure (no automatic re-download of the same object). Re-fetching the same CDN object would re-corrupt, so the retry added cost with no integrity benefit; all three reference implementations (iOS/Android/Node) already terminate on mismatch, confirming it was over-specified. - SPEC.md: bump to 1.2; rewrite the §4 failure-table row + §6 scenario 6; changelog. - conformance/README.md: #6 is now PASS (conformant), not a deviation; resolve the action item. - ios-simulator workflow: judge #6 as conformant under 1.2 (terminating without single-stream retry is expected).
…total/read-only-fs) 5 new JVM tests (9 cases) driving the real ConcurrentRangeDownloader via the MockWebServer RangeFaultServer, arming the previously-dead fault modes: - OcdsTransient5xxTest: 503 transient retry-in-place + budget-exhaustion-throws + 501-permanent-bypass (#4) - Ocds416ResumeTest: 416-on-resume is transient, never discards seeded segments (#4/#6) - OcdsMultipartRejectTest: multipart/byteranges body -> FALLBACK + wipe (#5) - OcdsBadTotalRejectTest: disagreeing Content-Range total -> FALLBACK + wipe (#5) - OcdsReadOnlyFsTest: read-only/near-full FS -> clean fail, no corrupt artifact, idempotent re-concat (the segment-file design's raison d'etre, §5.2) Full module suite green (25 tests). 1/9 mutation-proven so far (503->permanent); the rest are green characterization guards pending mutation verification. Adapter layer (single-flight/progress-CAS/cancel) stays uncovered — needs Robolectric.
OcdsBadTotalRejectTest referenced RangeFaultServer.FaultMode.BAD_TOTAL_206 but the mode was never added (the coverage workflow's harness step didn't persist to source), so the test suite did not compile. Add the enum value + a dispatcher branch that serves the correct window/body with a Content-Range total that disagrees with the probe total, so the core's total-mismatch check is exercised. Full module unit suite now compiles and passes.
…verification - Android: 9 new tests (5xx/416/multipart/bad-total/read-only-fs), 6 mutation-proven load-bearing; 2 (never-discards, read-only-fs) green but only break under insertion-type regressions; adapter layer needs Robolectric. - Node: 3 new tests (416 concurrent+single-stream, concurrent->single-stream handoff), all 3 mutation-proven. - Document why true cross-restart resume (#2) and #9 give-up budget are out of the downloader test layer, and fix the Android run command (example-host gradle).
…ng extraction The Nitro adapter (HybridReactNativeRangeDownloaderSpec / JNI) can't be JVM-unit- tested directly. Extract its dependency-free pieces verbatim into a new RangeDownloadLogic.kt (same package/source set, no build.gradle change) and have the adapter DELEGATE — a pure move (adapter diff +11/-21, all delegation): - RunRegistry: the channel|taskId single-flight registry (identity-checked finish preserved) - progressPercent: the total>0 clamp/guard math feeding the (unchanged) CAS gate - sweepPartialArtifacts + runKey: the .segN/.partial artifact sweep 39 new pure-JVM tests (RangeDownloadLogic/RunRegistry/MonotonicProgressGate/ SegmentArtifactSweep); full module suite green at 64 tests. 3 mutants planted + killed (sweep glob-narrow, registry identity-drop, progressPercent clamp-removal). No download/network/file behavior changed — proven by the pre-existing OCDS/HTTP/ Smoke suite (which drives the real paths) staying green.
… extraction The adapter's single-flight / progress-clamp / artifact-sweep logic was extracted to RangeDownloadLogic.kt and unit-tested (39 tests, 3 mutation-proven). Update the coverage doc: the adapter layer is no longer 'needs Robolectric' — only the CAS primitive + JNI/Promise boundary remain genuinely not JVM-unit-testable.
…ght workers (OCDS §5.8) The APK concurrent download invoked ConcurrentRangeDownloader.download(url, path) with NO cancelHandle, so clearCache/clearApkCache deleted the .segN files while the 8 worker threads were still streaming into them — the cancel-then-delete resurrection race OCDS §5.8 forbids (a live worker re-creates a just-deleted segment / writes to a deleted FD → corruption / EROFS). The JS-bundle caller fixed this during OCDS; the APK caller never adopted the machinery. Mirror the bundle wiring: create a CancelHandle, store it in activeDownload, pass it to .download(...), clear it in the finally, and cancel() (shutdownNow + awaitTermination) at the top of wipeApkCacheFiles — the single sweep both external cleanup paths (clearCache / clearApkCache) call — BEFORE any .segN is deleted. wipeApkCacheFiles is never invoked mid-download, so this only cancels a genuinely-orphaned run. Compiles clean. Found by auditing the APK path against OCDS (it shares the conformant core but its ~1346-line caller was never audited and has zero tests).
…nally cancelled Completes the prior cancel-wiring fix: now that clearCache/clearApkCache abort the in-flight core workers, that abort surfaces in downloadAPK's catch as an exception. Without suppression it emitted a spurious update/error for an intentional cancel. Hoist cancelHandle so the catch can detect cancelHandle.aborted and skip update/error on an intentional cancel (mirrors react-native-bundle-update's intentionallyCancelled guard). Compiles clean.
…rely deferred (offline) OCDS audit BUG #2b. When SHA256SUMS.asc is temporarily unreachable (device offline) verifyExistingApk/tryPromoteAndVerify return Indeterminate/Deferred and throw ApkVerificationDeferredException, which the downloadAPK catch surfaced as update/error on every retry — a benign 'verify later, bytes preserved' condition mis-reported as a download error with no native bound. Suppress update/error for ApkVerificationDeferredException (type-based, not string match), alongside the intentional-cancel suppression; downgrade its log to info. The Promise still rejects (throw e preserved) so the awaited JS caller (auto-update/index.native.ts:119) still retries on the next online pass, and the byte-preservation (rollback to .partial) is untouched — only the spurious user-facing error event is withheld. Compiles clean.
…overage APK updater The APK updater (HybridObject/JNI) can't be JVM-instantiated and had ZERO tests. Extract its dependency-free pure pieces verbatim into AppUpdateLogic.kt (segment- name template + CONCURRENT_SEGMENT_COUNT, 416 total parse + is416Complete, 206 Content-Range parse + is206StartAligned) and have the adapter DELEGATE (pure move, diff 23/23). Add junit testImplementation + 17 JVM unit tests (segment names, 206/416 parse + whitespace tolerance, completeness/alignment predicates, segmentCount==8); all 10 planted mutants killed (mutation-proven load-bearing). Also wire the adapter's inline 206-Content-Range parse through the new parse206ContentRange (it was an identical inline duplicate — the helper was tested but unused, i.e. coverage theater). No download/file behavior changed (regexes byte-identical; File-I/O untouched). Behavior preservation rests on the pure-move + the new pins, since this module has no pre-existing suite. STILL device/Robolectric-only: the JNI download loop, segment deletion + §5.8 cancel race, promote/verify/install File-I/O.
…raction tests react-native-app-update shares the OCDS core but its ~1346-line caller was never audited and had zero tests. Audit found 4 issues; 3 fixed (#1 HIGH cancel-then- delete §5.8 race, #2a/#2b spurious update/error), pure logic extracted to AppUpdateLogic.kt + 17 mutation-proven JVM tests. #3/#4 recorded as backlog; JNI download/cancel/promote File-I/O noted as device/Robolectric-only.
- BUG #4: clarify verifyAPK does package-name + signing-cert verification (anti-tamper), not a whole-file SHA/GPG check against the manifest. - Add an explicit out-of-OCDS-scope note: the desktop full-installer (DMG/exe/AppImage) is downloaded by electron-updater, not the OCDS core; only the desktop OTA JS bundle is OCDS-conformant.
…g them verifyExistingApk's catch-all mapped every exception to Indeterminate, which flows to ApkVerificationDeferredException and is suppressed from update/error. A genuine local fault (corrupt APK, path/security rejection, runtime error) was therefore mis-reported as a benign 'verify later' and retried until the JS give-up budget was spent. Narrow the deferral to IOException (offline / disk-I/O transient) only; rethrow other exceptions so they surface to the caller. Verified: app-update module unit tests green.
Contributor
Author
OCDS conformance — adversarial review summary (native: iOS + Android)Independent multi-agent audit of this PR against OCDS SPEC v1.2, with each high-severity finding re-checked by a separate skeptic. No blockers — every download path has a SHA256/signature backstop, so a corrupt artifact cannot be installed. Coverage was independently confirmed real (Swift 30/30, Android range 12 suites + APK 17, mutation-proven by injecting regressions). Landed in this PR (just pushed)
Confirmed correct
Backlog — real but accepted (not fixed here)
Full report available on request. |
ByteZhang1024
approved these changes
Jun 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Defines the OneKey Concurrent Download Standard (OCDS v1.1) and brings the iOS + Android concurrent (multi-range) downloaders into conformance.
Standard
native-modules/react-native-range-downloader/SPEC.md— normative, platform-agnostic standard: segment-file data model, permanent-vs-transient failure classification, resume / retry / integrity / finalization / fallback / termination, trust boundary, conformance scenarios.iOS (range-downloader core + bundle-update caller)
RangeDownloadOutcome+RangeFallbackKind+ tunable params).segN— no data loss; in-place per-segment retry + stall watchdog + jittered backoff +Retry-AfterAndroid (concurrent core + bundle-update caller)
CancelHandlefrom the bundle caller (cancel-then-delete)Validation
xcodebuildBUILD SUCCEEDED; Androidgradle compileDebugKotlinBUILD SUCCESSFUL (both modules)Follow-ups (not in this PR)
shutdownNow+awaitTermination(small resurrection window)Pairs with the app-monorepo PR (desktop + shared orchestration).
✅ Verification record
How every platform's downloader is verified against the standard, and how each
test was proven load-bearing (a green test that survives its own regression
proves nothing). Full methodology + runnable harness:
react-native-range-downloader/conformance/.Approach
flowchart TD SPEC["OCDS SPEC v1.2<br/>§6 — 11 conformance scenarios"] SPEC --> AND["Android<br/>pure-JVM + OkHttp MockWebServer<br/>real fault injection"] SPEC --> NODE["Node / Desktop<br/>jest + real local HTTP/HTTPS server"] SPEC --> IOSU["iOS · unit<br/>SwiftPM over the real RangeDownloadLogic"] SPEC --> IOSE["iOS · end-to-end<br/>real Release .app on Simulator<br/>+ local fault server (silent auto-download)"] AND --> MUT["Mutation verification<br/>inject the regression each test targets<br/>→ the test MUST go red"] NODE --> MUT IOSE --> EVID["evidence: server log + app-latest.log<br/>+ on-disk .segN + final SHA"] EVID --> ADV["adversarial verify<br/>(default FAIL unless 3 sources agree)"] MUT --> OK(["load-bearing ✔"]) ADV --> OKSPEC §6 coverage matrix
200to a Range → permanent → single-stream416/5xxtransient (re-evaluate, retry)206download()→ single-flight¹ resume tested via a persisted-manifest proxy — a true SIGKILL-mid-write isn't reproducible in-process; covered by the manifest-resume + intra-call-resume tests.
² Android progress: the clamp/guard math is unit-tested, but the monotonic CAS primitive stays inline in the JNI adapter (not unit-testable).
³ iOS #8/#11 need a temp shim (the iOS app has no cancel trigger at all — #8 would test dead code); both behaviors are fully covered on Android + Node.
⁴ Node #9 give-up budget lives in the shared kit caller (
useAppUpdate.test.ts), not the downloader.Test counts & strength
.segN, real SHA).progress.json.appon a booted SimulatorMutants planted & killed (load-bearing proof)
flowchart LR subgraph Android a1["503 → permanent"] --> ka(["🔴 caught"]) a2["416 → permanent"] --> ka a3["bypass multipart"] --> ka a4["drop total check"] --> ka a5["501 → transient"] --> ka a6["retry budget +2"] --> ka a7["registry identity-drop"] --> ka a8["progress clamp removed"] --> ka a9["seg-glob narrowed"] --> ka end subgraph Node n1["416 → permanent"] --> kn(["🔴 caught"]) n2["break single-stream 416"] --> kn n3["isConcurrentFallback→false"] --> kn endEvery mutant produced a real test failure (not a compile error) and was reverted.
Honesty / scope
#6is now conformant everywhere.RangeDownloadLogic.kt, iOSRangeDownloadLogic.swift) are pure moves — proven by the pre-existing real-path suites staying green.🔩 Android APK updater — audit + fixes
The Android APK download path (
react-native-app-update) shares the OCDS core, but its ~1346-line caller was never audited and had zero tests. An adversarial audit found 4 issues.Issues
213acf0f3)cancelHandle→clearCachedeleted.segNwhile 8 workers were still writing. WiredCancelHandle+ cancel-before-delete (mirrors the bundle caller).24d5973cb)update/errorwhen the run was intentionally cancelled.011c24a58)erroron every retry; suppressed (the Promise still rejects → JS still retries).Coverage (
b9caf71f9)Extracted the dependency-free pure logic into
AppUpdateLogic.kt(segment names + count, 416/206Content-Rangeparse, completeness/alignment predicates); the adapter now delegates (pure move). Added junit + 17 JVM unit tests, with all 10 planted mutants killed (mutation-proven). Also wired a previously-untested inline 206-parse duplicate through the helper.Honesty / scope
Still device/Robolectric-only: the JNI download loop, the actual
.segNdeletion + §5.8 cancel-race execution, and the promote/verify/install File-I/O are not exercised by these JVM tests.