diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 92b4b7eb..45195c99 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.61): + - AesCrypto (3.0.67): - boost - DoubleConversion - fast_float @@ -27,7 +27,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AsyncStorage (3.0.61): + - AsyncStorage (3.0.67): - boost - DoubleConversion - fast_float @@ -55,7 +55,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AutoSizeInput (3.0.61): + - AutoSizeInput (3.0.67): - boost - DoubleConversion - fast_float @@ -85,7 +85,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (3.0.61): + - BackgroundThread (3.0.67): - boost - DoubleConversion - fast_float @@ -115,7 +115,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - ChartWebview (3.0.61): + - ChartWebview (3.0.67): - boost - DoubleConversion - fast_float @@ -146,7 +146,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - CloudFs (3.0.61): + - CloudFs (3.0.67): - boost - DoubleConversion - fast_float @@ -174,7 +174,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - CloudKitModule (3.0.61): + - CloudKitModule (3.0.67): - boost - DoubleConversion - fast_float @@ -208,7 +208,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.61): + - DnsLookup (3.0.67): - boost - DoubleConversion - fast_float @@ -244,7 +244,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (3.0.61): + - KeychainModule (3.0.67): - boost - DoubleConversion - fast_float @@ -278,7 +278,7 @@ PODS: - MMKV (2.2.4): - MMKVCore (~> 2.2.4) - MMKVCore (2.2.4) - - NetworkInfo (3.0.61): + - NetworkInfo (3.0.67): - boost - DoubleConversion - fast_float @@ -366,7 +366,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Pbkdf2 (3.0.61): + - Pbkdf2 (3.0.67): - boost - DoubleConversion - fast_float @@ -394,7 +394,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - PerpDepthBar (3.0.61): + - PerpDepthBar (3.0.67): - boost - DoubleConversion - fast_float @@ -424,7 +424,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Ping (3.0.61): + - Ping (3.0.67): - boost - DoubleConversion - fast_float @@ -2336,7 +2336,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (3.0.61): + - react-native-pager-view (3.0.67): - boost - DoubleConversion - fast_float @@ -2451,7 +2451,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (3.0.61): + - react-native-tab-view (3.0.67): - boost - DoubleConversion - fast_float @@ -2469,7 +2469,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.61) + - react-native-tab-view/common (= 3.0.67) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2480,7 +2480,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (3.0.61): + - react-native-tab-view/common (3.0.67): - boost - DoubleConversion - fast_float @@ -3065,7 +3065,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (3.0.61): + - ReactNativeAppUpdate (3.0.67): - boost - DoubleConversion - fast_float @@ -3096,7 +3096,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleCrypto (3.0.61): + - ReactNativeBundleCrypto (3.0.67): - boost - DoubleConversion - fast_float @@ -3127,7 +3127,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (3.0.61): + - ReactNativeBundleUpdate (3.0.67): - boost - DoubleConversion - fast_float @@ -3162,7 +3162,7 @@ PODS: - SocketRocket - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.61): + - ReactNativeCheckBiometricAuthChanged (3.0.67): - boost - DoubleConversion - fast_float @@ -3193,7 +3193,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (3.0.61): + - ReactNativeDeviceUtils (3.0.67): - boost - DoubleConversion - fast_float @@ -3224,7 +3224,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (3.0.61): + - ReactNativeGetRandomValues (3.0.67): - boost - DoubleConversion - fast_float @@ -3255,7 +3255,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (3.0.61): + - ReactNativeLiteCard (3.0.67): - boost - DoubleConversion - fast_float @@ -3284,7 +3284,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (3.0.61): + - ReactNativeNativeLogger (3.0.67): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3315,7 +3315,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (3.0.61): + - ReactNativePerfMemory (3.0.67): - boost - DoubleConversion - fast_float @@ -3346,7 +3346,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativePerfStats (3.0.61): + - ReactNativePerfStats (3.0.67): - boost - DoubleConversion - fast_float @@ -3377,7 +3377,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.61): + - ReactNativeRangeDownloader (3.0.67): - boost - DoubleConversion - fast_float @@ -3408,7 +3408,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (3.0.61): + - ReactNativeSplashScreen (3.0.67): - boost - DoubleConversion - fast_float @@ -3439,7 +3439,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeZipArchive (3.0.61): + - ReactNativeZipArchive (3.0.67): - boost - DoubleConversion - fast_float @@ -3528,7 +3528,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (3.0.61): + - ScrollGuard (3.0.67): - boost - DoubleConversion - fast_float @@ -3558,7 +3558,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SegmentSlider (3.0.61): + - SegmentSlider (3.0.67): - boost - DoubleConversion - fast_float @@ -3588,7 +3588,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (3.0.61): + - Skeleton (3.0.67): - boost - DoubleConversion - fast_float @@ -3619,7 +3619,7 @@ PODS: - SocketRocket - Yoga - SocketRocket (0.7.1) - - SplitBundleLoader (3.0.61): + - SplitBundleLoader (3.0.67): - boost - DoubleConversion - fast_float @@ -3649,7 +3649,7 @@ PODS: - SocketRocket - Yoga - SSZipArchive (2.6.0) - - TcpSocket (3.0.61): + - TcpSocket (3.0.67): - boost - DoubleConversion - fast_float @@ -4037,31 +4037,31 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 5d79d012eda0c688e81fb1e5efb66697a8bf63b2 - AsyncStorage: d36fdf626a5daea9ab7ddf147743f91cb8fc47b2 - AutoSizeInput: 46d8edf6010443fdca7dc3a69bb44553780b1155 - BackgroundThread: 28c77551040ecc7a70dabf132b840f7b6b1cb7ef + AesCrypto: 59c2d9cbb92e42e1b8583009290e7bc6334cf334 + AsyncStorage: ca50c12aa6a0001156b763c73d36c1092461777b + AutoSizeInput: 922901d6d81c8590145cde1874f547be864b6155 + BackgroundThread: 3bef5865d0faa5ecb63a4c83429f555b045d1daf boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ChartWebview: df5cfa93f2fff737382f08041ba8034932266674 - CloudFs: 7d28ea2cc410fcae83fa573f4d5c313f3203352d - CloudKitModule: 9f6c25469fa12adbcf55b0f9ec9a26bb489978c6 + ChartWebview: 1dd32680a6dc68b85837cee8a1e9baf447c9b647 + CloudFs: f2f9e5cf4b83efd51c41ad51e57d752d1c7c0098 + CloudKitModule: 505062c38dac6df61e98dad8a7680e32688503e6 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 2b92e6ee2a0925934097334e7024214ee39f3fd0 + DnsLookup: 7740891737ed17e55b5daa69b44a53ee1c8950a0 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a - KeychainModule: 91e5f89dfcc8981beb4ee8ad1480eed76ff1d96a + KeychainModule: 3a44abae044f0afc2409fd6ec4e6c9d297596d0f MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df - NetworkInfo: aff4df0ccff078e448ad3da1c5620e7285f129fc + NetworkInfo: 2881c5ede983fb7540d07315e92b971729447104 NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - Pbkdf2: 723893ae9eaa320168c62cb825a4bcef89703894 - PerpDepthBar: ce30834568bd8b9ea933267437defb431c6c576d - Ping: 8f45505d4f773a5f6348852679e2801e1bf052ac + Pbkdf2: c4d44d0220dada426d4c728000433cd530c4bba3 + PerpDepthBar: 49ddaff3c6402c344bb78d07ecd9844b9d1af880 + Ping: ff93df87bfd728cc5ee31ffbf54767f7ddff35cd RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 @@ -4098,9 +4098,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: 5bde74d01c86137851dddc589a91566e5fa2a490 + react-native-pager-view: 969b570d4df41d518b3a0c5064411c9f84f2fb0d react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: 84fd866023d2753d09cdb1dd62cbe086fb39ccc7 + react-native-tab-view: 7c7102194026c5cf6f90d506afe07efa3f8a9fff React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -4134,27 +4134,27 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: d4931ea70ba02c29bec1a3db68ef1b2d0b39f2c2 - ReactNativeBundleCrypto: 9b44893be06c117be94f9d5eb7f2666c0b4ee731 - ReactNativeBundleUpdate: 0a352e1fe87dfeefea7b7e477834c6830900500d - ReactNativeCheckBiometricAuthChanged: a422d1c4e87f5de024409e8c6b97469bebe271ec - ReactNativeDeviceUtils: f5b15156b5864fc43ba71b3ec09ad9ec3111e9bb - ReactNativeGetRandomValues: 94a7f6bc3fcb91991d6aaa574d4ad0c140ff5285 - ReactNativeLiteCard: 663a9d5772550d83de6aa79282c9c3fc5410fd15 - ReactNativeNativeLogger: 6337f2f51c7f7c62a8575764799a74d22ffec372 - ReactNativePerfMemory: b0d2ac23e8bcd2e07418efe92a3e19379666bb2e - ReactNativePerfStats: bdff04f45bbd161e0e3c907d1bb36e8ddb7410c0 - ReactNativeRangeDownloader: 73efe1ec206257b2f425d58e7e238f45221ae707 - ReactNativeSplashScreen: 6223f61235b36f12f468e369064416854608aa75 - ReactNativeZipArchive: bdf08f7ee00352d2b2894449c8b1b6810f40f2b2 + ReactNativeAppUpdate: a8b8a599de1b75ddee435db8819fd3d846526dd9 + ReactNativeBundleCrypto: 040f2824b49eefdf6e8568b612cafdbd12a99f22 + ReactNativeBundleUpdate: 47c1720c0b5ef3f63dc1e2855b41825024e41799 + ReactNativeCheckBiometricAuthChanged: 461a0984bcc4c0b85db5aabc000a28edcd2a4d4c + ReactNativeDeviceUtils: 77d51784f92659c20e8134561625f591a772eb0e + ReactNativeGetRandomValues: e492be2cd6e3d5f4ad8582730596e7f2a1df75f8 + ReactNativeLiteCard: dec11c240dc327d0b85e3ff8aeca59c4be4da59d + ReactNativeNativeLogger: 008cd55912829c60bf85b5e52ff15d519e1fd210 + ReactNativePerfMemory: a21938afb8161413fea294fd8129a4b28da50d68 + ReactNativePerfStats: 200eb55b45ddb0ec4c23c7f0d55f76ade1c27b30 + ReactNativeRangeDownloader: 9372ed9319f42008f0570d0d0fa29b3c66d0a478 + ReactNativeSplashScreen: 00ee4d0da1f8eab079ff3eff5dc8d46bbbde7fb8 + ReactNativeZipArchive: 145faa8bf413ce7d45a74fac21e02a6566d84012 RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: 61f6774ecb5ac6ccd5fbeb34830a117ab3e2d4f0 - SegmentSlider: 295a0ade25855058c11d2b185ad13762b1f4a166 - Skeleton: 07da4b794e0e2b58bd75ba820d0c2e6e87c9225e + ScrollGuard: 9636f872bd88220f7631301698a1b3110d062914 + SegmentSlider: 07c1081fd70e173ba36de9194dd6c1b6e147c513 + Skeleton: a5ac4c29c713e591d237872f27d46faeb06aa556 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - SplitBundleLoader: 8421ede8cbc7f15a7608b59b126fcfe22aca5ccc + SplitBundleLoader: 120852ff0a02c3cb529ef1c722c40c06ae9865d0 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea - TcpSocket: bd4063e4dda0fb7f6952376592e3efdeac915710 + TcpSocket: d9070521064fa987f6e348d4e94b74c37b81e08b Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e PODFILE CHECKSUM: 11e5274bb8ec6380d5fbde829e700fe4f0cf9c8a diff --git a/native-modules/react-native-app-update/android/build.gradle b/native-modules/react-native-app-update/android/build.gradle index 517459d8..189c813f 100644 --- a/native-modules/react-native-app-update/android/build.gradle +++ b/native-modules/react-native-app-update/android/build.gradle @@ -153,4 +153,9 @@ dependencies { // MMKV for reading DevSettings (compileOnly: provided by the host app via react-native-mmkv) compileOnly "io.github.zhongwuzw:mmkv:2.2.4" + + // Pure-logic unit tests (AppUpdateLogic) run on the local JVM via + // :onekeyfe_react-native-app-update:testDebugUnitTest. No Robolectric: the + // extracted logic is dependency-free (no Context / Nitro / OkHttp). + testImplementation "junit:junit:4.13.2" } diff --git a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogic.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogic.kt new file mode 100644 index 00000000..e9b78abc --- /dev/null +++ b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogic.kt @@ -0,0 +1,85 @@ +package com.margelo.nitro.reactnativeappupdate + +/** + * Dependency-free pure logic extracted from ReactNativeAppUpdate. + * + * This object intentionally pulls in NO Android / Nitro / OkHttp / File-I/O + * dependencies: it holds only constants and pure string-derivation helpers so + * the values can be unit-tested under plain JVM JUnit (the adapter itself + * cannot, because it needs a live Context / FileProvider / network). + */ +object AppUpdateLogic { + // Must match ConcurrentRangeDownloader's default segmentCount: the + // concurrent downloader writes sibling segment files + // ".seg0"..".seg${N-1}", and Phase 2 below scans this + // range to detect an in-flight concurrent download. + const val CONCURRENT_SEGMENT_COUNT = 8 + + /** + * Derive the sibling segment-file name the concurrent downloader writes for + * a given partial path and segment index, i.e. ".seg". + * This MUST match exactly what ConcurrentRangeDownloader writes. + */ + fun segmentFileName(partialFilePath: String, index: Int): String = + "$partialFilePath.seg$index" + + /** + * All [CONCURRENT_SEGMENT_COUNT] segment-file names for a given partial + * path, ordered seg0..seg${N-1}. + */ + fun segmentFileNames(partialFilePath: String): List = + (0 until CONCURRENT_SEGMENT_COUNT).map { segmentFileName(partialFilePath, it) } + + /** + * Parse the total size out of a 416 (Range Not Satisfiable) + * `Content-Range: bytes * /` header. Returns null when the header is + * absent or is not the "unsatisfiable range" form (e.g. a normal + * `bytes start-end/total`). + * + * Regex body copied VERBATIM from ReactNativeAppUpdate.downloadAPK (the 416 + * branch); wrapped here with no behavior change so the parse step is pure + * and unit-testable. The adapter's `total == partialBytes` comparison stays + * in the adapter (it needs runtime `partialBytes`); see [is416Complete]. + */ + fun parse416Total(contentRange: String?): Long? = + contentRange + ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } + + /** + * Parse a 206 `Content-Range: bytes start-end/total` header into + * (start, end, total). `total` is null when the server sends `*` + * (unknown total). Returns null when the header is absent or malformed. + * + * `rangeRegex` body copied VERBATIM from ReactNativeAppUpdate.downloadAPK + * (the 206 sanity-check). The adapter's `start != partialBytes` + * CDN-misalignment guard stays in the adapter; see [is206StartAligned]. + */ + fun parse206ContentRange(contentRange: String?): Triple? { + val match = contentRange + ?.let { Regex("""bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)""").find(it) } + ?: return null + val start = match.groupValues.getOrNull(1)?.toLongOrNull() + val end = match.groupValues.getOrNull(2)?.toLongOrNull() + // group 3 is either digits or literal "*"; "*" → unknown total → null. + val total = match.groupValues.getOrNull(3)?.toLongOrNull() + return Triple(start, end, total) + } + + /** + * 416 recovery predicate: the server-reported total exactly equals the + * bytes we already have on disk, meaning our `.partial` IS the whole APK + * and just needs verify + rename rather than a wipe. Pure mirror of the + * adapter's `totalFromHeader != null && totalFromHeader == partialBytes`. + */ + fun is416Complete(total: Long?, partialBytes: Long): Boolean = + total != null && total == partialBytes + + /** + * 206 start-alignment guard: the server's body starts exactly where we + * asked it to resume from. When false (CDN/proxy rewrote the range), the + * 206 body is a mis-aligned slice and must NOT be appended. Pure mirror of + * the adapter's `rangeStart != null && rangeStart == partialBytes` check. + */ + fun is206StartAligned(start: Long?, partialBytes: Long): Boolean = + start != null && start == partialBytes +} diff --git a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt index 65424b6e..0b492be9 100644 --- a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +++ b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt @@ -46,16 +46,23 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { private const val CHANNEL_ID = "updateApp" private const val NOTIFICATION_ID = 1 - // Must match ConcurrentRangeDownloader's default segmentCount: the - // concurrent downloader writes sibling segment files - // ".seg0"..".seg${N-1}", and Phase 2 below scans this - // range to detect an in-flight concurrent download. - private const val CONCURRENT_SEGMENT_COUNT = 8 + // Segment-count + segment-file naming live in AppUpdateLogic (pure, + // unit-testable). The concurrent downloader writes sibling segment + // files ".seg0"..".seg${N-1}", and Phase 2 below + // scans this range to detect an in-flight concurrent download. + private const val CONCURRENT_SEGMENT_COUNT = AppUpdateLogic.CONCURRENT_SEGMENT_COUNT } private val listeners = CopyOnWriteArrayList() private val nextListenerId = AtomicLong(1) private val isDownloading = AtomicBoolean(false) + // The in-flight concurrent download's cancel handle, so clearCache can stop + // its workers (shutdownNow + awaitTermination) BEFORE deleting .segN files. + // Without this a still-running worker resurrects a just-deleted segment / writes + // to a deleted FD — the cancel-then-delete race OCDS §5.8 forbids. Mirrors + // react-native-bundle-update's activeDownloads cancel wiring. + private val activeDownload = + java.util.concurrent.atomic.AtomicReference(null) // downloadThread removed: downloads use coroutine-based Promise.async, not raw threads private fun sendEvent(type: String, progress: Int = 0, message: String = "") { @@ -289,9 +296,22 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.warn("AppUpdate", "verifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...") ApkVerifyOutcome.HashMismatch } - } catch (e: Exception) { - OneKeyLog.warn("AppUpdate", "verifyExistingApk: unexpected failure: ${e.javaClass.simpleName}: ${e.message}") + } catch (e: java.io.IOException) { + // Offline / disk-I/O transient (ASC fetch or APK read): we genuinely + // cannot decide validity right now, so preserve the bytes and defer to + // the next online retry (Indeterminate → caller rolls back to .partial). + OneKeyLog.warn("AppUpdate", "verifyExistingApk: transient I/O failure, indeterminate: ${e.javaClass.simpleName}: ${e.message}") ApkVerifyOutcome.Indeterminate + } catch (e: Exception) { + // A non-I/O failure (corrupt local state, path/security rejection, + // runtime fault) is NOT a "can't tell because offline" case — it is a + // genuine local error. Do NOT collapse it into a benign Deferred verify: + // downloadAPK's catch suppresses ApkVerificationDeferredException from + // `update/error`, which would silently hide this real failure and keep + // retrying a permanently-bad file until the JS budget is spent. Rethrow + // so it surfaces to the caller as an error. + OneKeyLog.error("AppUpdate", "verifyExistingApk: non-I/O failure, surfacing: ${e.javaClass.simpleName}: ${e.message}") + throw e } } @@ -440,6 +460,9 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { throw Exception("Download already in progress") } + // Hoisted so the catch can tell an intentional clearCache-cancel from a + // real failure (a cancel must NOT surface as an `update/error`). + var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null try { val url = params.downloadUrl val filePath = filePathFromUrl(url) @@ -510,7 +533,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // concurrent run could otherwise be picked up below and reused — // wipe them so this restarts cleanly from byte zero. if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() } expectedSize > 0 && existingSize < expectedSize -> { OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume") @@ -522,7 +545,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // concat committed-cursor, risking a mixed file. With no .segN, // Phase 2 takes the single-stream size-based path and Range-resumes // from the partial's length. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() if (!downloadedFile.renameTo(partialFile)) { OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final") downloadedFile.delete() @@ -542,7 +565,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // Final was stale → start from byte zero. Wipe any // sibling .segN left by an earlier concurrent run so // it can't be mistaken for trustworthy in-flight bytes. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() } ApkVerifyOutcome.Indeterminate -> { // ASC could not be fetched (offline) or could @@ -573,7 +596,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // path must match exactly what ConcurrentRangeDownloader writes: // File("$partialFilePath.seg$i"). val hasConcurrentSegments = - (0 until CONCURRENT_SEGMENT_COUNT).any { buildFile("$partialFilePath.seg$it").exists() } + (0 until CONCURRENT_SEGMENT_COUNT).any { buildFile(AppUpdateLogic.segmentFileName(partialFilePath, it)).exists() } var partialBytes = 0L if (partialFile.exists() && !hasConcurrentSegments) { val partialSize = partialFile.length() @@ -608,7 +631,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.warn("AppUpdate", "downloadAPK: stale partial (>expected $partialSize/$expectedSize), discarding") partialFile.delete() // Partial bytes are untrustworthy → restart from zero; drop any sibling .segN too. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() } partialSize > 0 -> { partialBytes = partialSize @@ -643,6 +666,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // the thread that actually moved the percent forward proceeds. val concurrentProgress = AtomicInteger(-1) val notifyLock = Any() + // Wire a cancel handle so clearCache can stop these workers + // before deleting .segN (OCDS §5.8). Cleared in the finally below. + cancelHandle = ConcurrentRangeDownloader.CancelHandle() + activeDownload.set(cancelHandle) val concurrentOutcome = ConcurrentRangeDownloader( httpClient = concurrentClient, log = { msg -> OneKeyLog.info("AppUpdate", msg) }, @@ -653,7 +680,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // files to the read-only root fs → EROFS. Resolve it to // the real apks dir (filesDir/apks) first, exactly like // react-native-bundle-update passes an absolute path. - ).download(url, partialFile.absolutePath) { transferred, total -> + ).download(url, partialFile.absolutePath, cancelHandle) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) // Only the thread that advances the percent emits; a @@ -724,9 +751,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { if (response.code == 416) { val contentRange = response.header("Content-Range") response.close() - val totalFromHeader = contentRange - ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) { + val totalFromHeader = AppUpdateLogic.parse416Total(contentRange) + if (AppUpdateLogic.is416Complete(totalFromHeader, partialBytes) && partialFile.exists()) { OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 416 with total=$totalFromHeader matches partial, attempting promote+verify") when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) { PromoteOutcome.Valid -> { @@ -743,7 +769,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery hash mismatch — server build changed mid-download") if (partialFile.exists()) partialFile.delete() // Server build changed → these bytes are worthless; drop sibling .segN too. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() throw java.io.IOException("Server build changed mid-download (size matches but hash differs)") } PromoteOutcome.Deferred -> { @@ -761,7 +787,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.warn("AppUpdate", "downloadAPK: HTTP 416 (range not satisfiable), discarding partial and failing attempt") if (partialFile.exists()) partialFile.delete() // Partial discarded as unusable → drop sibling .segN too so the next attempt starts clean. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() throw Exception("HTTP 416 (range not satisfiable)") } @@ -781,7 +807,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.warn("AppUpdate", "downloadAPK: requested Range but server returned 200, restarting from scratch") if (partialFile.exists()) partialFile.delete() // Restarting from byte zero → drop any sibling .segN too. - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() partialBytes = 0L } @@ -791,13 +817,14 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // be appending mis-aligned bytes and only catching it later // at the SHA step — with the partial now corrupted. Demote to // a full restart instead. - val rangeRegex = Regex("""bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)""") - val rangeMatch = if (serverWillResume) { - response.header("Content-Range")?.let { rangeRegex.find(it) } + // Parse the 206 Content-Range via the unit-tested helper (was an + // inline duplicate of AppUpdateLogic.parse206ContentRange's regex). + val parsed206 = if (serverWillResume) { + AppUpdateLogic.parse206ContentRange(response.header("Content-Range")) } else null if (serverWillResume) { - val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull() - if (rangeStart == null || rangeStart != partialBytes) { + val rangeStart = parsed206?.first + if (!AppUpdateLogic.is206StartAligned(rangeStart, partialBytes)) { // This 206 body is a slice starting at the wrong offset (CDN bug / // proxy rewrite). It is NOT a full file, so we must not consume it as // one — doing so would write mis-aligned bytes (caught only later at @@ -809,7 +836,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { val contentRangeHeader = response.header("Content-Range") OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarding partial and retrying from scratch") if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete() response.close() throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch") } @@ -821,7 +848,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { } val contentLength = body.contentLength() val totalSize: Long = if (serverWillResume) { - val parsedTotal = rangeMatch?.groupValues?.getOrNull(3)?.toLongOrNull() + val parsedTotal = parsed206?.third parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L)) } else { if (contentLength > 0) contentLength else expectedSize @@ -898,10 +925,37 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { notifyManager.notify(NOTIFICATION_ID, builder.build()) } } catch (e: Exception) { - OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}") - sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}") + // Two conditions reach this catch that are NOT download failures and + // must therefore be suppressed from `update/error`: + // + // 1. A clearCache/clearApkCache cancel aborts the core workers, + // which surfaces here as an exception — intentional, not a + // failure (mirrors react-native-bundle-update). + // 2. A deferred verification (ApkVerificationDeferredException): + // the detached ASC was temporarily unreachable, so we couldn't + // decide whether the on-disk bytes are still the right APK. The + // bytes are preserved (rolled back to .partial) for the next + // online retry; this is a "retry later", not a download error. + // Detect it by TYPE, never by message string — the message is a + // constant today but type-matching can't silently rot. + // + // Both still rethrow so the JS Promise rejects (the caller learns the + // attempt didn't complete); only the spurious `update/error` event is + // withheld. For the deferred case we also downgrade the log to info so + // logs don't read like a failure. + val intentionallyCancelled = cancelHandle?.aborted?.get() == true + val verificationDeferred = e is ApkVerificationDeferredException + if (verificationDeferred) { + OneKeyLog.info("AppUpdate", "downloadAPK: verification deferred (ASC unavailable); bytes preserved for next online retry: ${e.message}") + } else { + OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}") + } + if (!intentionallyCancelled && !verificationDeferred) { + sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}") + } throw e } finally { + activeDownload.set(null) isDownloading.set(false) } } @@ -1270,6 +1324,15 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ * log lines so the two callers stay distinguishable in logcat. */ private fun wipeApkCacheFiles(tag: String, protectedPaths: Set = emptySet()) { + // OCDS §5.8: stop any in-flight concurrent download (shutdownNow + + // awaitTermination) BEFORE deleting its .segN, so a still-running worker + // cannot resurrect a just-deleted segment or write to a deleted FD. Only + // the two external cleanup paths (clearCache / clearApkCache) call this; + // it is never invoked mid-download. + activeDownload.getAndSet(null)?.let { + OneKeyLog.info("AppUpdate", "$tag: cancelling in-flight download before wipe") + it.cancel() + } val context = NitroModules.applicationContext if (context == null) { OneKeyLog.warn("AppUpdate", "$tag: application context unavailable, skipping file cleanup") diff --git a/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateContentRangeTest.kt b/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateContentRangeTest.kt new file mode 100644 index 00000000..411a98ed --- /dev/null +++ b/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateContentRangeTest.kt @@ -0,0 +1,164 @@ +package com.margelo.nitro.reactnativeappupdate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM coverage for the "Content-Range / 416 header parsing" unit extracted + * out of ReactNativeAppUpdate.downloadAPK into [AppUpdateLogic]. + * + * These exercise the REAL extracted code (regex literals copied character-for- + * character from the adapter — the 416 `bytes * /` branch and the 206 + * `bytes start-end/total` sanity check), never a re-implementation. The adapter + * keeps all File I/O / response.close / throw; only the pure string-parse and + * the two comparison predicates moved here, so this guards the parse step is + * byte-identical and the CDN-misalignment / 416-complete guards stay total. + */ +class AppUpdateContentRangeTest { + + // --------------------------------------------------------------------------- + // parse416Total — `Content-Range: bytes * /` (the 416 branch, line 742) + // --------------------------------------------------------------------------- + + @Test + fun parse416Total_extractsTotalFromUnsatisfiableRange() { + assertEquals(12345L, AppUpdateLogic.parse416Total("bytes */12345")) + } + + @Test + fun parse416Total_isTolerantOfWhitespaceAroundStarAndSlash() { + // The verbatim regex is `bytes\s+\*\s*/\s*(\d+)`, so spaces around `*` and + // `/` are all allowed. + assertEquals(99L, AppUpdateLogic.parse416Total("bytes * / 99")) + assertEquals(99L, AppUpdateLogic.parse416Total("bytes * /99")) + } + + @Test + fun parse416Total_rejectsNormalContentRange() { + // A satisfiable `bytes 0-1/2` is NOT the unsatisfiable form → no total. + assertNull(AppUpdateLogic.parse416Total("bytes 0-1/2")) + assertNull(AppUpdateLogic.parse416Total("bytes 100-199/500")) + } + + @Test + fun parse416Total_nullAndGarbageYieldNull() { + assertNull(AppUpdateLogic.parse416Total(null)) + assertNull(AppUpdateLogic.parse416Total("")) + assertNull(AppUpdateLogic.parse416Total("garbage")) + assertNull(AppUpdateLogic.parse416Total("bytes */")) // no digits after slash + } + + // --------------------------------------------------------------------------- + // parse206ContentRange — `bytes start-end/total` (the 206 sanity check, l.808) + // --------------------------------------------------------------------------- + + @Test + fun parse206ContentRange_parsesStartEndTotal() { + assertEquals( + Triple(100L, 199L, 500L), + AppUpdateLogic.parse206ContentRange("bytes 100-199/500"), + ) + } + + @Test + fun parse206ContentRange_starTotalYieldsNullTotal() { + // `total` is `*` (server doesn't know the full length) → total parses null, + // but start/end are still recovered. + assertEquals( + Triple(0L, 99L, null), + AppUpdateLogic.parse206ContentRange("bytes 0-99/*"), + ) + } + + @Test + fun parse206ContentRange_isTolerantOfWhitespace() { + // Verbatim regex `bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)`. + assertEquals( + Triple(100L, 199L, 500L), + AppUpdateLogic.parse206ContentRange("bytes 100 - 199 / 500"), + ) + } + + @Test + fun parse206ContentRange_garbageAndNullYieldNull() { + assertNull(AppUpdateLogic.parse206ContentRange("garbage")) + assertNull(AppUpdateLogic.parse206ContentRange(null)) + assertNull(AppUpdateLogic.parse206ContentRange("")) + // The 416 unsatisfiable form is not a valid 206 range → no match. + assertNull(AppUpdateLogic.parse206ContentRange("bytes */12345")) + } + + // --------------------------------------------------------------------------- + // is416Complete — the 416-recovery predicate (adapter comparison, line 743) + // --------------------------------------------------------------------------- + + @Test + fun is416Complete_trueOnlyWhenTotalEqualsPartial() { + // Server says the whole APK is exactly what we already have → recover, don't wipe. + assertTrue(AppUpdateLogic.is416Complete(99L, 99L)) + // Sizes differ → partial is stale/corrupt → not complete. + assertFalse(AppUpdateLogic.is416Complete(99L, 50L)) + assertFalse(AppUpdateLogic.is416Complete(50L, 99L)) + // No parseable total → cannot claim completeness. + assertFalse(AppUpdateLogic.is416Complete(null, 0L)) + assertFalse(AppUpdateLogic.is416Complete(null, 99L)) + } + + // --------------------------------------------------------------------------- + // is206StartAligned — the CDN-misalignment guard (adapter check, lines 814) + // --------------------------------------------------------------------------- + + @Test + fun is206StartAligned_trueOnlyWhenStartEqualsPartial() { + // 206 body starts exactly where we asked to resume → safe to append. + assertTrue(AppUpdateLogic.is206StartAligned(100L, 100L)) + // Start != requested offset (CDN/proxy rewrote the range) → mis-aligned slice. + assertFalse(AppUpdateLogic.is206StartAligned(100L, 50L)) + assertFalse(AppUpdateLogic.is206StartAligned(50L, 100L)) + // Missing/unparseable start → demote to full restart, never append. + assertFalse(AppUpdateLogic.is206StartAligned(null, 0L)) + assertFalse(AppUpdateLogic.is206StartAligned(null, 100L)) + } + + // --------------------------------------------------------------------------- + // End-to-end: parse → predicate, mirroring how the adapter chains them. + // --------------------------------------------------------------------------- + + @Test + fun parse416ThenIs416Complete_matchesAdapterRecoveryDecision() { + // (a) total == partialBytes → recover. + val total = AppUpdateLogic.parse416Total("bytes */2048") + assertTrue(AppUpdateLogic.is416Complete(total, 2048L)) + // (b) anything else → wipe. + assertFalse(AppUpdateLogic.is416Complete(total, 1024L)) + } + + @Test + fun parse206ThenIs206StartAligned_matchesAdapterStartCheck() { + val (start, _, _) = AppUpdateLogic.parse206ContentRange("bytes 4096-8191/8192")!! + assertTrue(AppUpdateLogic.is206StartAligned(start, 4096L)) + assertFalse(AppUpdateLogic.is206StartAligned(start, 0L)) + } + + // --------------------------------------------------------------------------- + // Sibling-segment invariant: segmentCount == 8 (the concurrent downloader's + // default that the 416/206 cleanup loops scan). Asserted over the REAL const. + // --------------------------------------------------------------------------- + + @Test + fun concurrentSegmentCountIsEight() { + assertEquals(8, AppUpdateLogic.CONCURRENT_SEGMENT_COUNT) + assertEquals(8, AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").size) + assertEquals( + "/tmp/app.apk.partial.seg0", + AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").first(), + ) + assertEquals( + "/tmp/app.apk.partial.seg7", + AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").last(), + ) + } +} diff --git a/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogicSegmentTest.kt b/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogicSegmentTest.kt new file mode 100644 index 00000000..50471014 --- /dev/null +++ b/native-modules/react-native-app-update/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogicSegmentTest.kt @@ -0,0 +1,88 @@ +package com.margelo.nitro.reactnativeappupdate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pin tests for the extracted pure unit: + * "CONCURRENT_SEGMENT_COUNT constant (segment-count + segment-path derivation)". + * + * These assert directly against the REAL extracted code in [AppUpdateLogic] + * (never a re-implementation): the byte-identical-move constant and the + * faithful capture of the inlined ".seg" string template that the + * adapter previously duplicated across 6 sites. + */ +class AppUpdateLogicSegmentTest { + + /** + * Pins the must-equal-core-default invariant. + * + * COUPLING NOTE: [AppUpdateLogic.CONCURRENT_SEGMENT_COUNT] must equal the + * shared `ConcurrentRangeDownloader`'s default `segmentCount = 8`. That core + * default is a PRIVATE constructor parameter default, not a public constant, + * so this test cannot reference it directly — it can only pin the documented + * magic number 8. If the core default ever changes, this hard-coded 8 is the + * tripwire that forces a human to re-sync both sides (otherwise the Phase-2 + * scan would miss in-flight segment files written past index 7, or scan a + * dead range). + */ + @Test + fun concurrentSegmentCountEqualsCoreDefaultEight() { + assertEquals(8, AppUpdateLogic.CONCURRENT_SEGMENT_COUNT) + } + + /** segmentFileName is the verbatim ".seg" template. */ + @Test + fun segmentFileNameAppendsDotSegIndex() { + assertEquals( + "x.apk.partial.seg0", + AppUpdateLogic.segmentFileName("x.apk.partial", 0), + ) + assertEquals( + "x.apk.partial.seg7", + AppUpdateLogic.segmentFileName("x.apk.partial", 7), + ) + // The helper is a dumb string template: it does not validate the index, so + // any integer flows straight through (mirrors the inlined call sites). + assertEquals( + "x.apk.partial.seg42", + AppUpdateLogic.segmentFileName("x.apk.partial", 42), + ) + } + + /** segmentFileNames returns exactly CONCURRENT_SEGMENT_COUNT names, seg0..seg7. */ + @Test + fun segmentFileNamesYieldsEightOrderedNamesSeg0ToSeg7() { + val names = AppUpdateLogic.segmentFileNames("x.apk.partial") + + assertEquals(AppUpdateLogic.CONCURRENT_SEGMENT_COUNT, names.size) + assertEquals(8, names.size) + assertEquals("x.apk.partial.seg0", names.first()) + assertEquals("x.apk.partial.seg7", names.last()) + } + + /** + * The generated names are distinct and strictly ordered seg0..seg${N-1}, + * each equal to the single-name helper for the same index. This guards the + * Phase-2 scan range: a duplicate or out-of-order name would make the + * in-flight-download detection scan the wrong sibling files. + */ + @Test + fun segmentFileNamesAreDistinctAndOrderedAndMatchSingleHelper() { + val partial = "/data/user/0/app/files/update.apk.partial" + val names = AppUpdateLogic.segmentFileNames(partial) + + // distinct + assertEquals(names.size, names.toSet().size) + + // ordered seg0..seg${N-1}, each consistent with segmentFileName(index) + for (i in 0 until AppUpdateLogic.CONCURRENT_SEGMENT_COUNT) { + assertEquals(AppUpdateLogic.segmentFileName(partial, i), names[i]) + assertTrue( + "name at $i must end with .seg$i", + names[i].endsWith(".seg$i"), + ) + } + } +} diff --git a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt index 9d2d6813..94cf1f04 100644 --- a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +++ b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt @@ -18,8 +18,9 @@ import java.io.FileOutputStream import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.zip.ZipEntry @@ -1094,8 +1095,27 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { private val listeners = CopyOnWriteArrayList() private val nextListenerId = AtomicLong(1) - private val isDownloading = AtomicBoolean(false) + + // Per-destination single-flight (OCDS §5.8): one in-flight run per final + // bundle path, keyed by `filePath`. `putIfAbsent` makes a second + // downloadBundle for the SAME dest fail fast ("Already downloading"), while + // different dests now run concurrently (the old global boolean serialized + // everything). The value is the live CancelHandle so clearDownload/clearBundle + // can stop the 8 workers BEFORE deleting the directory (cancel-then-delete), + // preventing a worker from re-creating a just-deleted `.segN`. Being an + // in-memory map, a crashed run leaves no stale lock — the entry is gone on + // relaunch, giving inherent stale-lock recovery (OCDS §5.8). + private val activeDownloads = + ConcurrentHashMap() private val httpClient = OkHttpClient.Builder() + // OCDS §5.4 timeouts. `readTimeout` is the primary inter-byte stall + // window: OkHttp raises SocketTimeoutException when no bytes arrive within + // it, which the segment loop classifies transient and retries. We do NOT + // set callTimeout — it bounds the WHOLE call and would cut a legitimately + // slow large segment mid-stream; the overall run deadline is owned by the + // shared-JS retry budget (ServiceAppUpdate), not this socket layer. + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) .addNetworkInterceptor { chain -> val req = chain.request() if (!req.url.isHttps) { @@ -1191,10 +1211,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { override fun downloadBundle(params: BundleDownloadParams): Promise { return Promise.async { - if (isDownloading.getAndSet(true)) { - OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading") - throw Exception("Already downloading") - } + // Per-dest single-flight is acquired below once `filePath` is known. + // Input validation (version/HTTPS) runs FIRST and is intentionally + // NOT gated by the lock — a malformed request must fail the same way + // whether or not a download is in flight, and must never register a + // lock entry it then has to unwind. + var acquiredKey: String? = null + var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null try { val context = getContext() @@ -1224,6 +1247,19 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // "exists at filePath -> already valid" cache check above. val partialFilePath = "$filePath.partial" + // OCDS §5.8 per-destination single-flight. Register a CancelHandle for + // THIS dest; a second downloadBundle for the same `filePath` fails fast + // ("Already downloading"). Different dests are allowed to run + // concurrently. The handle is passed to the concurrent downloader so + // clearDownload/clearBundle can stop its workers before deleting files. + val handle = ConcurrentRangeDownloader.CancelHandle() + if (activeDownloads.putIfAbsent(filePath, handle) != null) { + OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading $filePath") + throw Exception("Already downloading") + } + acquiredKey = filePath + cancelHandle = handle + val result = BundleDownloadResult( downloadedFile = filePath, downloadUrl = downloadUrl, @@ -1247,12 +1283,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (partialFile.exists()) partialFile.delete() File("$partialFilePath.progress").delete() for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() - // Keep isDownloading held across the skip delay below. Clearing - // it before the sleep opens a ~1s window where a second - // downloadBundle could pass the getAndSet guard and run - // concurrently. Reset only after the delay completes. + // Keep the per-dest lock held across the skip delay below. + // The `finally` removes the lock entry AFTER this return runs, + // so the lock spans the whole sleep — closing the ~1s window + // where a second downloadBundle for this dest could otherwise + // pass the single-flight guard and run concurrently. Thread.sleep(1000) - isDownloading.set(false) sendEvent("update/complete") return@async result } else { @@ -1284,7 +1320,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { val concurrentOutcome = ConcurrentRangeDownloader( httpClient = httpClient, log = { msg -> OneKeyLog.info("BundleUpdate", msg) }, - ).download(downloadUrl, partialFilePath) { transferred, total -> + ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) val prev = concurrentProgress.get() @@ -1338,7 +1374,9 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (downloadedFile.exists()) downloadedFile.delete() if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) { OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download") - isDownloading.set(false) + // The `finally` removes the per-dest lock after this + // return; the lock therefore spans the skip delay, + // same as the existing-file cache-hit path above. Thread.sleep(1000) sendEvent("update/complete") return@async result @@ -1545,7 +1583,26 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // sanitizeErrorMessageForEvent. OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}") val sanitized = sanitizeErrorMessageForEvent(e) - sendEvent("update/error", message = sanitized) + // An INTENTIONAL cancel (clearDownload/clearBundle flipped THIS + // run's CancelHandle, which aborts the workers → they throw + // IOException("aborted")) is NOT a download failure — the caller + // deliberately tore the run down. Before the cancel-then-delete + // path existed, clearing never aborted an in-flight run, so no + // `update/error` was emitted; preserve that. Detect it off the + // handle's `aborted` flag rather than the message, because + // sanitizeErrorMessageForEvent collapses "aborted" to the generic + // "IO_IOException" tag and would be indistinguishable from a real + // I/O failure. + val intentionallyCancelled = cancelHandle?.aborted?.get() == true + // A duplicate-dest single-flight rejection ("Already downloading") + // is NOT a failure of any in-flight download — it means another + // run already owns this dest. The original global-boolean guard + // threw this BEFORE the try, so no `update/error` was emitted; + // preserve that so listeners don't observe a spurious error for the + // rejected caller while the real download is still progressing. + if (sanitized != "Already downloading" && !intentionallyCancelled) { + sendEvent("update/error", message = sanitized) + } // Rethrow with the same sanitized message so the Promise // rejection surfacing to JS carries no /data/user/// // paths. Without this rewrap, FileNotFoundException etc. @@ -1555,7 +1612,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // native crash reporter) still sees the full chain. throw Exception(sanitized, e) } finally { - isDownloading.set(false) + // Release THIS run's per-dest single-flight lock. Keyed remove + // (only if we actually acquired it — input-validation failures + // throw before acquisition, leaving acquiredKey null). The + // value-checked remove avoids deleting an entry a later run for + // the same dest may have re-registered after a concurrent cancel. + val key = acquiredKey + val handle = cancelHandle + if (key != null && handle != null) { + activeDownloads.remove(key, handle) + } } } } @@ -1784,10 +1850,28 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } + // OCDS §5.8 cancel-then-delete. Stop every in-flight concurrent run's worker + // pool (flip its abort flag + shutdownNow) BEFORE the caller deletes the + // download directory. Without this, a live segment worker could re-create a + // `.segN` we just deleted (CRD streams into `.segN` via append). Removing each + // entry here also makes the dest immediately re-acquirable. The in-flight + // download's own `finally` does a value-checked remove, so racing it is safe. + private fun cancelAllActiveDownloads() { + val keys = activeDownloads.keys.toList() + for (key in keys) { + activeDownloads.remove(key)?.let { + OneKeyLog.info("BundleUpdate", "cancelling in-flight download: $key") + it.cancel() + } + } + } + override fun clearDownload(): Promise { return Promise.async { OneKeyLog.info("BundleUpdate", "clearDownload: clearing download directory...") val context = getContext() + // Cancel-then-delete: stop live workers before removing files. + cancelAllActiveDownloads() val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context)) if (downloadDir.exists()) { BundleUpdateStoreAndroid.deleteDir(downloadDir) @@ -1795,7 +1879,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } else { OneKeyLog.info("BundleUpdate", "clearDownload: download directory does not exist, skipping") } - isDownloading.set(false) OneKeyLog.info("BundleUpdate", "clearDownload: completed") } } @@ -1805,6 +1888,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { return Promise.async { OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...") val context = getContext() + // Cancel-then-delete: stop live workers before removing files. + cancelAllActiveDownloads() // Clear download directory val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context)) if (downloadDir.exists()) { @@ -1817,7 +1902,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { BundleUpdateStoreAndroid.deleteDir(bundleDir) OneKeyLog.info("BundleUpdate", "clearBundle: bundle directory deleted") } - isDownloading.set(false) OneKeyLog.info("BundleUpdate", "clearBundle: completed") } } diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index a05eb521..c5e902e4 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -985,6 +985,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { /// Read by the background-snapshot handler so it knows where to drop the /// `.resume` sidecar. private var activeDownloadFilePath: String? + /// OCDS §5.7: the highest progress percent already reported to JS for the + /// in-flight download. Used to floor single-stream progress so the bar never + /// moves backward at the concurrent→single-stream seam. Guarded by + /// `stateQueue`. Reset to 0 only on a genuine restart (permanent fallback). + private var lastReportedProgress: Int = 0 private var didEnterBackgroundObserver: NSObjectProtocol? override init() { @@ -1228,7 +1233,13 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { // below; the range downloader keeps its `.segN` files for the next // attempt (its background session resumes them by taskId). self.sendEvent(type: "update/start") - self.stateQueue.sync { self.activeDownloadFilePath = filePath } + // §5.7: a fresh downloadBundle starts the progress floor at 0; the + // concurrent path raises it as bytes land (below), and a transient + // single-stream fallback keeps it. + self.stateQueue.sync { + self.activeDownloadFilePath = filePath + self.lastReportedProgress = 0 + } // Stable, unique task id for this bundle. Same value across retry // attempts so the range downloader can resume its `.segN` files, and @@ -1241,31 +1252,88 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { // "fallback" are surfaced by this method's own sendEvent calls and // the download outcome below, so we don't double-emit them here. let progressListenerId = RangeDownloader.shared.addListener { [weak self] event in - guard event.channel.stringValue == DownloadChannel.bundle.stringValue, + guard let self = self, + event.channel.stringValue == DownloadChannel.bundle.stringValue, event.taskId == rangeTaskId, event.type == "progress" else { return } - self?.sendEvent(type: "update/downloading", progress: Int(event.progress)) - } - - let (rangeOutcome, _, rangeReason) = await RangeDownloader.shared.download( - channel: .bundle, - taskId: rangeTaskId, - urlString: downloadUrl, - filePath: filePath, - // SHA256 is verified by this module right after assembly (below), - // so we don't double-hash inside the range downloader. - expectedSha256: nil, - segmentCount: nil, - minConcurrentBytes: nil - ) + // §5.7: record the concurrent floor so a later single-stream + // transient fallback resumes the bar from here, not from 0. + let floored: Int = self.stateQueue.sync { + let clamped = max(self.lastReportedProgress, Int(event.progress)) + self.lastReportedProgress = clamped + return clamped + } + self.sendEvent(type: "update/downloading", progress: floored) + } + + // A `.fallback` outcome covers two very different situations whose + // recovery must NOT be the same: + // • Permanent — Range unsupported / file too small / server 200 / + // SHA mismatch. The downloader has already cleaned its `.segN` + // segments, so none survive; single-stream is the only way. + // • Transient — a network drop or app suspend interrupted a segment + // mid-flight ("segment N missing/truncated"). The downloader + // RETAINS the segments that DID complete; the next concurrent + // attempt resumes only the missing one(s). + // This method used to `discardArtifacts` on EVERY fallback and restart + // single-stream from byte 0, so any background/lock/network blip threw + // away tens of MB and the user saw progress reset to 0. We now retry + // the concurrent path while resumable segments survive, and fall back + // to single-stream only as a last resort — WITHOUT deleting those + // segments, so a later attempt can still resume them. + let maxConcurrentAttempts = 3 + var concurrentAttempt = 0 + // OCDS §4: consume the EXPLICIT typed class from the in-process core + // (`RangeDownloadClass`) instead of inferring transient-vs-permanent + // from whether `.segN` happens to survive on disk. The wire enum stays + // `completed | fallback` until nitrogen regen; we call the in-process + // `download(...)` directly, so we get the precise class here. + var rangeClass: RangeDownloadClass = .fallbackPermanent + var rangeReason: String? + while true { + concurrentAttempt += 1 + (rangeClass, _, rangeReason, _) = await RangeDownloader.shared.download( + channel: .bundle, + taskId: rangeTaskId, + urlString: downloadUrl, + filePath: filePath, + // SHA256 is verified by this module right after assembly (below), + // so we don't double-hash inside the range downloader. + expectedSha256: nil, + segmentCount: nil, + minConcurrentBytes: nil + ) + if rangeClass == .completed { + break // completed + } + // §4: a Transient class is resumable — retry the concurrent path. + // The core keeps `.segN`; the next call resumes only what's missing. + if rangeClass == .fallbackTransient, + concurrentAttempt < maxConcurrentAttempts { + OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent transient fallback (\(rangeReason ?? "")), resuming surviving segments (attempt \(concurrentAttempt)/\(maxConcurrentAttempts))") + // Brief backoff so a flapping network / suspend settles before + // we re-probe and resume the missing segment(s). + try? await Task.sleep(nanoseconds: 1_500_000_000) + continue + } + break // permanent, or transient retries exhausted + } RangeDownloader.shared.removeListener(progressListenerId) - if rangeOutcome.stringValue == RangeDownloadOutcome.fallback.stringValue { - // Concurrent path unavailable (Range unsupported / file too small / - // server returned 200 / transient error). Hand off to the - // single-stream path, leaving a clean slot. - OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent fallback (\(rangeReason ?? "")), using single-stream") - RangeDownloader.shared.discardArtifacts(filePath: filePath) + if rangeClass != .completed { + if rangeClass == .fallbackTransient { + // §4 Transient that outlived our concurrent retries: KEEP the + // segments so the next downloadBundle call's concurrent path can + // resume them. Single-stream is just this call's safety net, and + // its progress is floored (below) so the bar never drops to 0. + OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent transient fallback persists (\(rangeReason ?? "")), using single-stream (segments preserved for resume)") + } else { + // §4 Permanent (segments already cleaned by the downloader): + // clear any residue and hand off to single-stream from a clean + // slot; this is a genuine restart, so the progress floor resets. + OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent permanent fallback (\(rangeReason ?? "")), using single-stream") + RangeDownloader.shared.discardArtifacts(filePath: filePath) + } // fall through to the single-stream path below } else { OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent finished, verifying SHA256...") @@ -1312,8 +1380,24 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download delegate not initialized"]) } delegate.reset() + // OCDS §5.7: reported progress is monotonic non-decreasing within a + // run, and resets only on a GENUINE restart. The single-stream + // delegate emits its own 0→100, which would jump the bar backward at + // the concurrent→single-stream seam. Floor it: a transient fallback + // KEEPS the floor (the concurrent path already reported ~N%), a + // permanent fallback is a genuine restart so the floor resets to 0. + let progressFloor = (rangeClass == .fallbackTransient) + ? self.stateQueue.sync { self.lastReportedProgress } + : 0 + self.stateQueue.sync { self.lastReportedProgress = progressFloor } delegate.onProgress = { [weak self] progress in - self?.sendEvent(type: "update/downloading", progress: progress) + guard let self = self else { return } + let floored: Int = self.stateQueue.sync { + let clamped = max(self.lastReportedProgress, progress) + self.lastReportedProgress = clamped + return clamped + } + self.sendEvent(type: "update/downloading", progress: floored) } // Anchor for the background-snapshot handler. Set BEFORE @@ -1421,6 +1505,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"]) } + // A verified single-stream file is authoritative — drop any + // concurrent `.segN` segments we intentionally kept across a + // transient fallback so they don't linger as orphans. + RangeDownloader.shared.discardArtifacts(filePath: filePath) + self.sendEvent(type: "update/complete") OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") return result diff --git a/native-modules/react-native-range-downloader/README.md b/native-modules/react-native-range-downloader/README.md index bf3f579d..2e4d67ca 100644 --- a/native-modules/react-native-range-downloader/README.md +++ b/native-modules/react-native-range-downloader/README.md @@ -39,6 +39,21 @@ try { } ``` +## Specification + +The behavior of the concurrent downloader is governed by a normative, +platform-agnostic standard that **all** implementations (iOS, Android, Desktop, +and any future platform) MUST conform to: + +**→ [OneKey Concurrent Download Standard (OCDS)](./SPEC.md)** + +Any change to download behavior on any platform must be checked against OCDS. +When an implementation and the standard disagree, the implementation is wrong. + +How each platform (Node / Android / iOS) is verified against OCDS — and the +runnable verification code to re-run after any downloader change — lives in +**→ [`conformance/`](./conformance/README.md)**. + ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/native-modules/react-native-range-downloader/SPEC.md b/native-modules/react-native-range-downloader/SPEC.md new file mode 100644 index 00000000..0eca36b4 --- /dev/null +++ b/native-modules/react-native-range-downloader/SPEC.md @@ -0,0 +1,345 @@ +# OneKey Concurrent Download Standard (OCDS) + +- **Version:** 1.2 +- **Status:** Active +- **Last updated:** 2026-06-20 +- **Applies to:** every implementation of OneKey's concurrent (multi-range) + downloader — iOS (Swift), Android (Kotlin), Desktop (Node/Electron), and any + future platform. + +This document defines the behavior a concurrent downloader is expected to +provide. It is platform-agnostic: implementations differ in language and +transport, but must be **behaviorally equivalent** with respect to everything +described here. It is the reference; when an implementation and this document +disagree, the implementation is at fault. + +--- + +## 1. Purpose & scope + +The concurrent downloader fetches one file over **N parallel HTTP byte-range +connections** to improve throughput on high-latency / lossy networks (mobile is +the main beneficiary). It is used for OTA bundle, APK, and asset delivery. + +It is, by design, a drop-in accelerator: a single, sequential **single-stream** +downloader remains the baseline, and the concurrent path falls back to it +whenever range downloading is not possible. Correctness never depends on the +concurrent path succeeding. + +**In scope:** range discovery, segmentation, parallel fetch, on-disk layout, +resume, retry, integrity verification, finalization, fallback, progress, +cancellation, security, and background execution. + +**Out of scope:** the single-stream downloader's internals, the OTA install +pipeline, signature/key management, and UI. + +--- + +## 2. Architecture + +``` + caller (OTA / APK / asset) + │ download(url, dest) + ▼ + ┌─────────────────────────── Concurrent Downloader ───────────────────────────┐ + │ │ + │ ┌─────────┐ ┌──────────────┐ ┌───────────────────────────────┐ │ + │ │ Probe │─────▶│ Range planner│─────▶│ Reconcile (reuse on-disk segs)│ │ + │ │ 1 byte │ size │ N segments │ plan │ skip complete / resume short │ │ + │ └─────────┘ etag └──────────────┘ └───────────────┬───────────────┘ │ + │ │ no range / 200 / too small │ fetch missing │ + │ ▼ ┌────────┬────────┼────────┬───────┐ │ + │ [PERMANENT] ▼ ▼ ▼ ▼ │ │ + │ │ worker0 worker1 ... workerN-1 │ │ N parallel + │ │ │ │ │ │ │ │ Range GETs + │ │ ▼ ▼ ▼ ▼ │ │ (+ If-Range) + │ │ dest.seg0 dest.seg1 ... dest.segN-1 │ │ on-disk + │ │ └────────┴────┬───┴────────┘ │ │ segments + │ │ │ all complete │ │ + │ │ ▼ │ │ + │ │ ┌────────────────────┐ │ │ + │ │ │ Assemble (ordered │ │ │ + │ │ │ concat → .partial) │ │ │ + │ │ └─────────┬──────────┘ │ │ + │ │ ▼ │ │ + │ │ ┌────────────────────┐ │ │ + │ │ │ Verify SHA256 / sig │ │ │ + │ │ └─────────┬──────────┘ │ │ + │ │ ok ▼ ▼ mismatch [PERMANENT]│ │ + │ │ atomic rename .partial → dest │ │ + │ └──────────────┐ │ │ │ + └─────────────────────┼──────────────────────────┼────────────────────────────┘ + ▼ ▼ + Single-stream fallback ──────────▶ dest (final, verified) +``` + +**Components** + +- **Probe** — a single lightweight request (a 1-byte range) that learns total + size, range support, and a validator (`ETag`) in one round trip. Background + transports that cannot issue this kind of request use a separate foreground + request for the probe only. +- **Range planner** — splits the total size into N contiguous segments. +- **Reconcile** — before fetching, inspects on-disk artifacts from a prior + attempt and plans to fetch only what is missing or incomplete. +- **Workers** — N concurrent range GETs, each writing its own segment artifact. +- **Assembler** — concatenates completed segments, in order, into a `.partial`. +- **Verifier** — checks the assembled file against the expected whole-file + checksum (and signature, where applicable) before promotion. +- **Single-stream fallback** — the sequential downloader used whenever the + concurrent path is not possible. + +--- + +## 3. On-disk model + +The concurrent state is represented as **one artifact per segment** +(`.seg0 … .segN-1`), assembled into the final file by ordered +concatenation, then atomically renamed to ``. + +``` + .seg0 ┐ + .seg1 │ completed segments persist on disk across + … │ attempts and across process restarts + .segN-1 ┘ + │ concat (ordered) + ▼ + .partial ──(verify ok)──atomic rename──▶ +``` + +This layout is deliberate. A background download on some platforms can only +deliver a whole file per connection and cannot do positioned writes into a +shared file, so a single pre-allocated file written by offset is not portable. +The per-segment layout is the one model that works everywhere. (See Appendix A.) + +A size-based resume heuristic, if used, must operate on artifacts whose on-disk +size truthfully reflects durably-downloaded bytes — i.e. no full-size +pre-allocation that reports "complete" before the bytes exist. + +--- + +## 4. Failure model + +Every failure during a run resolves to exactly one of two classes, and the two +classes have **opposite** recoveries. The class is an explicit result of the +operation, never inferred from incidental side effects (such as whether some +files happen to remain on disk). + +| Observed condition | Class | Required outcome | +| --- | --- | --- | +| Server answers `200` to a `Range` request | **Permanent** | discard segments, fall back to single-stream | +| Range unsupported / probe inconclusive | **Permanent** | single-stream from the start | +| Total size below the concurrency threshold | not a failure | skip concurrency, use single-stream | +| `Content-Range` window ≠ requested range | **Permanent** | reject the body, discard, fall back | +| Whole-file checksum/signature mismatch after assembly | **Permanent** | discard final + artifacts; terminal failure surfaced to caller (no infinite re-download). Re-fetching the same object would re-corrupt, so no automatic single-stream retry is required. | +| `401` / `403` (auth / expired signed URL) | **Permanent** (this URL) | stop; surface to caller to obtain a fresh signed URL; do not blindly retry the dead URL | +| `404` / `410` (not found / gone) | **Permanent** | terminal failure surfaced to caller (single-stream will also fail) | +| Rejected non-HTTPS redirect (per §5.9) | **Permanent** | abort, surface to caller | +| Connection lost / timeout / DNS / TLS error | **Transient** | retry the segment in place; keep artifacts | +| `429` / throttling / `5xx` (except `501` / `505`) | **Transient** | back off, retry; keep artifacts | +| `416` to a resume request | **Transient** | re-evaluate size; keep artifacts | +| App suspended or terminated mid-download | **Transient** | keep artifacts; resume on relaunch | +| Local disk I/O error | **Transient** | retry; keep artifacts | + +Any condition not listed above is classified by default as: `4xx` → Permanent +(except `408` and `429` → Transient); `5xx` → Transient (except `501` and `505` +→ Permanent); anything else or unknown → Permanent. This default rule guarantees +that the "exactly one of two classes" property holds for every failure, listed +or not. + +**Permanent** means concurrency is fundamentally unusable for this object; +already-fetched bytes are unsalvageable, so discarding them is correct. + +**Transient** means the transfer was interrupted but is resumable; completed +segments are valuable and must be kept, and the concurrent path must resume +rather than restart. + +> The most damaging mistake is classifying a Transient failure as Permanent: it +> throws away tens of megabytes already on disk and restarts from zero. A run +> must never restart from byte 0 while resumable bytes exist. + +--- + +## 5. Coverage — what every implementation must handle + +The following enumerates the situations an implementation is expected to cover +and the behavior it must exhibit. Each row is a behavior that conformance +testing (§6) exercises. + +### 5.1 Discovery +- Determine total size and range support before segmenting; capture a validator + when available. +- When range is unsupported or the file is below the size threshold, use + single-stream and do not create concurrent artifacts. + +### 5.2 Segmentation & assembly +- Split into N contiguous segments; fetch each over its own connection. +- Assemble only after every segment is complete, by ordered concatenation. +- Produce the final file atomically (assemble → durable flush → atomic rename); + a half-written final file is never observable. +- All intermediate artifacts (segment files and the `.partial`) reside on the + same filesystem/volume as the final destination, so the finalize rename is a + true atomic rename; copying into place across volumes is not permitted. +- On success, remove all intermediate artifacts; the verified final file is + authoritative. + +### 5.3 Resume +- Completed segments persist across re-invocations and across process restarts. +- A re-invocation re-fetches only missing or incomplete segments; a completed + segment is never re-downloaded. +- An incomplete segment resumes from its current persisted length where the + transport allows, instead of restarting that segment. +- The run never restarts from byte 0 while completed segments or a valid partial + exist. + +### 5.4 Retry & backoff +- A transient segment error is retried in place (bounded; at least a few + attempts) before it is allowed to fail the run. +- One segment's transient error does not end the whole run before that segment's + retries are exhausted. +- Each request has a connection timeout and a bytes-stalled (no-progress) + timeout, and the run has an overall deadline; a stalled transfer (no bytes + received within the stall window) is a Transient timeout that triggers retry. +- Retries back off between attempts. Backoff uses jitter so that N segments do + not retry in lockstep, and when the server sends a `Retry-After` header it + overrides the computed backoff. +- Throttling (`429`) and server errors (`5xx`) are retried, not treated as + fatal. + +These timeouts, the stall window, the backoff bounds, and the deadline are +named, caller-tunable parameters; their default values are platform +configuration, not part of this standard. + +### 5.5 Integrity +- A segment is accepted only when the response is `206` and its `Content-Range` + matches the requested window exactly. +- A response that is `multipart/byteranges`, carries more than the single + requested range, or whose `Content-Range` total disagrees with the probe total + (or is an unknown `*` total) is rejected; a probe that cannot yield a concrete + total is treated as range-unsupported → single-stream. +- A segment's body length equals its planned length; short and over-long bodies + are rejected, and no bytes are written past a segment's planned end. +- The assembled file is verified by whole-file checksum (and signature where + applicable) before it is promoted to the final path. +- Object identity is guarded consistently — either pin via `If-Range`, or wipe + stale artifacts before reuse and rely on the whole-file checksum as the sole + identity guarantee. A mixed, partial policy is not acceptable. + +### 5.6 Fallback +- Single-stream fallback happens only for Permanent failures. +- When the single-stream path resumes and the server returns a full body + (`200`), existing bytes are not appended onto; that stream restarts cleanly. +- If concurrent and single-stream share any resume state, it is valid for both; + otherwise they do not share it. + +### 5.7 Progress +- Reported progress is monotonic non-decreasing within a run, even though N + workers report concurrently. +- Progress resets only on a genuine restart; a path or attempt switch does not + move the bar backward without a real restart. + +### 5.8 Cancellation +- An external cancellation stops in-flight work first, then deletes artifacts, so + a still-running task cannot resurrect a just-deleted file. +- Cancellation is wired from the caller through to the in-flight run, not only an + internal flag. +- At most one run may be active per destination. A second concurrent + `download()` for the same destination either joins the in-flight run or fails + fast; concurrent runs never co-write the same artifacts. A lock left behind by + a crashed run must be reclaimable (stale-lock recovery), so a crash does not + permanently block the destination. + +### 5.9 Security +- All requests are HTTPS, enforced on the initial request and on every redirect + hop; a redirect to a non-HTTPS URL is rejected. +- Errors surfaced outside the native/runtime layer do not leak internal + filesystem paths; full detail is logged locally only. + +### 5.10 Background execution +- Where the platform provides OS-level background transfer, a run continues + across app suspension/termination and resumes on relaunch. + +### 5.11 Termination & give-up +- Retry and resume are bounded by both a total attempt budget that persists + across process restarts and an overall wall-clock deadline; when either is + exhausted the whole download ends. +- The downloader always reaches a definitive terminal outcome — success, or a + terminal failure with a reason — and surfaces it to the caller. It never + silently loops or hangs across relaunches. +- If the single-stream fallback itself fails, that is the terminal failure of + the whole download; the fallback is not a black hole. + +--- + +## Trust boundary + +This module provides **integrity** (the whole-file checksum), not +**authenticity**. The trust responsibilities are delegated as follows; they are +existing obligations made explicit here, not new mechanism inside this module: + +- The expected checksum and size are supplied by the caller from a trusted, + signed channel (for example, a signature-verified manifest checked against a + pinned key). They are never read from the download response. +- Authenticity of an executable artifact is verified by the consumer with a + signature over the final bytes, before install and again at load. +- Artifacts are stored in an app-private location. +- Anti-rollback / version-monotonicity is the consumer's responsibility. + +--- + +## 6. Conformance scenarios + +An implementation is conformant when it passes all of the following: + +| # | Scenario | Expected result | +| --- | --- | --- | +| 1 | Network drops on some segments mid-run | Only affected segments are re-fetched on resume; no full restart | +| 2 | App suspended / killed mid-run | Resumes on relaunch from completed segments | +| 3 | Server returns `200` to a `Range` request | Classified Permanent; clean single-stream | +| 4 | `429` / `5xx` on a segment | Backoff + retry + keep; eventual success, no restart from 0 | +| 5 | Mis-aligned / short / over-long `206` | Rejected, no corruption; final checksum passes | +| 6 | Corrupted assembly | Checksum mismatch → discard artifacts → terminal failure surfaced to caller; no infinite loop (no automatic re-download of the same object) | +| 7 | Repeated lock / background / network-toggle stress | Reported progress never resets to 0 unless a Permanent failure occurred | +| 8 | Cancel mid-run | In-flight work stops, artifacts removed, nothing resurrected | +| 9 | Permanently failing object / exhausted budget | Download ends in bounded time with a terminal failure reported; no infinite loop across relaunches | +| 10 | Stalled socket (bytes stop) | Stall timeout fires → Transient retry | +| 11 | Two concurrent `download()` for the same dest | Serialized or one fails fast; artifacts never co-written | + +Conformance is evaluated per behavior. An implementation that does not yet meet +all of the above is non-conformant; its open gaps are tracked in that platform's +own notes until closed. **This document records no implementation's state.** + +--- + +## Appendix A. Platform constraints (non-normative) + +- **iOS background `URLSession`** can use only download tasks (not data tasks) + and delivers each task's body as one complete file at finish; positioned + writes into a shared file are impossible. This is what forces the per-segment + on-disk model (§3) and bounds sub-segment resume (§5.3, "where the transport + allows"). The probe (§5.1) runs on a separate foreground request. +- **Always-streaming transports** (Android OkHttp, Node `http`) read the + response body incrementally, so sub-segment resume and byte-level progress are + readily achievable. They do not get OS-level background survival (§5.10) and + rely on on-disk artifacts to resume on the next launch. + +## Appendix B. Change log + +- **1.2** (2026-06-22) — Removed the "retry once via single-stream" requirement + on a whole-file checksum/signature mismatch (§4 failure table, §6 scenario 6). + A mismatch after assembly is now simply Permanent → discard + terminal failure. + Rationale: re-fetching the same object would re-corrupt, so the retry adds cost + with no integrity benefit; all three reference implementations (iOS, Android, + Node) already terminate on mismatch, confirming the retry was over-specified. +- **1.1** (2026-06-20) — Added a terminal / give-up boundary (§5.11: bounded + attempt budget + wall-clock deadline, definitive terminal outcome, fallback + failure is terminal). Made the failure classification exhaustive with a + catch-all default rule and reworked the checksum-mismatch row to bound its + retry. Added stall/connection timeouts and an overall deadline, plus jitter and + `Retry-After` handling, to retry & backoff. Added per-destination single-flight + (with stale-lock recovery) to cancellation. Required intermediate artifacts on + the same filesystem for a true atomic rename. Required rejection of + `multipart/byteranges` / extra-range / disagreeing-total responses. Added a + Trust boundary section delegating authenticity, manifest-sourced + checksum/size, app-private storage, and anti-rollback to the caller/consumer. +- **1.0** (2026-06-20) — Initial standard. diff --git a/native-modules/react-native-range-downloader/android/build.gradle b/native-modules/react-native-range-downloader/android/build.gradle index b08591f8..7ab18d64 100644 --- a/native-modules/react-native-range-downloader/android/build.gradle +++ b/native-modules/react-native-range-downloader/android/build.gradle @@ -129,4 +129,7 @@ dependencies { implementation project(":onekeyfe_react-native-native-logger") implementation "com.squareup.okhttp3:okhttp:4.12.0" + + testImplementation "junit:junit:4.13.2" + testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" } diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt index eabd5188..242c2ec8 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt @@ -49,6 +49,12 @@ class ConcurrentRangeDownloader( private val segmentCount: Int = 8, private val minConcurrentBytes: Long = 2L * 1024 * 1024, private val maxPartRetry: Int = 3, + // Per-segment retry backoff (OCDS §5.4). Doubles each attempt up to a cap, + // with full jitter so N segments do not retry in lockstep. A server + // `Retry-After` overrides these. Caller-tunable; defaults are config, not + // part of the standard. + private val retryBaseDelayMillis: Long = 500L, + private val retryMaxDelayMillis: Long = 8_000L, private val log: (String) -> Unit = {}, ) { enum class Outcome { @@ -62,6 +68,27 @@ class ConcurrentRangeDownloader( /** Thrown internally when a segment proves concurrency can't be used. */ private class FallbackException(message: String) : Exception(message) + /** + * Thrown for an HTTP status that is permanently unrecoverable for this URL + * (auth/expired-signed-URL `401`/`403`, gone `404`/`410`, `501`/`505`, and + * any other non-retryable 4xx per OCDS §4's catch-all). Unlike a generic + * transient [java.io.IOException], this MUST bypass the per-segment retry + * loop ([downloadSegment]) — retrying a dead URL only wastes the attempt + * budget. The `HTTP ` message shape is preserved so the JS error + * taxonomy ([updateErrorTaxonomy.ts]) maps it to `HTTP_`. + */ + private class PermanentHttpException(val code: Int) : + Exception("HTTP $code") + + /** + * Typed transient carrying an optional `Retry-After` delay (milliseconds) + * the server asked us to wait before retrying (`429`/`503`). The retry loop + * in [downloadSegment] prefers this over its computed backoff. Keeps the + * `HTTP ` message shape for the JS taxonomy. + */ + private class TransientHttpException(val code: Int, val retryAfterMillis: Long?) : + java.io.IOException("HTTP $code") + /** * Cooperative-cancel handle the caller can register a download against. The * adapter keeps these in a per-taskId registry so `cancel`/`discardArtifacts` @@ -80,10 +107,30 @@ class ConcurrentRangeDownloader( if (aborted.get()) pool.shutdownNow() } - /** Flip the abort flag and stop the worker pool. Idempotent. */ + /** + * Flip the abort flag and stop the worker pool, then wait (bounded) for + * in-flight workers to actually terminate. `shutdownNow()` only + * *interrupts*; a worker blocked in a native `write()` may run a moment + * longer, so without this wait the caller's subsequent `.segN` delete + * could be resurrected by that straggler — the exact race §5.8 forbids. + * Idempotent. + */ fun cancel() { aborted.set(true) - pool?.shutdownNow() + pool?.let { p -> + p.shutdownNow() + try { + p.awaitTermination(AWAIT_TERMINATION_SECONDS, java.util.concurrent.TimeUnit.SECONDS) + } catch (e: InterruptedException) { + // Preserve the interrupt status; the bounded wait is best-effort. + Thread.currentThread().interrupt() + } + } + } + + private companion object { + /** Bounded wait for worker termination on cancel (cancel-then-delete). */ + const val AWAIT_TERMINATION_SECONDS = 3L } } @@ -181,7 +228,7 @@ class ConcurrentRangeDownloader( val futures = pending.map { part -> pool.submit { try { - downloadSegment(url, segFile(part.index), part, aborted) { delta -> + downloadSegment(url, segFile(part.index), part, total, aborted) { delta -> onProgress(transferred.addAndGet(delta), total) } } catch (e: FallbackException) { @@ -318,6 +365,13 @@ class ConcurrentRangeDownloader( cursor = segEndInFinal segFile(part.index).delete() } + // Force the assembled `.partial` durable before the caller renames it + // to the final path (OCDS §5.2: assemble → durable flush → atomic + // rename). `out.flush()` above only pushes the JVM/libc buffers to the + // kernel; without this fsync a power loss between the rename and the + // kernel's writeback could promote a `.partial` whose tail bytes never + // reached the platter, yielding a final file that fails SHA256. + out.fd.sync() } if (partialFile.length() != total) { partialFile.delete() @@ -333,6 +387,7 @@ class ConcurrentRangeDownloader( url: String, segFile: File, part: Part, + total: Long, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, ) { @@ -343,22 +398,60 @@ class ConcurrentRangeDownloader( if (have >= part.length) return val rangeStart = part.start + have try { - fetchSegment(url, segFile, part, rangeStart, aborted, onBytes) + fetchSegment(url, segFile, part, total, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e + } catch (e: PermanentHttpException) { + // Dead URL (auth/gone/non-retryable) — retrying only burns the + // attempt budget. Surface immediately; the caller maps the + // `HTTP ` message to a permanent JS taxonomy bucket. + throw e } catch (e: Exception) { if (aborted.get() || retry >= maxPartRetry) throw e retry += 1 - log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}") + // Prefer the server's Retry-After; otherwise exponential backoff + // with full jitter so the 8 segments do not retry in lockstep. + val retryAfter = (e as? TransientHttpException)?.retryAfterMillis + val delay = retryAfter ?: computeBackoffMillis(retry) + log("concurrent: segment ${part.index} retry $retry in ${delay}ms: ${e.javaClass.simpleName}") + sleepAbortable(delay, aborted) } } } + // Exponential backoff (base * 2^(attempt-1), capped) with full jitter: + // a uniformly random delay in [0, ceiling]. Full jitter is what actually + // de-correlates the N segments — without it they would retry in lockstep. + private fun computeBackoffMillis(attempt: Int): Long { + val exp = retryBaseDelayMillis shl (attempt - 1).coerceIn(0, 16) + val ceiling = exp.coerceAtMost(retryMaxDelayMillis).coerceAtLeast(1L) + return (Math.random() * ceiling).toLong().coerceAtLeast(0L) + } + + // Sleep in short slices so an external cancel() (which flips `aborted` and + // shutdownNow()s the pool) is observed promptly instead of after a multi- + // second backoff. Throws on abort so the loop bails immediately. + private fun sleepAbortable(totalMillis: Long, aborted: AtomicBoolean) { + var remaining = totalMillis + while (remaining > 0) { + if (aborted.get()) throw java.io.IOException("aborted") + val slice = minOf(remaining, 100L) + try { + Thread.sleep(slice) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw java.io.IOException("aborted") + } + remaining -= slice + } + } + private fun fetchSegment( url: String, segFile: File, part: Part, + total: Long, rangeStart: Long, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, @@ -374,7 +467,19 @@ class ConcurrentRangeDownloader( throw FallbackException("server returned 200 to a Range request") } if (response.code != 206) { - throw java.io.IOException("HTTP ${response.code}") + // OCDS §4 classification. Anything that is not a usable 206 is + // either a permanently-dead URL (bypass retries) or a transient + // condition (retry with backoff). The `HTTP ` message shape + // is preserved on both so the JS taxonomy maps it to HTTP_. + throw classifyHttpFailure(response) + } + // Reject a multipart/byteranges body: it carries range delimiters and + // (potentially) more than the single window we asked for, so streaming + // it raw into the segment file would splice in boundary bytes. This is + // not a usable single-range 206 — fall back to single-stream. + val contentType = response.header("Content-Type")?.lowercase() + if (contentType != null && contentType.startsWith("multipart/byteranges")) { + throw FallbackException("server returned multipart/byteranges to a single Range request") } // Verify the 206 covers exactly the slice we asked for. This guards // against a proxy/CDN returning a mis-aligned 206 (wrong window), @@ -392,6 +497,17 @@ class ConcurrentRangeDownloader( "expected $rangeStart-${part.end}" ) } + // The 206's `Content-Range` total (the `/` tail) MUST agree + // with the probe total. A disagreeing concrete total means the object + // changed size behind us (different build) — that is permanent for + // this window; an unknown `*` total cannot be reconciled either. Both + // make concurrency unusable → fall back to single-stream. + val parsedTotal = bounds.third + if (parsedTotal == null || parsedTotal != total) { + throw FallbackException( + "Content-Range total mismatch: got ${parsedTotal ?: "*"}, expected $total" + ) + } val body = response.body ?: throw java.io.IOException("Empty segment body") // Append the fetched tail to the segment file. Append mode keeps // resume correct: we only ever request the bytes not yet on disk. @@ -419,12 +535,64 @@ class ConcurrentRangeDownloader( } } - // Parse "bytes -/" → (start, end). Returns null when the - // header is absent-of-bounds (e.g. "bytes */1234") or otherwise unparseable. - private fun parseContentRangeBounds(value: String): Pair? { - val m = Regex("""bytes\s+(\d+)-(\d+)/""").find(value) ?: return null + // Parse "bytes -/" → (start, end, total). Returns null + // when the header lacks concrete bounds (e.g. "bytes */1234") or is otherwise + // unparseable. `total` is null when the total is the unknown `*` form + // ("bytes -/*"), which the caller treats as a disagreeing total. + private fun parseContentRangeBounds(value: String): Triple? { + val m = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""").find(value) ?: return null val start = m.groupValues[1].toLongOrNull() ?: return null val end = m.groupValues[2].toLongOrNull() ?: return null - return start to end + val total = m.groupValues[3].let { if (it == "*") null else it.toLongOrNull() } + return Triple(start, end, total) + } + + // OCDS §4 status classifier for a non-206, non-200 status: returns a + // [PermanentHttpException] (bypasses the per-segment retry loop) or a + // [TransientHttpException] (retried with backoff/Retry-After). The pure + // status→class decision lives in the module-visible [isPermanentHttpStatus] + // top-level function so it can be unit-tested without an okhttp Response. + private fun classifyHttpFailure(response: okhttp3.Response): Exception { + val code = response.code + if (isPermanentHttpStatus(code)) return PermanentHttpException(code) + return TransientHttpException(code, parseRetryAfterMillis(response.header("Retry-After"))) + } + + // Parse a `Retry-After` header into milliseconds. Only the delta-seconds form + // is honored (the absolute HTTP-date form is rarely sent for 429/503 and not + // worth a date parser here); an unparseable/absent value yields null so the + // caller falls back to its computed backoff. + private fun parseRetryAfterMillis(value: String?): Long? { + val seconds = value?.trim()?.toLongOrNull() ?: return null + if (seconds < 0) return null + return (seconds * 1000L).coerceAtMost(retryMaxDelayMillis) } } + +/** + * OCDS §4 HTTP-status classification. `true` = permanent (concurrency is + * fundamentally unusable for this object → discard artifacts, fall back to + * single-stream); `false` = transient (keep artifacts, retry). Pure and + * module-visible so it is unit-tested without constructing an okhttp Response. + * 200 (fallback) and 206 (proceed) are handled before a failure reaches the + * classifier, so they are not represented here. + * + * 401/403/404/410 → permanent (auth / expired signed URL / gone) + * 408/429 → transient (timeout / throttling) + * 416 → transient (resume; total size re-evaluated by the caller) + * other 4xx → permanent (catch-all default) + * 501/505 → permanent; other 5xx → transient (back off and retry) + * anything else → permanent (unknown → permanent, per §4) + */ +internal fun isPermanentHttpStatus(code: Int): Boolean = when (code) { + 401, 403, 404, 410 -> true + 408, 429 -> false + // 416 MUST stay above `in 400..499` (when matches top-to-bottom; 416 is a + // member of that range). Treating 416 as permanent would discard resumable + // `.segN` bytes and restart from byte 0 — the §4 "most damaging mistake". + 416 -> false + in 400..499 -> true + 501, 505 -> true + in 500..599 -> false + else -> true +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt new file mode 100644 index 00000000..8960dc39 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt @@ -0,0 +1,98 @@ +package com.margelo.nitro.reactnativerangedownloader + +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// Dependency-free RangeDownloader adapter logic. +// +// This file holds the DETERMINISTIC, dependency-light pieces of the Nitro +// adapter (ReactNativeRangeDownloader): the run-key derivation that pins +// single-flight semantics, the CAS gate that keeps progress monotonic and +// de-duped, and the per-segment artifact sweep. They were extracted VERBATIM +// (bodies unchanged) from `ReactNativeRangeDownloader.kt` so they can be +// compiled and unit-tested WITHOUT the NitroModules / OneKeyLog dependencies +// or the HybridObject JNI base class (which is device-only). +// +// `ReactNativeRangeDownloader` keeps owning the Nitro wiring, OkHttp client, +// Promise.async flows, sendEvent, SHA/promote — it only DELEGATES these three +// behaviors via `RangeDownloadLogic.`. Mirrors the iOS `RangeDownloadLogic` +// extraction. Everything here is pure Kotlin + java.io.File. +object RangeDownloadLogic { + + // Single-flight key: active downloads are keyed by "channel|taskId" so + // cancel/discardArtifacts can flip the abort flag + stop the worker pool + // BEFORE deleting files, instead of racing live workers that would resurrect + // a just-deleted .partial. Channel is identified by its enum name. + fun runKey(channelName: String, taskId: String): String = "$channelName|$taskId" + + // Single-flight registry of in-flight downloads keyed by "channel|taskId". + // + // Extracted VERBATIM from the adapter's inline `activeDownloads` + // ConcurrentHashMap usage so the keyed single-flight invariants can be unit + // tested WITHOUT the Nitro / JNI / OkHttp dependencies. The adapter delegates + // to one instance; behaviour is byte-for-byte the same: + // - [start] registers a fresh handle under the key, overwriting any prior + // one (dedup: `map[key] = handle`, adapter:87). + // - [finish] does an IDENTITY-checked `map.remove(key, handle)` so a + // concurrent [cancel] that already replaced the handle is NOT clobbered + // (adapter:112 invariant: "only deregister our own handle"). + // - [cancel] atomically removes + `.cancel()`s the live handle if present + // (adapter:206). + // + // ConcurrentHashMap gives the atomic put / identity-remove / remove primitives; + // no extra lock is needed. + class RunRegistry { + private val active = + ConcurrentHashMap() + + /** Live entry count — for assertions/diagnostics (no leaked keys on success/cancel). */ + val size: Int get() = active.size + + /** Register a fresh handle for [channelName]|[taskId], overwriting any prior. */ + fun start(channelName: String, taskId: String): ConcurrentRangeDownloader.CancelHandle { + val handle = ConcurrentRangeDownloader.CancelHandle() + active[runKey(channelName, taskId)] = handle + return handle + } + + /** + * Deregister [handle] for the key — but ONLY if it is still the live handle. + * A concurrent [cancel] that replaced it must not be clobbered. + */ + fun finish(channelName: String, taskId: String, handle: ConcurrentRangeDownloader.CancelHandle) { + active.remove(runKey(channelName, taskId), handle) + } + + /** Remove + cancel the live handle for the key, if any. No-op when absent. */ + fun cancel(channelName: String, taskId: String) { + active.remove(runKey(channelName, taskId))?.cancel() + } + } + + // CAS gate for progress events. The progress callback is invoked concurrently + // by the helper's worker threads; only the thread that advances the + // percentage to a strictly higher value should emit, which keeps progress + // monotonic and de-duped without a lock (this only affects event ordering, + // never file bytes). + // + // Returns the percentage to emit, or null when this transfer/total tuple does + // not advance past [previousProgress] (no event). Callers feed the result + // back as the new previous via their AtomicInteger CAS. + fun progressPercent(transferred: Long, total: Long): Int? { + if (total <= 0) return null + return ((transferred * 100) / total).toInt().coerceIn(0, 100) + } + + // Delete every sibling artifact for [destFilePath]: all `.partial.seg` + // segment files (matched by filename prefix, so any segmentCount is swept, not + // just the shipped default) plus the concatenated `.partial` itself. + // Glob by filename prefix so a future resume can't re-trust stale bytes (no + // `.progress` manifest exists anymore in the segmented model). + fun sweepPartialArtifacts(destFilePath: String) { + val partial = File("$destFilePath.partial") + partial.parentFile + ?.listFiles { f -> f.name.startsWith(partial.name + ".seg") } + ?.forEach { it.delete() } + partial.delete() + } +} diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index f80fc2aa..8835e4c2 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -6,7 +6,6 @@ import com.margelo.nitro.core.Promise import com.margelo.nitro.nativelogger.OneKeyLog import java.io.File import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong @@ -37,12 +36,10 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { // Active downloads keyed by "channel|taskId" so cancel/discardArtifacts can // flip the abort flag + stop the worker pool BEFORE deleting files, instead of - // racing live workers that would resurrect a just-deleted .partial. - private val activeDownloads = - ConcurrentHashMap() - - private fun runKey(channel: DownloadChannel, taskId: String): String = - "${channel.name}|$taskId" + // racing live workers that would resurrect a just-deleted .partial. The keyed + // single-flight logic lives in RangeDownloadLogic.RunRegistry (dependency-free, + // unit-tested); this class only supplies the channel/taskId. + private val activeDownloads = RangeDownloadLogic.RunRegistry() // HTTPS-only client: reject any redirect to a non-HTTPS hop. Mirrors the // existing react-native-bundle-update configuration verbatim. @@ -82,9 +79,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { sendEvent(channel, taskId, type = "start") - val runKey = runKey(channel, taskId) - val cancelHandle = ConcurrentRangeDownloader.CancelHandle() - activeDownloads[runKey] = cancelHandle + val cancelHandle = activeDownloads.start(channel.name, taskId) // The progress callback is invoked concurrently by the helper's worker // threads, so guard lastProgress with an AtomicInteger + CAS: only the @@ -99,8 +94,8 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { minConcurrentBytes = minConcurrentBytes, log = { msg -> OneKeyLog.info("RangeDownloader", msg) }, ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total -> - if (total > 0) { - val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) + val p = RangeDownloadLogic.progressPercent(transferred, total) + if (p != null) { val prev = lastProgress.get() if (p > prev && lastProgress.compareAndSet(prev, p)) { sendEvent(channel, taskId, type = "progress", progress = p) @@ -109,18 +104,26 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { } } finally { // Only deregister our own handle (a concurrent cancel may have replaced it). - activeDownloads.remove(runKey, cancelHandle) + activeDownloads.finish(channel.name, taskId, cancelHandle) } if (outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) { - // Concurrency unusable. The helper has already cleaned up its own - // artifacts where appropriate; the caller runs its single-stream path. - OneKeyLog.info("RangeDownloader", "download: concurrent not used, returning fallback") + // OCDS §4 wire mapping (parity with the iOS shim). On Android the core + // helper splits the two §4 classes by RETURN vs THROW: a RESUMABLE + // (transient) interruption — network drop / incomplete segment — is thrown + // and surfaces below as a promise rejection (the `.segN`/`.partial` are + // kept). `Outcome.FALLBACK` is therefore the PERMANENT class only: range + // unsupported / a 200 to a Range request / the object is too small / a + // single-stream leftover. The helper has already wiped its concurrent + // artifacts on this path, so it maps to `FALLBACKPERMANENT` with the + // `RANGEUNSUPPORTED` sub-kind, and the caller restarts single-stream. + OneKeyLog.info("RangeDownloader", "download: concurrent not used, returning permanent fallback") sendEvent(channel, taskId, type = "fallback", message = "concurrent unavailable") return@async RangeDownloadResult( - outcome = RangeDownloadOutcome.FALLBACK, + outcome = RangeDownloadOutcome.FALLBACKPERMANENT, filePath = destFilePath, fallbackReason = "concurrent unavailable (range unsupported / 200 / too small / single-stream partial)", + fallbackKind = RangeFallbackKind.RANGEUNSUPPORTED, ) } @@ -152,6 +155,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { outcome = RangeDownloadOutcome.COMPLETED, filePath = destFilePath, fallbackReason = null, + fallbackKind = null, ) } } @@ -194,19 +198,14 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { // Flip the abort flag + shutdown the pool for an in-flight download (if any). private fun cancelActive(channel: DownloadChannel, taskId: String) { - activeDownloads.remove(runKey(channel, taskId))?.cancel() + activeDownloads.cancel(channel.name, taskId) } // Delete every sibling artifact for [destFilePath]: all `.partial.seg` // segment files (matched by filename prefix, so any segmentCount is swept, not // just the shipped default) plus the concatenated `.partial` itself. - private fun sweepPartialArtifacts(destFilePath: String) { - val partial = File("$destFilePath.partial") - partial.parentFile - ?.listFiles { f -> f.name.startsWith(partial.name + ".seg") } - ?.forEach { it.delete() } - partial.delete() - } + private fun sweepPartialArtifacts(destFilePath: String) = + RangeDownloadLogic.sweepPartialArtifacts(destFilePath) // Atomically replace [dest] with [src] so a kill mid-finalize never leaves // NEITHER file. On API 26+ uses Files.move with ATOMIC_MOVE/REPLACE_EXISTING diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt new file mode 100644 index 00000000..132f482c --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt @@ -0,0 +1,554 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * OCDS §6 conformance scenarios driving the REAL + * [ConcurrentRangeDownloader.download] against the [RangeFaultServer] + * MockWebServer harness. Each test is named with its OCDS-T number and asserts + * on the recorded ranges, the on-disk artifacts (`.partial` / `.segN`), and the + * assembled bytes (SHA-256 vs source). + * + * Out of scope of this CORE suite (asserted/explained, never forced): + * - OCDS-T6 (whole-file SHA mismatch → single-stream retry once → terminal): + * the core does NOT compute a whole-file checksum. On COMPLETED it leaves the + * assembled bytes at `partialFilePath`; the CALLER renames + SHA-256/GPG + * verifies and drives the retry-once-then-terminal policy. Not a core test. + * - OCDS-T11 (two concurrent download() for the same dest → serialized / one + * fails fast, artifacts never co-written): single-flight is a CALLER-level + * per-taskId registry concern (the adapter), not in this dependency-free core. + * - OCDS-T9 (cross-relaunch attempt budget / bounded terminal): the budget that + * spans process relaunches lives in the shared-JS caller and is tested there. + * The core's in-process bound (maxPartRetry) is exercised indirectly by T4. + * + * Threading note (from FaultServer.kt): the dispatcher runs concurrently on + * MockWebServer reader threads, so range assertions read the synchronized + * `requestedRanges` only after the download() call has fully returned. + */ +class ConcurrentRangeDownloaderOcdsTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path, + // and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-ocds", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers ------------------------------------------------------------- + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + private fun assertNoArtifactsLeft(partial: String) { + assertFalse(".partial must be wiped", File(partial).exists()) + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // OCDS-T1: drop one segment's connection mid-body a couple of times. + // Only that segment is re-fetched (retries stay in its own window); the + // other segments are requested exactly once; assembled SHA is correct; no + // from-0 restart. + // ------------------------------------------------------------------------ + @Test + fun ocdsT1_midBodyDropOnlyRefetchesAffectedSegment() { + val target = parts[3] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.DROP_DURING_BODY, target.start, target.end, times = 2) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(sha256(content), sha256(File(partial))) + + val ranges = fs.requestedRanges.toList() + // The dropped segment is fetched: initial + 2 retries = 3 times (its window). + // (Resume requests only the not-yet-on-disk tail, but the Range start stays + // inside the segment window — never byte 0 of the file.) + val targetReqs = ranges.filter { it.first in target.start..target.end } + assertTrue( + "dropped segment must be re-fetched at least twice (got ${targetReqs.size})", + targetReqs.size >= 2, + ) + // No from-0 restart: every retry of the target segment starts within its own + // window, never at file byte 0 (unless segment 0 itself is the target — it + // is not here, target is part[3]). + assertTrue( + "no retry of the affected segment may restart at file byte 0", + targetReqs.all { it.first >= target.start }, + ) + // Every OTHER segment is requested exactly once. + for (p in parts) { + if (p.index == target.index) continue + val reqs = ranges.filter { it.first == p.start && it.second == p.end } + assertEquals( + "segment ${p.index} must be requested exactly once", + 1, + reqs.size, + ) + } + // All segments consumed on success. + for (i in 0 until 8) assertFalse(segFile(partial, i).exists()) + } + + // ------------------------------------------------------------------------ + // OCDS-T2: (a) full download completes; (b) resume re-fetches only the tail. + // ------------------------------------------------------------------------ + @Test + fun ocdsT2a_fullDownloadRequestsAllEightRanges() { + val fs = RangeFaultServer(content) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(content.size.toLong(), File(partial).length()) + assertEquals(sha256(content), sha256(File(partial))) + + val ranges = fs.requestedRanges.toList() + for (p in parts) { + val reqs = ranges.filter { it.first == p.start && it.second == p.end } + assertEquals("segment ${p.index} must be requested once", 1, reqs.size) + } + } + + @Test + fun ocdsT2b_resumeRefetchesOnlyMissingSegments() { + val fs = RangeFaultServer(content) + startServer(fs) + + val partial = partialPath() + // Seed parts 0..4 as already-completed `.segN` files (mid-run kill model). + seedSegments(partial, content, completedThrough = 4, segments = 8) + + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(sha256(content), sha256(File(partial))) + + // The probe always issues a "bytes=0-0" request (size/Range capability + // check); it is NOT a segment fetch, so exclude it from the segment counts. + val PROBE = 0L to 0L + val ranges = fs.requestedRanges.toList().filterNot { it == PROBE } + // No SEGMENT request for the seeded windows (parts 0..4) beyond the probe. + for (idx in 0..4) { + val p = parts[idx] + val reqs = ranges.filter { it.first in p.start..p.end } + assertTrue("seeded segment $idx must NOT be re-fetched (got $reqs)", reqs.isEmpty()) + } + // Only the tail (parts 5..7) is fetched, once each. + for (idx in 5..7) { + val p = parts[idx] + val reqs = ranges.filter { it.first == p.start && it.second == p.end } + assertEquals("tail segment $idx must be requested once", 1, reqs.size) + } + } + + // ------------------------------------------------------------------------ + // OCDS-T3: status 200 to a Range request (probe) → FALLBACK + artifacts wiped. + // The probe itself sends Range "bytes=0-0"; a 200 there makes the probe + // report supportsRange=false → FALLBACK before any segment is written, so + // there are no artifacts to wipe (and the test asserts none exist). + // ------------------------------------------------------------------------ + @Test + fun ocdsT3_status200ToRangeFallsBackAndWipesArtifacts() { + // Arm 200 on the probe window (bytes=0-0). The probe's 200 short-circuits to + // FALLBACK; no concurrent path runs. + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STATUS_200, 0L, 0L, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome) + assertNoArtifactsLeft(partial) + } + + // OCDS-T3 (segment-level 200): the probe succeeds (206) but a segment fetch + // gets a 200 (server ignored Range on that connection) → core throws + // FallbackException → Outcome.FALLBACK and wipeArtifacts() clears every `.segN` + // / `.partial`. + @Test + fun ocdsT3_status200OnSegmentFallsBackAndWipesArtifacts() { + val target = parts[2] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STATUS_200, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome) + assertNoArtifactsLeft(partial) + } + + // ------------------------------------------------------------------------ + // OCDS-T4: 429 (+Retry-After: 0) once on a segment → transient retry succeeds. + // SHA correct; the affected segment requested twice; no from-0 restart. + // ------------------------------------------------------------------------ + @Test + fun ocdsT4_status429ThenOkRetriesInPlace() { + val target = parts[5] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STATUS_429_THEN_OK, target.start, target.end, times = 1) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(sha256(content), sha256(File(partial))) + + val ranges = fs.requestedRanges.toList() + // 429 returned an empty body, so the segment has 0 bytes on disk → the retry + // re-requests the SAME full window (start == part.start). Exactly two hits. + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertEquals("affected segment must be requested twice (429 then OK)", 2, targetReqs.size) + assertTrue( + "retry must not restart at file byte 0", + targetReqs.all { it.first == target.start }, + ) + } + + // ------------------------------------------------------------------------ + // OCDS-T5(a): mis-aligned Content-Range → rejected, never silent corruption. + // The core treats a mismatched Content-Range as transient (retry); the + // harness's misaligned fault always shifts the window, so every retry is + // rejected → the segment never completes → download() throws after the + // per-segment budget. The KEY assertion: no corruption — assembly never + // COMPLETED with wrong bytes. + // ------------------------------------------------------------------------ + @Test + fun ocdsT5a_misalignedContentRangeIsRejectedNeverCorrupts() { + val target = parts[1] + val fs = RangeFaultServer(content) + // Fire forever so the misalignment never "expires" into a good 206. + .arm(RangeFaultServer.FaultMode.MISALIGNED_CONTENT_RANGE, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + var threw = false + var outcome: ConcurrentRangeDownloader.Outcome? = null + try { + outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + // Never a corrupt COMPLETED. Either it threw (transient give-up, segments + // kept for resume) or — if a future core maps it to fallback — FALLBACK. + assertTrue( + "misaligned 206 must not produce a COMPLETED outcome", + outcome != ConcurrentRangeDownloader.Outcome.COMPLETED, + ) + assertTrue("misaligned 206 must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) + // No assembled final file with the wrong window was produced. + if (File(partial).exists()) { + assertNotEquals( + "a `.partial` left on disk must NOT be a complete (corrupt) file", + content.size.toLong(), + File(partial).length(), + ) + } + } + + // OCDS-T5(b): over-long body → not written past segment end; rejected. + // The harness fires the over-long body forever, so the segment is dropped on + // each attempt and never completes → throw after budget; crucially the + // neighbour segments are NOT corrupted (each writes its own `.segN`). + @Test + fun ocdsT5b_overLongBodyIsRejectedNoNeighbourCorruption() { + val target = parts[4] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.OVER_LONG_BODY, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + var outcome: ConcurrentRangeDownloader.Outcome? = null + var threw = false + try { + outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + assertTrue( + "over-long 206 must not produce a COMPLETED outcome", + outcome != ConcurrentRangeDownloader.Outcome.COMPLETED, + ) + assertTrue("over-long body must be rejected (threw or fell back)", threw || outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) + // The over-long segment is dropped (deleted) on overrun, so neighbours are + // never spliced. Any neighbour `.segN` that exists must be exactly its + // planned length — no extra bytes leaked across windows. + for (p in parts) { + if (p.index == target.index) continue + val f = segFile(partial, p.index) + if (f.exists()) { + assertTrue( + "neighbour segment ${p.index} must not exceed its planned length", + f.length() <= p.length, + ) + } + } + } + + // OCDS-T5(c): short body → the segment ends short and the tail is resumed + // (Range start advanced, NOT from 0) → final SHA correct. + // + // CORE BEHAVIOUR (verified): a clean short read (a complete 206 whose body is + // shorter than the requested window, no socket error) is NOT retried inside + // the same `download()` call — `downloadSegment` returns after a successful + // `fetchSegment`, so a short segment makes that invocation throw + // "incomplete", leaving the short `.segN` on disk. The resume is + // CROSS-INVOCATION (next relaunch): the second `download()` sees a short + // `.segN`, requests only the missing tail (`Range` start = current seg + // length, which is advanced past part.start), and completes. This test drives + // both invocations to assert the tail-resume + correct final SHA + no from-0 + // restart. + @Test + fun ocdsT5c_shortBodyResumesTailToCorrectSha() { + val target = parts[6] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.SHORT_BODY, target.start, target.end, times = 1) + startServer(fs) + + val partial = partialPath() + // First invocation: the short body leaves segment 6 one byte short, so this + // call throws "incomplete" and keeps the `.segN` for resume. + var firstThrew = false + try { + newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + firstThrew = true + } + assertTrue("short body must leave the first invocation incomplete", firstThrew) + // The short segment is kept on disk, one byte under its planned length. + val seg = segFile(partial, target.index) + assertTrue("short `.segN` must be kept for resume", seg.exists()) + assertEquals("short segment is exactly one byte short", target.length - 1, seg.length()) + + val rangesAfterFirst = fs.requestedRanges.toList() + + // Second invocation (relaunch): the fault has expired (times=1), so the tail + // is served normally and the download completes. + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(sha256(content), sha256(File(partial))) + + // The RESUME requested only the missing tail of segment 6: Range start + // advanced past part.start (= part.end here, the single missing byte), end == + // part.end. Never a from-0 (file byte 0) restart. + val resumeRanges = fs.requestedRanges.toList().drop(rangesAfterFirst.size) + val tailResume = resumeRanges.filter { + it.first > target.start && it.first <= target.end && it.second == target.end + } + assertTrue( + "short body must trigger a tail-only resume (start advanced, not from 0)", + tailResume.isNotEmpty(), + ) + } + + // ------------------------------------------------------------------------ + // OCDS-T8: cancel mid-download → run stops promptly; cancel-then-delete works + // (artifacts can be cleaned without a straggler resurrecting them). + // Strategy: stall every segment (1 byte/sec) so the download is in-flight, + // then cancel() from another thread. cancel() shuts the pool down and waits + // (bounded) for workers to terminate, so a subsequent delete is durable. + // ------------------------------------------------------------------------ + @Test + fun ocdsT8_cancelMidDownloadStopsAndArtifactsStayDeleted() { + val fs = RangeFaultServer(content) + // Stall ALL eight segment windows so the workers are parked in body reads. + for (p in parts) { + fs.arm(RangeFaultServer.FaultMode.STALL, p.start, p.end, times = Int.MAX_VALUE) + } + startServer(fs) + + val partial = partialPath() + val handle = ConcurrentRangeDownloader.CancelHandle() + val downloader = newDownloader() + + val resultRef = AtomicReference(null) + val started = CountDownLatch(1) + val worker = Executors.newSingleThreadExecutor() + val future = worker.submit { + started.countDown() + try { + resultRef.set( + downloader.download( + url = url(), + partialFilePath = partial, + cancelHandle = handle, + onProgress = { _, _ -> }, + ) + ) + } catch (e: Exception) { + resultRef.set(e) + } + } + + // Wait until the download has begun, give it a beat to open the segment + // sockets, then cancel. + started.await(5, TimeUnit.SECONDS) + Thread.sleep(400) + val cancelStart = System.currentTimeMillis() + handle.cancel() + + // download() must return promptly after cancel (well under the 60s the stall + // would otherwise take). cancel() awaits worker termination (<=3s) + the + // download() loop joins futures; allow a generous bound. + future.get(15, TimeUnit.SECONDS) + val elapsed = System.currentTimeMillis() - cancelStart + worker.shutdownNow() + + assertTrue("cancel must abort promptly (took ${elapsed}ms)", elapsed < 12_000) + + // cancel-then-delete: now that workers are terminated, deleting the artifacts + // must STICK — no straggler worker resurrects a `.segN`. + File(partial).delete() + for (i in 0 until 8) segFile(partial, i).delete() + // Give any (forbidden) straggler a window to (incorrectly) recreate a file. + Thread.sleep(500) + assertNoArtifactsLeft(partial) + + // The aborted run did not produce a COMPLETED outcome. + assertNotEquals( + "cancelled run must not report COMPLETED", + ConcurrentRangeDownloader.Outcome.COMPLETED, + resultRef.get(), + ) + } + + // ------------------------------------------------------------------------ + // OCDS-T10: stalled socket (bytes trickle past the read timeout) → the + // read-timeout surfaces as a transient error → retry succeeds. + // The default OkHttpClient read timeout is 10s, too slow for a fast unit + // test, so we inject a client with a 600ms read timeout. The harness stalls + // the segment ONCE (times=1): the first attempt times out mid-body, the core + // retries, and the second attempt (fault expired) streams the full window. + // ------------------------------------------------------------------------ + @Test + fun ocdsT10_stalledSocketTimesOutAsTransientThenRetrySucceeds() { + val target = parts[7] + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STALL, target.start, target.end, times = 1) + startServer(fs) + + // Inject a small read timeout so the stall (1 byte/sec) trips it fast. + val fastClient = OkHttpClient.Builder() + .readTimeout(600, TimeUnit.MILLISECONDS) + .build() + val downloader = ConcurrentRangeDownloader( + httpClient = fastClient, + segmentCount = 8, + // Tiny backoff so the single retry runs fast. + retryBaseDelayMillis = 50L, + retryMaxDelayMillis = 200L, + ) + + val partial = partialPath() + val outcome = downloader.download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(sha256(content), sha256(File(partial))) + + val ranges = fs.requestedRanges.toList() + // The stalled segment is requested more than once (timeout → transient retry). + // The retry may resume from whatever partial bytes the stall delivered + // (start >= part.start), never from file byte 0. + val targetReqs = ranges.filter { it.first in target.start..target.end && it.second == target.end } + assertTrue( + "stalled segment must be retried (>=2 requests, got ${targetReqs.size})", + targetReqs.size >= 2, + ) + assertTrue( + "stall retry must not restart at file byte 0", + targetReqs.all { it.first >= target.start }, + ) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt new file mode 100644 index 00000000..fcf97fb4 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt @@ -0,0 +1,282 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import java.io.File +import java.security.MessageDigest +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Reusable JVM test harness for [ConcurrentRangeDownloader] — the analogue of + * the desktop e2e "fault server", implemented as an OkHttp [MockWebServer] + * [Dispatcher] rather than a real HTTP server process. + * + * Threading: a custom [Dispatcher.dispatch] is invoked on MockWebServer's + * internal per-connection reader threads. The core opens up to 8 sockets in + * parallel (one per pending segment) plus the probe, so [dispatch] is called + * CONCURRENTLY. Every piece of mutable state here (request log, fault-mode hit + * counters) is therefore thread-safe (synchronized list / atomic counters). + * MockWebServer serves each connection on its own thread, so 8 parallel range + * requests are genuinely concurrent — there is no per-request serialization to + * mask a concurrency bug in the core. + */ +class RangeFaultServer(private val content: ByteArray) { + + private val total: Long = content.size.toLong() + private val etag: String = "\"" + sha256(content).substring(0, 16) + "\"" + + /** Every [start,end] (inclusive) the server was asked to serve. Thread-safe. */ + val requestedRanges: MutableList> = + Collections.synchronizedList(ArrayList()) + + /** Count of every request received (incl. probe + retries). Thread-safe. */ + private val faults = ConcurrentHashMap() + + /** + * A fault that targets exactly one byte range. [times] auto-expires: after it + * has fired [times] times the matching request succeeds normally, so a + * scenario can fail one segment N times then let the retry through. + */ + private class Fault( + val targetStart: Long, + val targetEnd: Long, + val times: Int, + ) { + var fired: Int = 0 + } + + enum class FaultMode { + /** SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY — drop mid-body. */ + DROP_DURING_BODY, + + /** Ignore Range, return 200 + full body (core → FALLBACK). */ + STATUS_200, + + /** 429 with Retry-After: 0, then a normal 206 on the retry. */ + STATUS_429_THEN_OK, + + /** 5xx transient (retried in place). */ + STATUS_5XX, + + /** 416 Range Not Satisfiable (transient per OCDS §4). */ + STATUS_416, + + /** 206 with a Content-Range window that does NOT match the request. */ + MISALIGNED_CONTENT_RANGE, + + /** 206 with the correct window but a total that DISAGREES with the probe (core → FALLBACK). */ + BAD_TOTAL_206, + + /** 206 whose body has MORE bytes than the requested range. */ + OVER_LONG_BODY, + + /** 206 whose body has FEWER bytes than the requested range. */ + SHORT_BODY, + + /** Content-Type: multipart/byteranges (core → FALLBACK). */ + MULTIPART_BYTERANGES, + + /** Throttle the body so the read stalls (NO_RESPONSE-like slow trickle). */ + STALL, + } + + /** + * Arm a fault on the request whose Range is exactly [start]-[end] (inclusive). + * For segment-level faults pass the [Part] window from [planParts]. [times] + * lets the fault auto-expire so the retried request succeeds. + */ + fun arm(mode: FaultMode, start: Long, end: Long, times: Int = 1): RangeFaultServer { + faults[mode] = Fault(start, end, times) + return this + } + + /** Default status code for the [STATUS_5XX] fault (overridable per-arm). */ + var status5xxCode: Int = 503 + + val dispatcher: Dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val range = parseRequestRange(request.getHeader("Range")) + ?: return errorResponse(400) // tests always send a Range + val (start, end) = range + requestedRanges.add(start to end) + + // Find an armed-and-unexpired fault that targets this exact window. + val firingMode = faults.entries.firstOrNull { (_, f) -> + f.targetStart == start && f.targetEnd == end && f.fired < f.times + }?.also { it.value.fired++ }?.key + + if (firingMode == null) { + return normal206(start, end) + } + + return when (firingMode) { + FaultMode.DROP_DURING_BODY -> + ok206Headers(start, end) + .setBody(bufferFor(start, end)) + .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY) + + FaultMode.STATUS_200 -> + MockResponse() + .setResponseCode(200) + .addHeader("ETag", etag) + .addHeader("Content-Length", total.toString()) + .setBody(Buffer().write(content)) + + FaultMode.STATUS_429_THEN_OK -> + MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", "0") + + FaultMode.STATUS_5XX -> + MockResponse().setResponseCode(status5xxCode) + + FaultMode.STATUS_416 -> + MockResponse() + .setResponseCode(416) + .addHeader("Content-Range", "bytes */$total") + + FaultMode.MISALIGNED_CONTENT_RANGE -> { + // Serve the correct slice but advertise a shifted window. + val badStart = start + 1 + val badEnd = end + 1 + MockResponse() + .setResponseCode(206) + .addHeader("ETag", etag) + .addHeader("Content-Range", "bytes $badStart-$badEnd/$total") + .setBody(bufferFor(start, end)) + } + + FaultMode.BAD_TOTAL_206 -> { + // Serve the correct window + body, but advertise a total that disagrees + // with the probe total → core must reject (Content-Range total check). + MockResponse() + .setResponseCode(206) + .addHeader("ETag", etag) + .addHeader("Content-Range", "bytes $start-$end/${total + 1}") + .setBody(bufferFor(start, end)) + } + + FaultMode.OVER_LONG_BODY -> { + // Append one extra byte beyond the requested window. + val extraEnd = minOf(end + 1, total - 1) + ok206Headers(start, end).setBody(bufferFor(start, extraEnd)) + } + + FaultMode.SHORT_BODY -> { + // One byte fewer than requested (read ends early; segment < length). + val shortEnd = (end - 1).coerceAtLeast(start) + ok206Headers(start, end).setBody(bufferFor(start, shortEnd)) + } + + FaultMode.MULTIPART_BYTERANGES -> + ok206Headers(start, end) + .removeHeader("Content-Type") + .addHeader("Content-Type", "multipart/byteranges; boundary=THISISABOUNDARY") + .setBody(bufferFor(start, end)) + + FaultMode.STALL -> + ok206Headers(start, end) + .setBody(bufferFor(start, end)) + // Trickle 1 byte/sec so the read stalls well past any client read + // timeout; the socket policy keeps the connection open meanwhile. + .throttleBody(1, 1, TimeUnit.SECONDS) + } + } + } + + private fun normal206(start: Long, end: Long): MockResponse = + ok206Headers(start, end).setBody(bufferFor(start, end)) + + private fun ok206Headers(start: Long, end: Long): MockResponse = + MockResponse() + .setResponseCode(206) + .addHeader("ETag", etag) + .addHeader("Content-Type", "application/octet-stream") + .addHeader("Content-Range", "bytes $start-$end/$total") + + private fun errorResponse(code: Int): MockResponse = + MockResponse().setResponseCode(code) + + /** Inclusive slice [start,end] of [content] as an okio Buffer. */ + private fun bufferFor(start: Long, end: Long): Buffer { + val from = start.toInt() + val to = (end + 1).coerceAtMost(total).toInt() + return Buffer().write(content, from, to - from) + } + + companion object { + /** Parse a request `Range: bytes=start-end` header → inclusive (start,end). */ + fun parseRequestRange(header: String?): Pair? { + if (header == null) return null + val m = Regex("""bytes=(\d+)-(\d+)""").find(header) ?: return null + val start = m.groupValues[1].toLongOrNull() ?: return null + val end = m.groupValues[2].toLongOrNull() ?: return null + return start to end + } + } +} + +// --------------------------------------------------------------------------- +// Shared helpers (mirror the core so tests assert against the same plan). +// --------------------------------------------------------------------------- + +/** A planned byte range, mirroring [ConcurrentRangeDownloader]'s private Part. */ +data class PlannedPart(val index: Int, val start: Long, val end: Long) { + val length: Long get() = end - start + 1 +} + +/** + * Mirror of the core's `planRanges`: ceil-chunk the total into [segments] + * inclusive windows. Trailing empty parts are dropped exactly as the core does. + */ +fun planParts(total: Long, segments: Int = 8): List { + val parts = ArrayList() + val chunk = (total + segments - 1) / segments + var i = 0 + while (i < segments) { + val start = i * chunk + if (start >= total) break + val end = minOf(start + chunk - 1, total - 1) + parts.add(PlannedPart(parts.size, start, end)) + i += 1 + } + return parts +} + +/** SHA-256 hex digest of a byte array. */ +fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + +/** SHA-256 hex digest of a file's contents. */ +fun sha256(file: File): String = sha256(file.readBytes()) + +/** + * Pre-write the correct `.segN` files for parts 0..[completedThrough] (inclusive) + * so a resume run re-fetches only the tail. Mirrors the core's segment-file + * resume model: a full-length `.segN` is treated as done and skipped. + */ +fun seedSegments( + partialFilePath: String, + content: ByteArray, + completedThrough: Int, + segments: Int = 8, +) { + val parts = planParts(content.size.toLong(), segments) + for (part in parts) { + if (part.index > completedThrough) continue + val segFile = File("$partialFilePath.seg${part.index}") + segFile.parentFile?.let { if (!it.exists()) it.mkdirs() } + segFile.writeBytes(content.copyOfRange(part.start.toInt(), (part.end + 1).toInt())) + } +} + +/** A real [ConcurrentRangeDownloader] over a plain OkHttpClient (no app deps). */ +fun newDownloader(segments: Int = 8): ConcurrentRangeDownloader = + ConcurrentRangeDownloader(OkHttpClient(), segments) diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt new file mode 100644 index 00000000..fa1b7dea --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt @@ -0,0 +1,63 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * OCDS §4 conformance: the HTTP-status → permanent/transient classification. + * Mirrors the JS taxonomy matrix (updateErrorTaxonomy.test.ts) so the four + * platform classifiers stay in lockstep. `true` = permanent, `false` = transient. + */ +class IsPermanentHttpStatusTest { + + @Test + fun permanentStatuses() { + // auth / expired signed URL / gone / not found + for (code in listOf(401, 403, 404, 410)) { + assertTrue("HTTP $code should be permanent", isPermanentHttpStatus(code)) + } + // not-implemented / version-not-supported are permanent even though 5xx + assertTrue("HTTP 501 should be permanent", isPermanentHttpStatus(501)) + assertTrue("HTTP 505 should be permanent", isPermanentHttpStatus(505)) + // every other 4xx → permanent (catch-all default) + for (code in listOf(400, 402, 405, 409, 411, 418, 451, 499)) { + assertTrue("HTTP $code (other 4xx) should be permanent", isPermanentHttpStatus(code)) + } + // unknown / non-HTTP → permanent + for (code in listOf(0, -1, 100, 300, 600, 999)) { + assertTrue("HTTP $code (unknown) should be permanent", isPermanentHttpStatus(code)) + } + } + + @Test + fun transientStatuses() { + // request timeout / throttling + assertFalse("HTTP 408 should be transient", isPermanentHttpStatus(408)) + assertFalse("HTTP 429 should be transient", isPermanentHttpStatus(429)) + // server errors except 501/505 → transient (back off and retry) + for (code in listOf(500, 502, 503, 504, 506, 599)) { + assertFalse("HTTP $code (5xx) should be transient", isPermanentHttpStatus(code)) + } + } + + /** + * The regression this guards: 416 is a member of `400..499`, so a naive + * catch-all would mark it permanent and discard resumable `.segN` bytes. + * It MUST resolve transient; its 4xx neighbours must stay permanent. + */ + @Test + fun rangeNotSatisfiableIsTransientNotPermanent() { + assertFalse("HTTP 416 must be transient", isPermanentHttpStatus(416)) + assertTrue("HTTP 415 (neighbour) should be permanent", isPermanentHttpStatus(415)) + assertTrue("HTTP 417 (neighbour) should be permanent", isPermanentHttpStatus(417)) + } + + /** Exactly-one-class is total: every status in 100..599 returns a Boolean. */ + @Test + fun everyStatusResolvesWithoutThrowing() { + for (code in 100..599) { + isPermanentHttpStatus(code) + } + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt new file mode 100644 index 00000000..a2851e6e --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt @@ -0,0 +1,368 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Focused JVM unit coverage for the **MonotonicProgressGate** — the + * `AtomicInteger(-1)` + CAS emit-gating idiom the Nitro adapter + * ([ReactNativeRangeDownloader]) wraps around its concurrent progress callback. + * + * IMPORTANT: this gate is NOT a standalone extracted symbol — it is the inline + * adapter expression at `ReactNativeRangeDownloader.kt`: + * - line 94: `val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1)` + * - lines 104-105: + * `val prev = lastProgress.get()` + * `if (p > prev && lastProgress.compareAndSet(prev, p)) { …emit… }` + * + * So "driving the real code" here means driving the REAL JDK + * [java.util.concurrent.atomic.AtomicInteger] (the exact type the adapter + * holds) through the verbatim adapter expression. [tryAdvance] below is NOT a + * re-implementation of business logic — it is the literal `p > prev && CAS(prev,p)` + * line copied from the adapter, kept in one place so the concurrency tests can + * call it. The 0..100 clamp and the `total > 0` guard are the CALLER's job and + * live in [RangeDownloadLogic.progressPercent] (driven directly here too); the + * gate is purely "advance to a strictly-higher Int, lock-free". + * + * The RunRegistry / single-flight and segment-artifact-path assertions drive the + * genuinely-extracted [RangeDownloadLogic.runKey] and + * [RangeDownloadLogic.sweepPartialArtifacts] against a real [ConcurrentHashMap] + * (mirroring the adapter's `activeDownloads` put / `remove(key, value)` at lines + * 87 / 112). Pure JVM JUnit — no Nitro / HybridObject / Context, matching the + * established module pattern (see RangeDownloadLogicTest / IsPermanentHttpStatusTest). + */ +class MonotonicProgressGateTest { + + private lateinit var tmpDir: File + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-gate", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + // The VERBATIM adapter gate expression (ReactNativeRangeDownloader.kt:104-105) + // driven against the REAL java.util.concurrent.atomic.AtomicInteger. Returns + // true iff `p` strictly exceeds the current max AND this thread won the CAS. + private fun tryAdvance(gate: AtomicInteger, p: Int): Boolean { + val prev = gate.get() + return p > prev && gate.compareAndSet(prev, p) + } + + // ---- MonotonicProgressGate: single-threaded behavior ---- + + @Test + fun firstCallWithZeroAdvancesFromInitialMinusOne() { + // Initial state is -1 (adapter line 94), so the very first 0% must emit. + val gate = AtomicInteger(-1) + assertTrue("0 must beat the -1 initial and emit", tryAdvance(gate, 0)) + assertEquals(0, gate.get()) + } + + @Test + fun ascendingZeroToHundredEveryCallEmits() { + val gate = AtomicInteger(-1) + for (p in 0..100) { + assertTrue("ascending $p must emit", tryAdvance(gate, p)) + assertEquals(p, gate.get()) + } + assertEquals(100, gate.get()) + } + + @Test + fun duplicateValueDoesNotReEmit() { + val gate = AtomicInteger(-1) + assertTrue("first 5 emits", tryAdvance(gate, 5)) + assertFalse("equal value must NOT re-emit (strictly-greater only)", tryAdvance(gate, 5)) + assertEquals("state unchanged on a no-emit", 5, gate.get()) + } + + @Test + fun outOfOrderLowerValueIsDroppedAndStateHolds() { + val gate = AtomicInteger(-1) + assertTrue("10 emits", tryAdvance(gate, 10)) + assertFalse("a later 7 (< current max 10) must be dropped", tryAdvance(gate, 7)) + assertEquals("state must stay at the high-water mark 10", 10, gate.get()) + // And a subsequent strictly-higher value still advances normally. + assertTrue("11 still emits after a dropped 7", tryAdvance(gate, 11)) + assertEquals(11, gate.get()) + } + + @Test + fun outOfOrderRepeatingStreamEmitsOnlyMonotonicHighWaterMarks() { + // Mirrors the real worker callback shape: out-of-order, repeating percentages. + val gate = AtomicInteger(-1) + val emitted = mutableListOf() + for (p in listOf(10, 10, 25, 25, 20, 50, 50, 49, 100, 100)) { + if (tryAdvance(gate, p)) emitted.add(p) + } + assertEquals(listOf(10, 25, 50, 100), emitted) + assertEquals(100, gate.get()) + } + + // ---- MonotonicProgressGate: concurrency (the load-bearing CAS contract) ---- + + @Test + fun concurrentSameTargetExactlyOneWinsCas() { + // K threads all try to advance -1 -> the SAME target. CAS guarantees exactly + // one winner; all losers see a no-emit; final state == that target. + val k = 64 + val target = 42 + val gate = AtomicInteger(-1) + val ready = CountDownLatch(k) + val go = CountDownLatch(1) + val wins = AtomicInteger(0) + val pool = Executors.newFixedThreadPool(k) + repeat(k) { + pool.submit { + ready.countDown() + go.await(5, TimeUnit.SECONDS) + if (tryAdvance(gate, target)) wins.incrementAndGet() + } + } + ready.await(5, TimeUnit.SECONDS) + go.countDown() + pool.shutdown() + assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS)) + + assertEquals("exactly ONE thread may win the CAS to the same target", 1, wins.get()) + assertEquals("state must equal the advanced target", target, gate.get()) + } + + @Test + fun concurrentRacingIncrementsEmitOncePerDistinctHighWaterUpdate() { + // Many threads each push the SAME ascending sequence 0..100 concurrently. + // The gate is lock-free + monotonic, so across ALL threads each distinct + // high-water value (0..100 -> 101 values) advances exactly once: total true + // returns == 101, regardless of thread count or interleaving. Final == 100. + val threads = 16 + val maxPercent = 100 + val gate = AtomicInteger(-1) + val totalWins = AtomicInteger(0) + val ready = CountDownLatch(threads) + val go = CountDownLatch(1) + val pool = Executors.newFixedThreadPool(threads) + repeat(threads) { + pool.submit { + ready.countDown() + go.await(5, TimeUnit.SECONDS) + for (p in 0..maxPercent) { + if (tryAdvance(gate, p)) totalWins.incrementAndGet() + } + } + } + ready.await(5, TimeUnit.SECONDS) + go.countDown() + pool.shutdown() + assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS)) + + assertEquals( + "true returns must equal the count of distinct strictly-increasing updates (0..100)", + maxPercent + 1, + totalWins.get(), + ) + assertEquals("final state must be the max seen", maxPercent, gate.get()) + } + + @Test + fun concurrentMonotonicityNeverObservesARegression() { + // Under concurrent advancement the gate must never let `get()` go backwards. + // An observer thread samples the gate while writers race it forward; every + // sample must be >= the previous sample. + val gate = AtomicInteger(-1) + val writers = 8 + val maxPercent = 100 + val ready = CountDownLatch(writers) + val go = CountDownLatch(1) + val regressionObserved = AtomicInteger(0) + val pool = Executors.newFixedThreadPool(writers + 1) + + val observer = pool.submit { + var last = Int.MIN_VALUE + // Sample aggressively until the gate reaches the terminal value. + while (gate.get() < maxPercent) { + val now = gate.get() + if (now < last) regressionObserved.incrementAndGet() + last = now + } + if (gate.get() < last) regressionObserved.incrementAndGet() + } + + repeat(writers) { + pool.submit { + ready.countDown() + go.await(5, TimeUnit.SECONDS) + for (p in 0..maxPercent) tryAdvance(gate, p) + } + } + ready.await(5, TimeUnit.SECONDS) + go.countDown() + observer.get(10, TimeUnit.SECONDS) + pool.shutdown() + assertTrue("workers must finish", pool.awaitTermination(10, TimeUnit.SECONDS)) + + assertEquals("observer must never see progress go backwards", 0, regressionObserved.get()) + assertEquals(maxPercent, gate.get()) + } + + // ---- Caller-owned clamp/guard feeding the gate (RangeDownloadLogic.progressPercent) ---- + + @Test + fun callerClampAndGuardOwnTheZeroToHundredRangeNotTheGate() { + // total <= 0 yields no percentage at all (guarded by the caller), so the gate + // is never even consulted. + assertNull("non-positive total emits nothing", RangeDownloadLogic.progressPercent(10, 0)) + assertNull(RangeDownloadLogic.progressPercent(10, -1)) + // Over-100 transferred (momentary cross-segment overshoot) is clamped to 100 + // BEFORE the gate, so the gate only ever sees values in 0..100. + assertEquals(100, RangeDownloadLogic.progressPercent(150, 100)) + // The full pipeline: clamped percentages flow into the gate monotonically. + val gate = AtomicInteger(-1) + val emitted = mutableListOf() + for ((transferred, total) in listOf(0L to 100L, 50L to 100L, 49L to 100L, 150L to 100L)) { + val p = RangeDownloadLogic.progressPercent(transferred, total) ?: continue + if (tryAdvance(gate, p)) emitted.add(p) + } + assertEquals(listOf(0, 50, 100), emitted) + } + + // ---- RunRegistry / single-flight (RangeDownloadLogic.runKey + activeDownloads map) ---- + + @Test + fun runKeyJoinsChannelAndTaskStablyAndCollisionFree() { + // Stable: same inputs -> same key. + assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1")) + assertEquals( + RangeDownloadLogic.runKey("MAIN", "task-1"), + RangeDownloadLogic.runKey("MAIN", "task-1"), + ) + // Collision-free across channel and across taskId. + assertNotEquals( + RangeDownloadLogic.runKey("MAIN", "t"), + RangeDownloadLogic.runKey("OTHER", "t"), + ) + assertNotEquals( + RangeDownloadLogic.runKey("MAIN", "a"), + RangeDownloadLogic.runKey("MAIN", "b"), + ) + } + + @Test + fun singleFlightRegistryDedupesSecondRegisterForSameKey() { + // Models the adapter's `activeDownloads[runKey] = handle` (line 87) single-flight + // map. putIfAbsent rejects a second register for the same key. + val registry = ConcurrentHashMap() + val key = RangeDownloadLogic.runKey("MAIN", "task-7") + val first = Any() + val second = Any() + assertNull("first register wins", registry.putIfAbsent(key, first)) + assertTrue("second register for same key is rejected (returns the incumbent)", + registry.putIfAbsent(key, second) === first) + assertTrue("incumbent handle must remain", registry[key] === first) + // A different key (different taskId) co-exists, not deduped. + val otherKey = RangeDownloadLogic.runKey("MAIN", "task-8") + assertNull(registry.putIfAbsent(otherKey, second)) + assertEquals(2, registry.size) + } + + @Test + fun cancelRemovesOnlyTheMatchingHandleNotAConcurrentReplacement() { + // Mirrors `activeDownloads.remove(runKey, cancelHandle)` (line 112): the + // value-conditional remove only clears OUR handle, so a concurrent cancel that + // already replaced the handle for the same key is left intact. + val registry = ConcurrentHashMap() + val key = RangeDownloadLogic.runKey("MAIN", "task-9") + val ours = Any() + val replacement = Any() + registry[key] = ours + // Simulate a concurrent cancel replacing the handle under the same key. + registry[key] = replacement + // Our finally-block remove must NOT evict the replacement. + assertFalse("value-conditional remove must not evict a replaced handle", + registry.remove(key, ours)) + assertTrue("replacement handle survives", registry[key] === replacement) + // Removing the actual current value succeeds and clears the key. + assertTrue(registry.remove(key, replacement)) + assertNull(registry[key]) + } + + @Test + fun concurrentRegistersForSameKeyOnlyOneWins() { + val registry = ConcurrentHashMap() + val key = RangeDownloadLogic.runKey("MAIN", "race") + val k = 32 + val winners = AtomicInteger(0) + val ready = CountDownLatch(k) + val go = CountDownLatch(1) + val pool = Executors.newFixedThreadPool(k) + repeat(k) { + pool.submit { + val mine = Any() + ready.countDown() + go.await(5, TimeUnit.SECONDS) + if (registry.putIfAbsent(key, mine) == null) winners.incrementAndGet() + } + } + ready.await(5, TimeUnit.SECONDS) + go.countDown() + pool.shutdown() + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)) + assertEquals("exactly one concurrent register may win the single-flight slot", 1, winners.get()) + assertEquals(1, registry.size) + } + + // ---- segment artifact paths / sweep (RangeDownloadLogic.sweepPartialArtifacts) ---- + + @Test + fun segmentArtifactPathsAreDotSegNUnderTheDotPartialPrefix() { + // Drive the REAL sweep: it deletes `.partial` + every + // `.partial.seg` matched by filename prefix, for ANY segment count. + val dest = File(tmpDir, "asset.bin").absolutePath + val partial = File("$dest.partial").apply { writeText("p") } + val count = 8 + val segs = (0 until count).map { File("$dest.partial.seg$it").apply { writeText("$it") } } + // Verify the path shape the adapter/helper assembles for a given filePath+count. + segs.forEachIndexed { i, f -> + assertEquals("seg path must be .partial.seg$i", "$dest.partial.seg$i", f.absolutePath) + } + // A sibling NOT under the `.partial` prefix must survive the sweep. + val unrelated = File("$dest.keep").apply { writeText("k") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse("`.partial` must be swept", partial.exists()) + segs.forEach { assertFalse("${it.name} must be swept", it.exists()) } + assertTrue("unrelated sibling must survive", unrelated.exists()) + } + + @Test + fun sweepClearsCustomSegmentCountByPrefixNotJustDefaultEight() { + val dest = File(tmpDir, "big.bin").absolutePath + File("$dest.partial").writeText("p") + // A custom (non-default) 16-segment run must be fully cleared by prefix. + val segs = (0 until 16).map { File("$dest.partial.seg$it").apply { writeText("$it") } } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + segs.forEach { assertFalse("${it.name} (custom count) must be swept", it.exists()) } + assertFalse(File("$dest.partial").exists()) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt new file mode 100644 index 00000000..9d658d5d --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt @@ -0,0 +1,272 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * OCDS gap A2 (SPEC #4 — 416 Range Not Satisfiable kept TRANSIENT). + * + * Guards the single most-damaging §4 mistake called out in the core comment + * (ConcurrentRangeDownloader.kt lines 590-593): 416 MUST classify as transient + * (`isPermanentHttpStatus` line 593 `416 -> false`, placed ABOVE the + * `in 400..499 -> true` catch-all at line 594). If 416 were permanent the core + * would discard the resumable `.segN` bytes and restart the whole object from + * byte 0 — exactly the "do not discard resumable bytes" rule this test pins. + * + * Scenario: a RESUME run where parts 0..4 are already full on disk (seeded + * `.segN`), and the part[5] window hits a single 416 (`STATUS_416` → + * `416 + Content-Range: bytes star-slash-total`, FaultServer.kt:67,139-142) that then + * succeeds on retry. The load-bearing claims: + * - outcome == COMPLETED and the assembled SHA matches the source (so the 416 + * was recovered, not fatal, and nothing was corrupted); + * - the seeded windows 0..4 are NOT re-fetched — proving the 416 did NOT + * trigger a from-0 wipe/restart of already-downloaded segments; + * - the seeded `.segN` for parts 0..4 are NOT deleted/re-created — their + * on-disk length stays exactly full (the "do not discard resumable bytes" + * guarantee, asserted on the bytes on disk, not just the request log); + * - part[5] is requested exactly TWICE (416 then OK), and every request for + * it starts inside its own window (>= part[5].start), never at file byte 0. + * + * Drives the REAL [ConcurrentRangeDownloader.download] over the + * [RangeFaultServer] harness — no behavior is re-implemented here. + */ +class Ocds416ResumeTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) → concurrent path, 8 non-empty parts. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-ocds-416", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) ------------------ + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + // ------------------------------------------------------------------------ + // OCDS gap A2: 416 on a resume is TRANSIENT — recovered in place, resumable + // bytes preserved, no from-0 restart. + // ------------------------------------------------------------------------ + @Test + fun ocds416OnResumeIsTransientKeepsResumableBytesAndCompletes() { + val target = parts[5] + val fs = RangeFaultServer(content) + // One 416 on the part[5] window, then the retry is served normally. + .arm(RangeFaultServer.FaultMode.STATUS_416, target.start, target.end, times = 1) + startServer(fs) + + val partial = partialPath() + + // Seed parts 0..4 as already-completed `.segN` files (mid-run resume model). + // These full-length segments must survive the 416 untouched. + seedSegments(partial, content, completedThrough = 4, segments = 8) + val seededLengths: Map = (0..4).associateWith { idx -> + val f = segFile(partial, idx) + assertTrue("seed for part $idx must exist before run", f.exists()) + f.length() + } + // Sanity: each seed is exactly its planned full length going in. + for (idx in 0..4) { + assertEquals( + "seed for part $idx must start at full planned length", + parts[idx].length, + seededLengths.getValue(idx), + ) + } + + // Fast backoff so the single 416 retry runs quickly (mirrors ocdsT10). + val downloader = ConcurrentRangeDownloader( + httpClient = OkHttpClient(), + segmentCount = 8, + retryBaseDelayMillis = 50L, + retryMaxDelayMillis = 200L, + ) + + val outcome = downloader.download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + // (1) The 416 was recovered (not fatal) and assembly is byte-correct. + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertEquals(content.size.toLong(), File(partial).length()) + assertEquals(sha256(content), sha256(File(partial))) + + // The probe always issues a "bytes=0-0" capability check; exclude it from the + // segment-window counts (mirrors ocdsT2b lines 184-191). + val PROBE = 0L to 0L + val ranges = fs.requestedRanges.toList().filterNot { it == PROBE } + + // (2) No from-0 wipe/restart: the seeded windows 0..4 are NOT re-fetched. + // A permanent-416 regression would discard `.segN` and re-request these. + for (idx in 0..4) { + val p = parts[idx] + val reqs = ranges.filter { it.first in p.start..p.end } + assertTrue("seeded segment $idx must NOT be re-fetched after a 416 (got $reqs)", reqs.isEmpty()) + } + // And specifically nothing ever re-requested file byte 0 as a segment fetch. + assertTrue( + "no segment fetch may restart at file byte 0 after a 416", + ranges.none { it.first == 0L && it.second == 0L }, + ) + + // (3) The direct on-disk proof that a 416 never discards resumable `.segN` + // bytes lives in `ocds416NeverDiscardsSeededSegmentsOnDisk` (a perpetual 416 + // is held mid-run so the seeded segments can be length-checked on disk). On + // this COMPLETED path the segments are correctly consumed, so the load-bearing + // proof here is the SHA + the request log: seeded windows 0..4 were never + // re-fetched (asserted above) and part[5] recovered in place (asserted next). + + // (4) part[5] is requested exactly TWICE (416 then OK). The 416 has an empty + // body, so part[5] has 0 bytes on disk → the retry re-requests the SAME full + // window (start == part.start, end == part.end), never advancing past it and + // never collapsing to file byte 0. + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertEquals( + "part[5] must be requested exactly twice (416 then OK)", + 2, + targetReqs.size, + ) + assertTrue( + "every part[5] request must start inside its own window (>= part.start), never file byte 0", + targetReqs.all { it.first >= target.start }, + ) + + // (5) The tail beyond part[5] (parts 6..7) was fetched once each — the run + // proceeded normally around the recovered 416 with no global restart. + for (idx in 6..7) { + val p = parts[idx] + val reqs = ranges.filter { it.first == p.start && it.second == p.end } + assertEquals("tail segment $idx must be requested once", 1, reqs.size) + } + + // (6) On COMPLETED every `.segN` is consumed (no straggler artifacts). + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped on success", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // OCDS gap A2 (resumable-bytes guard, no from-0 wipe): drive a run that does + // NOT complete past the 416 so we can observe the seeded `.segN` ON DISK and + // prove a 416 leaves them intact (rather than discarding them and restarting + // from byte 0). We arm 416 FOREVER on part[5] so it never recovers; the run + // exhausts the per-segment retry budget and throws, leaving the seeded + // segments 0..4 untouched on disk for direct length inspection. + // ------------------------------------------------------------------------ + @Test + fun ocds416NeverDiscardsSeededSegmentsOnDisk() { + val target = parts[5] + val fs = RangeFaultServer(content) + // Fire 416 forever so part[5] never recovers and the run gives up — but a + // transient classification must keep the OTHER segments' bytes on disk. + .arm(RangeFaultServer.FaultMode.STATUS_416, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + seedSegments(partial, content, completedThrough = 4, segments = 8) + val seededLengths: Map = (0..4).associateWith { idx -> + segFile(partial, idx).length() + } + + // Fast backoff so the bounded retries exhaust quickly. + val downloader = ConcurrentRangeDownloader( + httpClient = OkHttpClient(), + segmentCount = 8, + retryBaseDelayMillis = 20L, + retryMaxDelayMillis = 80L, + ) + + var threw = false + var outcome: ConcurrentRangeDownloader.Outcome? = null + try { + outcome = downloader.download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + + // A perpetual 416 is transient → retried then given up (throws), NEVER a + // permanent FALLBACK that wipes artifacts, and NEVER a corrupt COMPLETED. + assertTrue( + "a perpetual 416 must give up via a thrown transient exhaustion, not COMPLETED/FALLBACK", + threw, + ) + assertFalse( + "a 416 must not produce a COMPLETED outcome", + outcome == ConcurrentRangeDownloader.Outcome.COMPLETED, + ) + + // THE load-bearing on-disk assertion: the seeded `.segN` for parts 0..4 are + // still present AND still exactly their full planned length. A permanent-416 + // regression (discard resumable bytes + restart from byte 0) would have + // deleted or truncated these — this fails loudly if that ever happens. + for (idx in 0..4) { + val f = segFile(partial, idx) + assertTrue("seeded `.seg$idx` must NOT be discarded by a 416", f.exists()) + assertEquals( + "seeded `.seg$idx` must keep its full resumable length after a 416 (no from-0 wipe)", + seededLengths.getValue(idx), + f.length(), + ) + assertEquals( + "seeded `.seg$idx` length must equal its planned window length", + parts[idx].length, + f.length(), + ) + } + + // The 416 part window was actually exercised (the fault fired), and no + // request collapsed to a from-0 restart of an already-seeded window. + val PROBE = 0L to 0L + val ranges = fs.requestedRanges.toList().filterNot { it == PROBE } + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertTrue("part[5] (the 416 window) must have been requested", targetReqs.isNotEmpty()) + assertTrue( + "every part[5] request stays inside its window; no from-0 restart", + targetReqs.all { it.first >= target.start }, + ) + for (idx in 0..4) { + val p = parts[idx] + assertTrue( + "seeded window $idx must never be re-fetched because of a 416", + ranges.none { it.first in p.start..p.end }, + ) + } + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt new file mode 100644 index 00000000..ef4503b9 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt @@ -0,0 +1,147 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import okhttp3.mockwebserver.MockWebServer +import java.io.File + +/** + * OCDS gap A4 (SPEC #5 — OCDS §5): a 206 whose `Content-Range` total (the + * `/` tail) DISAGREES with the probe total → PERMANENT FALLBACK + * (single-stream), never silent corruption. This is the "object changed size + * behind us" case: the byte window we asked for is served correctly, but the + * server now advertises a different whole-object size, so concurrency is + * unreconcilable and the core must bail to single-stream rather than splice in + * bytes from a different build. + * + * Drives the REAL [ConcurrentRangeDownloader.download] against the + * [RangeFaultServer] MockWebServer harness, using the same conventions as + * [ConcurrentRangeDownloaderOcdsTest] (newDownloader(), planParts, arm(...), + * sha256(File), assertNoArtifactsLeft). + * + * Core path under test (ConcurrentRangeDownloader.kt): + * - parseContentRangeBounds (542-548) returns Triple(start, end, total+1); + * - the bounds check at 494 PASSES (start/end are correct); + * - the total check at 506 (`parsedTotal != total`) FIRES → FallbackException; + * - the worker sets fallback=true (234-237), download() wipeArtifacts() (252) + * and returns Outcome.FALLBACK (253). + * + * The harness BAD_TOTAL_206 fault (FaultServer.kt:155-164) is the only mode that + * produces this: it keeps the correct start/end bounds and the correct body, but + * advertises `/` in the `/` tail — so it exercises the total + * branch in isolation (the bounds branch never trips). + */ +class OcdsBadTotalRejectTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path, + // and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-ocds-a4", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) ------------------ + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + private fun assertNoArtifactsLeft(partial: String) { + assertFalse(".partial must be wiped", File(partial).exists()) + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // A4: a single segment's 206 advertises a Content-Range total of (total+1). + // The probe window (bytes=0-0) is unarmed, so the probe still reports the + // true total and supportsRange=true → the concurrent path runs. When the + // armed segment returns its (correct-window, correct-body) 206 with the + // wrong `/` tail, the core's total check fires FallbackException → + // Outcome.FALLBACK and every artifact is wiped. No corrupt assembly. + // ------------------------------------------------------------------------ + @Test + fun ocdsA4_badContentRangeTotalFallsBackAndWipesArtifacts() { + val target = parts[3] + val fs = RangeFaultServer(content) + // Fire forever so the bad total never "expires" into a good 206 — the + // window must be permanently unusable for concurrency. + .arm(RangeFaultServer.FaultMode.BAD_TOTAL_206, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + // Load-bearing: a disagreeing concrete total is permanent for this window, + // so the core must signal single-stream fallback (NOT COMPLETED, NOT a + // transient throw). This assertion fails if the line-506 total check is + // removed/weakened or mis-classified as transient. + assertEquals( + "Content-Range total mismatch must yield FALLBACK", + ConcurrentRangeDownloader.Outcome.FALLBACK, + outcome, + ) + assertNotEquals( + "bad-total 206 must NOT complete the concurrent download", + ConcurrentRangeDownloader.Outcome.COMPLETED, + outcome, + ) + + // Load-bearing: no corruption — the fallback path wipes every `.partial` / + // `.segN`, so the caller's single-stream retry starts from a clean slate and + // no half-written segment from a differently-sized object survives. + assertNoArtifactsLeft(partial) + + // Defensive: even if a `.partial` were (incorrectly) left behind, it must + // never be a full-length (i.e. silently-assembled / corrupt) file. + if (File(partial).exists()) { + assertNotEquals( + "a leftover `.partial` must NOT be a complete (corrupt) file", + content.size.toLong(), + File(partial).length(), + ) + } + + // Load-bearing: the offending window WAS actually fetched (so the fallback + // is provably driven by the bad-total 206, not by an earlier probe bail). + // The probe issues bytes=0-0; the target window 3 is a real segment fetch. + val ranges = fs.requestedRanges.toList() + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertTrue( + "the bad-total segment window must have been requested (got ${targetReqs.size})", + targetReqs.isNotEmpty(), + ) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt new file mode 100644 index 00000000..4fcc205f --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt @@ -0,0 +1,114 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import okhttp3.mockwebserver.MockWebServer +import java.io.File + +/** + * OCDS §5 conformance — gap A3 (SPEC #5): a `multipart/byteranges` body served + * to a SINGLE Range request must be REJECTED as a PERMANENT fallback (never + * spliced raw into the segment file), and every on-disk artifact must be wiped. + * + * This is the multipart TWIN of [ConcurrentRangeDownloaderOcdsTest]'s + * `ocdsT3_status200OnSegmentFallsBackAndWipesArtifacts`: same + * FallbackException → wipeArtifacts → Outcome.FALLBACK contract, different + * trigger. Here the core's branch at ConcurrentRangeDownloader.kt:480-483 + * (Content-Type startsWith "multipart/byteranges" → FallbackException) is the + * one under test. + * + * It drives the REAL [ConcurrentRangeDownloader.download] against the + * [RangeFaultServer] MockWebServer harness — no re-implementation of core + * logic. The probe (bytes=0-0) is NOT armed, so it returns a normal 206 and the + * concurrent path actually starts; only the targeted segment window is armed + * with [RangeFaultServer.FaultMode.MULTIPART_BYTERANGES], so the worker hits the + * multipart body, throws FallbackException, and the run falls back + wipes. + */ +class OcdsMultipartRejectTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path, + // and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-ocds-multipart", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers (identical conventions to ConcurrentRangeDownloaderOcdsTest) -- + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + private fun assertNoArtifactsLeft(partial: String) { + assertFalse(".partial must be wiped", File(partial).exists()) + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // OCDS §5 / gap A3: multipart/byteranges body on a SEGMENT fetch. + // The probe succeeds (206), the concurrent path starts, then the armed + // segment fetch receives a 206 whose Content-Type is + // "multipart/byteranges; boundary=THISISABOUNDARY". The core rejects it as + // a PERMANENT fallback (ConcurrentRangeDownloader.kt:480-483 → + // FallbackException), the worker sets fallback=true + aborted=true + // (lines 234-237), and after the futures join fallback.get() is true → + // wipeArtifacts (lines 250-253) → Outcome.FALLBACK. No boundary bytes are + // spliced into any segment; every `.segN` / `.partial` is wiped. + // ------------------------------------------------------------------------ + @Test + fun ocdsA3_multipartByterangesOnSegmentFallsBackAndWipesArtifacts() { + val target = parts[2] + val fs = RangeFaultServer(content) + // Fire forever so the multipart body never "expires" into a good 206 — + // the segment can never complete, the only resolution is fallback. + .arm(RangeFaultServer.FaultMode.MULTIPART_BYTERANGES, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + // Load-bearing: PERMANENT fallback (not COMPLETED, not a transient throw). + // If the multipart branch regressed (e.g. the body was streamed raw into + // the segment file), this would assemble corrupt bytes and report COMPLETED + // instead — the equality fails. + assertEquals(ConcurrentRangeDownloader.Outcome.FALLBACK, outcome) + + // Load-bearing: no corruption left behind. The `.partial` and ALL eight + // `.segN` files MUST be wiped. A raw multipart body spliced into `.seg2` + // (the regression) would leave a boundary-contaminated artifact here, and + // the assembled `.partial` (if it ever formed) would survive — both would + // fail assertNoArtifactsLeft. + assertNoArtifactsLeft(partial) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt new file mode 100644 index 00000000..59f876d1 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt @@ -0,0 +1,250 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * OCDS gap A6 (SPEC §5.2 disk) — the segment-file design exists SPECIFICALLY to + * survive near-full / read-only filesystems: there is NO `RandomAccessFile. + * setLength(total)` pre-allocation (which fails EROFS/ENOSPC up front on f2fs), + * only plain O_WRONLY sequential appends + O_RDONLY reads. This suite drives the + * REAL [ConcurrentRangeDownloader.download] against a directory we flip to + * read-only with [File.setWritable] and asserts the download fails CLEANLY: + * + * - it throws / returns a NON-COMPLETED outcome (never a corrupt COMPLETED), + * - it leaves NO partial/corrupt assembled artifact (`.partial` is not created + * in the read-only dir, so nothing half-written can be promoted), + * - it does NOT data-loss the resumable `.segN` inputs (the read-only dir + * blocks NEW-file creation but the already-present segment files survive, + * readable, full-length — so a later attempt on a writable dir resumes), and + * - it does NOT crash-loop: once the dir is writable again the SAME inputs + * assemble idempotently to the correct whole-file SHA-256. + * + * Conventions mirror [ConcurrentRangeDownloaderOcdsTest]: 3 MiB content over 8 + * segments, [newDownloader] / [planParts] / [seedSegments] / [RangeFaultServer] + * / [sha256] / [assertNoArtifactsLeft]. The probe (`bytes=0-0`) is READ-only, so + * a read-only dir does not block it — the failure must come from the concat + * write, exactly as §5.2 describes. + * + * Out of scope (asserted/explained, never forced): the fsync DURABILITY guarantee + * itself (concatenate(): `out.fd.sync()`) is not directly observable from the JVM + * — a JVM test cannot prove the bytes reached the platter, only that the assemble + * → size-guard → (would-be) rename pipeline is correct and crash-safe. What §5.2 + * IS testable for is asserted below: concat over a read-only dir fails cleanly, + * and committed-prefix concat is idempotent. + */ +class OcdsReadOnlyFsTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // Same shape as the core OCDS suite: 3 MiB > minConcurrentBytes (2 MiB) so the + // concurrent path is taken, ceil-chunked over 8 non-empty segments. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-a6-rofs", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + // Always restore writability so deleteRecursively can clean up even if a test + // body asserts before flipping it back. + tmpDir.setWritable(true) + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers (identical conventions to ConcurrentRangeDownloaderOcdsTest) ---- + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + private fun assertNoArtifactsLeft(partial: String) { + assertFalse(".partial must be wiped", File(partial).exists()) + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // A6(a): concat into a READ-ONLY directory fails CLEANLY. + // Seed all 8 `.segN` (so download() skips fetching and goes straight to the + // concat — concatenate() at :332), then flip the dir read-only. The concat's + // `FileOutputStream(partialFile, append=true)` (:344) cannot CREATE the + // `.partial` in a read-only dir → FileNotFoundException propagates out of + // download(). Load-bearing assertions: it THREW, NO COMPLETED outcome, and NO + // `.partial` artifact exists (nothing half-written to promote). Crucially the + // resumable `.segN` inputs survive full-length (a read-only dir blocks NEW + // files, not reads of existing ones), so no data is lost. + // ------------------------------------------------------------------------ + @Test + fun a6a_concatIntoReadOnlyDirFailsCleanlyNoArtifactNoDataLoss() { + val fs = RangeFaultServer(content) + startServer(fs) + + val partial = partialPath() + // All 8 segments present and full-length → planning marks none pending, the + // worker pool never runs, and download() proceeds directly to concatenate(). + seedSegments(partial, content, completedThrough = 7, segments = 8) + for (i in 0 until 8) { + assertTrue("seeded segment $i must exist before RO flip", segFile(partial, i).exists()) + } + + // Make the destination directory read-only. The probe (bytes=0-0) is a pure + // read, so it still succeeds; the ONLY thing that fails is the concat write. + assertTrue("test precondition: setWritable(false) must succeed", tmpDir.setWritable(false)) + assertFalse("test precondition: dir must now be read-only", tmpDir.canWrite()) + + var threw = false + var outcome: ConcurrentRangeDownloader.Outcome? = null + try { + outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + + // CLEAN failure: a read-only filesystem must surface as a thrown error, never + // a corrupt success. (No setLength pre-allocation crash; the plain O_WRONLY + // open simply fails and the exception propagates.) + assertTrue("read-only concat must throw (clean failure, not a crash-loop)", threw) + assertNotEquals( + "read-only filesystem must NOT yield a COMPLETED outcome", + ConcurrentRangeDownloader.Outcome.COMPLETED, + outcome, + ) + // NO partial/corrupt assembled artifact: the read-only dir refuses to create + // the `.partial`, so there is nothing half-written for the caller to promote. + assertFalse( + "no `.partial` may be created in a read-only dir", + File(partial).exists(), + ) + // NO data loss: the resumable inputs survive, each at its exact planned + // length, so a later attempt on a writable dir resumes (no from-0 restart). + for (p in parts) { + val seg = segFile(partial, p.index) + assertTrue("segment ${p.index} must survive the read-only failure", seg.exists()) + assertEquals( + "surviving segment ${p.index} must keep its full planned length", + p.length, + seg.length(), + ) + } + + // No crash-loop: restoring writability lets the SAME inputs assemble cleanly. + // This drives the REAL concat over the surviving `.segN` and proves the prior + // read-only failure left a perfectly resumable state. + assertTrue("restore: setWritable(true) must succeed", tmpDir.setWritable(true)) + val retryOutcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + assertEquals( + "retry on a writable dir must COMPLETE from the surviving segments", + ConcurrentRangeDownloader.Outcome.COMPLETED, + retryOutcome, + ) + // Load-bearing: a REAL SHA-256 over the REAL assembled file vs the source. A + // from-0 restart, a corrupt concat, or a misordered splice would fail here. + assertEquals(sha256(content), sha256(File(partial))) + // Concat consumed every segment on success. + for (i in 0 until 8) assertFalse(segFile(partial, i).exists()) + } + + // ------------------------------------------------------------------------ + // A6(b): COMMITTED-PREFIX idempotent concat (§5.2 "assemble is idempotent / + // committed-prefix resume"). Model a concat that was interrupted PART-WAY: + // all 8 `.segN` are present AND the `.partial` already holds the first K + // segments' combined extent (a mid-concat-interrupt snapshot). On a writable + // dir, download() must NOT re-append the already-committed prefix (append + // writes at EOF; concatenate() skips bytes below `written` at :348-352) and + // must finish to the exact correct whole-file SHA — i.e. running concat over a + // partially-committed `.partial` is idempotent and never double-writes the + // prefix or restarts from 0. + // ------------------------------------------------------------------------ + @Test + fun a6b_committedPrefixConcatIsIdempotentToCorrectSha() { + val fs = RangeFaultServer(content) + startServer(fs) + + val partial = partialPath() + // All 8 segments full-length on disk (a clean "all fetched" state). + seedSegments(partial, content, completedThrough = 7, segments = 8) + + // Pre-write a `.partial` equal to the first 3 segments' combined extent — the + // exact byte image an interrupted concat would have left (the first K segments + // already appended, in order, into `.partial`). Use the genuine source bytes + // so the committed prefix is correct; concatenate() must extend, not rewrite. + val k = 3 + val committedExtent = parts.take(k).sumOf { it.length } + File(partial).writeBytes(content.copyOfRange(0, committedExtent.toInt())) + assertEquals( + "precondition: pre-committed `.partial` spans the first $k segments", + committedExtent, + File(partial).length(), + ) + // The committed-prefix model also allows the consumed segments' `.segN` to be + // gone (an interrupted concat deletes each seg after appending it). Delete the + // first K to exercise the "committed prefix segment whose `.segN` is gone is + // still DONE" branch (:201-223) — it must NOT be re-fetched (the probe is the + // only network the server should see beyond none). + for (i in 0 until k) segFile(partial, i).delete() + + val outcome = newDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + // Load-bearing: idempotent concat must land EXACTLY the source bytes. A + // double-appended prefix would overshoot total (caught by the :376 size guard + // → delete+throw, so we would NOT be COMPLETED) or, if it slipped through, + // mismatch this SHA. + assertEquals(content.size.toLong(), File(partial).length()) + assertEquals(sha256(content), sha256(File(partial))) + + // The committed-prefix segments (whose `.segN` we deleted) must NOT have been + // re-fetched: the only request the server should ever see is the probe + // (bytes=0-0). Any real segment fetch here would prove the committed prefix + // was wrongly treated as missing (a from-disk restart, the §4 cardinal sin). + val PROBE = 0L to 0L + val segmentRequests = fs.requestedRanges.toList().filterNot { it == PROBE } + assertTrue( + "no segment may be re-fetched when all are on disk / committed (got $segmentRequests)", + segmentRequests.isEmpty(), + ) + // Concat consumed every remaining segment on success. (The `.partial` is the + // assembled result the caller promotes — it is NOT wiped on COMPLETED; only + // the `.segN` inputs are consumed. Its full-length correctness is already + // asserted above via the size + SHA-256 checks.) + for (i in 0 until 8) { + assertFalse(".seg$i must be consumed on success", segFile(partial, i).exists()) + } + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt new file mode 100644 index 00000000..dc3f0305 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt @@ -0,0 +1,275 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * OCDS gap A1 — §4 HTTP status classification, the TRANSIENT 5xx branch + * (`isPermanentHttpStatus`: `in 500..599 -> false`, + * ConcurrentRangeDownloader.kt:596) driven end-to-end through + * `classifyHttpFailure` (line 555-559 → [TransientHttpException]) and the + * `downloadSegment` retry-in-place loop (line 410-419) against the REAL + * [ConcurrentRangeDownloader] over the [RangeFaultServer] MockWebServer harness. + * + * Two scenarios: + * - [transient5xxThenOkRetriesInPlaceAndAssembles]: a 503 fired a finite number + * of times on ONE segment window, then it recovers. Asserts per-segment retry, + * segments kept (no from-0 restart, no FALLBACK), recovery 206, final SHA over + * the real assembled file, and that EVERY OTHER segment is requested once. + * - [transient5xxBudgetExhaustionThrowsKeepingArtifactsForResume]: a 503 fired + * forever on one window exhausts the per-segment retry budget (maxPartRetry=3 + * → initial + 3 retries = exactly 4 hits) → `download()` throws (transient + * give-up, NOT a corrupt COMPLETED, NOT a FALLBACK that wipes artifacts). + * + * The harness's default [RangeFaultServer.status5xxCode] is 503 (FaultServer.kt + * :100, served with an empty body at lines 136-137), so each fired attempt + * leaves 0 bytes on disk for that segment → the retry re-requests the SAME full + * window (start == part.start), mirroring OCDS-T4's 429 assertion. + */ +class OcdsTransient5xxTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) → the core takes the concurrent path, + // and 3 MiB ceil-chunked over 8 segments yields 8 non-empty parts. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + private val parts: List by lazy { planParts(content.size.toLong(), 8) } + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-ocds-5xx", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + if (this::server.isInitialized) server.shutdown() + tmpDir.deleteRecursively() + } + + // --- helpers (mirror ConcurrentRangeDownloaderOcdsTest) ------------------- + + private fun startServer(faultServer: RangeFaultServer) { + server = MockWebServer() + server.dispatcher = faultServer.dispatcher + server.start() + } + + private fun url() = server.url("/asset.bin").toString() + + private fun partialPath() = File(tmpDir, "asset.bin.partial").absolutePath + + private fun segFile(partial: String, index: Int) = File("$partial.seg$index") + + private fun assertNoArtifactsLeft(partial: String) { + assertFalse(".partial must be wiped", File(partial).exists()) + for (i in 0 until 8) { + assertFalse(".seg$i must be wiped", segFile(partial, i).exists()) + } + } + + // Fast-backoff downloader form (mirrors OCDS-T10) so the single/few retries + // run fast in a unit test. Default maxPartRetry (3) is preserved so the + // budget-exhaustion test exercises the real bound. + private fun fastDownloader() = ConcurrentRangeDownloader( + httpClient = OkHttpClient(), + segmentCount = 8, + retryBaseDelayMillis = 50L, + retryMaxDelayMillis = 200L, + ) + + // ------------------------------------------------------------------------ + // A1(a): transient 5xx on ONE segment a few times → retried in place, then + // recovers. Segments are KEPT across the retry (no from-0 restart, no + // FALLBACK), the recovery 206 completes, and the assembled SHA is correct. + // ------------------------------------------------------------------------ + @Test + fun transient5xxThenOkRetriesInPlaceAndAssembles() { + val target = parts[3] + // Fire 503 twice on this window; the 3rd attempt (fault expired, times=2) + // streams the full window normally. 2 < maxPartRetry(3), so the budget is + // NOT exhausted and the segment recovers within this single download() call. + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = 2) + startServer(fs) + + val partial = partialPath() + val outcome = fastDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + // Recovery: completes (NOT FALLBACK — a 5xx is transient, never discards + // concurrency), and the assembled bytes are byte-for-byte the source. + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + assertNotEquals( + "transient 5xx must NOT fall back to single-stream", + ConcurrentRangeDownloader.Outcome.FALLBACK, + outcome, + ) + assertEquals(content.size.toLong(), File(partial).length()) + assertEquals(sha256(content), sha256(File(partial))) + + val ranges = fs.requestedRanges.toList() + // The 503 returned an EMPTY body, so the segment has 0 bytes on disk after + // each fired attempt → every retry re-requests the SAME full window + // (start == part.start). Initial + 2 transient retries + 1 recovery 206 = + // EXACTLY three hits on the full window. (Mirrors OCDS-T4's 429 assertion.) + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertEquals( + "affected segment must be requested exactly 3 times (503, 503, then 206)", + 3, + targetReqs.size, + ) + // No from-0 restart: every retry of the affected window starts at part.start + // (which is not file byte 0 here — target is part[3]). + assertTrue( + "retry-in-place must never restart at file byte 0", + targetReqs.all { it.first == target.start }, + ) + // Every OTHER segment is requested EXACTLY once — the retry stayed scoped to + // the affected window and did not perturb its neighbours. + for (p in parts) { + if (p.index == target.index) continue + val reqs = ranges.filter { it.first == p.start && it.second == p.end } + assertEquals( + "segment ${p.index} must be requested exactly once", + 1, + reqs.size, + ) + } + // All `.segN` consumed (assembled + deleted) on COMPLETED. + for (i in 0 until 8) { + assertFalse(".seg$i must be consumed on success", segFile(partial, i).exists()) + } + } + + // ------------------------------------------------------------------------ + // A1(b): transient 5xx that NEVER recovers exhausts the per-segment retry + // budget (maxPartRetry=3) → download() throws. The KEY guarantees: it is a + // transient give-up (NOT a permanent FALLBACK that wipes artifacts), the + // affected window is hit EXACTLY budget+1 times (initial + 3 retries), and + // no corrupt COMPLETED file is produced. + // ------------------------------------------------------------------------ + @Test + fun transient5xxBudgetExhaustionThrowsKeepingArtifactsForResume() { + val target = parts[2] + // Fire 503 forever so the segment never recovers and the retry budget is + // exhausted. status5xxCode defaults to 503 (transient per §4). + val fs = RangeFaultServer(content) + .arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + var threw = false + var outcome: ConcurrentRangeDownloader.Outcome? = null + try { + outcome = fastDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + + // A perpetual transient 5xx gives up by THROWING — it must NOT be mapped to + // a (corrupt) COMPLETED, and it must NOT be a permanent FALLBACK (a 5xx is + // transient; FALLBACK is reserved for "concurrency fundamentally unusable"). + assertTrue("perpetual transient 5xx must give up by throwing", threw) + assertEquals("budget exhaustion must not COMPLETE", null, outcome) + assertNotEquals( + "transient 5xx budget exhaustion must NOT be a COMPLETED", + ConcurrentRangeDownloader.Outcome.COMPLETED, + outcome, + ) + assertNotEquals( + "transient 5xx budget exhaustion must NOT be a FALLBACK", + ConcurrentRangeDownloader.Outcome.FALLBACK, + outcome, + ) + + val ranges = fs.requestedRanges.toList() + // The affected window is requested EXACTLY maxPartRetry+1 = 4 times (initial + // attempt + 3 retries), all at part.start (empty 503 body → 0 bytes on disk + // → same full window each time, never a from-0 restart). This is the + // load-bearing budget assertion: it would FAIL if the loop did not honour + // maxPartRetry, or restarted from byte 0, or treated 5xx as permanent (1 hit). + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertEquals( + "affected segment must be requested exactly maxPartRetry+1 (4) times", + 4, + targetReqs.size, + ) + assertTrue( + "every 5xx retry must restart at the segment window start, never file byte 0", + targetReqs.all { it.first == target.start }, + ) + // No complete (corrupt) `.partial` was assembled. If a `.partial` exists at + // all, it must NOT be a full-length file. + if (File(partial).exists()) { + assertNotEquals( + "a `.partial` left on disk must NOT be a complete (corrupt) file", + content.size.toLong(), + File(partial).length(), + ) + } + } + + // ------------------------------------------------------------------------ + // A1(c): a permanent 5xx (501) on the SAME branch's boundary must bypass the + // retry loop entirely — it is hit EXACTLY once. This pins the transient-vs- + // permanent split of `isPermanentHttpStatus` (501 -> true, line 595) so the + // A1(b) "retry 4 times" assertion is meaningful by contrast, not noise. + // ------------------------------------------------------------------------ + @Test + fun permanent5xx501BypassesRetryLoop() { + val target = parts[4] + val fs = RangeFaultServer(content) + fs.status5xxCode = 501 // 501 is permanent per §4 (isPermanentHttpStatus:595). + fs.arm(RangeFaultServer.FaultMode.STATUS_5XX, target.start, target.end, times = Int.MAX_VALUE) + startServer(fs) + + val partial = partialPath() + var threw = false + var outcome: ConcurrentRangeDownloader.Outcome? = null + try { + outcome = fastDownloader().download( + url = url(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + } catch (e: Exception) { + threw = true + } + + assertTrue("permanent 5xx (501) must surface (throw)", threw) + assertNotEquals( + "permanent 5xx must not COMPLETE", + ConcurrentRangeDownloader.Outcome.COMPLETED, + outcome, + ) + // A PermanentHttpException bypasses the retry loop (downloadSegment line + // 405-409), so the affected window is requested EXACTLY ONCE — proving the + // transient branch's 4 hits in A1(b) is a real, classifier-driven retry + // count and not an artifact of unconditional retrying. + val ranges = fs.requestedRanges.toList() + val targetReqs = ranges.filter { it.first == target.start && it.second == target.end } + assertEquals( + "permanent 5xx (501) must be requested exactly once (no retry-in-place)", + 1, + targetReqs.size, + ) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt new file mode 100644 index 00000000..f74d8953 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt @@ -0,0 +1,124 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Unit coverage for the dependency-free adapter logic extracted from + * [ReactNativeRangeDownloader]: the single-flight run-key, the CAS progress + * gate, and the per-segment artifact sweep. Pure JVM JUnit — no Nitro / + * HybridObject / Context, mirroring the established test pattern in this module + * (see IsPermanentHttpStatusTest). + */ +class RangeDownloadLogicTest { + + private lateinit var tmpDir: File + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-logic", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + // ---- runKey (single-flight semantics) ---- + + @Test + fun runKeyJoinsChannelAndTaskWithPipe() { + assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1")) + } + + @Test + fun runKeyDistinguishesChannelAndTask() { + // Different channels for the same task must not collide. + assertFalse( + RangeDownloadLogic.runKey("MAIN", "t") == RangeDownloadLogic.runKey("OTHER", "t"), + ) + // Different tasks on the same channel must not collide. + assertFalse( + RangeDownloadLogic.runKey("MAIN", "a") == RangeDownloadLogic.runKey("MAIN", "b"), + ) + } + + // ---- progressPercent (CAS gate input) ---- + + @Test + fun progressPercentComputesFlooredPercentage() { + assertEquals(0, RangeDownloadLogic.progressPercent(0, 100)) + assertEquals(50, RangeDownloadLogic.progressPercent(50, 100)) + // Integer floor, never rounds up. + assertEquals(33, RangeDownloadLogic.progressPercent(1, 3)) + assertEquals(100, RangeDownloadLogic.progressPercent(100, 100)) + } + + @Test + fun progressPercentClampsToHundred() { + // transferred can momentarily exceed total across segments; clamp. + assertEquals(100, RangeDownloadLogic.progressPercent(150, 100)) + } + + @Test + fun progressPercentReturnsNullForNonPositiveTotal() { + assertNull(RangeDownloadLogic.progressPercent(10, 0)) + assertNull(RangeDownloadLogic.progressPercent(10, -1)) + } + + /** + * The CAS gate the adapter wraps around [progressPercent]: only a strictly + * higher percentage emits, so the stream stays monotonic and de-duped. This + * reproduces the adapter's `p > prev && compareAndSet` loop over a sequence + * of out-of-order, repeating worker callbacks. + */ + @Test + fun casGateKeepsProgressMonotonicAndDeduped() { + var prev = -1 + val emitted = mutableListOf() + // total fixed at 100; transferred arrives out of order with repeats. + for (transferred in listOf(10L, 10L, 25L, 25L, 20L, 50L, 50L, 49L, 100L)) { + val p = RangeDownloadLogic.progressPercent(transferred, 100) ?: continue + if (p > prev) { + prev = p + emitted.add(p) + } + } + assertEquals(listOf(10, 25, 50, 100), emitted) + } + + // ---- sweepPartialArtifacts ---- + + @Test + fun sweepRemovesPartialAndEverySegmentByPrefix() { + val dest = File(tmpDir, "asset.bin").absolutePath + val partial = File("$dest.partial") + partial.writeText("p") + // Custom (non-default) segment count: 12 segments must all be swept. + val segs = (0 until 12).map { File("$dest.partial.seg$it").apply { writeText("$it") } } + // An unrelated sibling must be left untouched. + val unrelated = File(tmpDir, "asset.bin.keep").apply { writeText("k") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse("partial should be deleted", partial.exists()) + segs.forEach { assertFalse("${it.name} should be deleted", it.exists()) } + assertTrue("unrelated sibling must survive", unrelated.exists()) + } + + @Test + fun sweepIsNoOpWhenNothingExists() { + val dest = File(tmpDir, "missing.bin").absolutePath + // Must not throw when there are no artifacts to remove. + RangeDownloadLogic.sweepPartialArtifacts(dest) + assertFalse(File("$dest.partial").exists()) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt new file mode 100644 index 00000000..67002cf3 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt @@ -0,0 +1,217 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Drives the REAL extracted single-flight unit + * [RangeDownloadLogic.RunRegistry] (keyed by "channel|taskId"). The registry was + * lifted VERBATIM from the Nitro adapter's inline `activeDownloads` + * ConcurrentHashMap usage so its keyed invariants can be unit-tested without the + * Nitro / JNI / OkHttp dependencies. Every assertion exercises the real code — + * nothing is re-implemented here. + * + * Channel mapping note: the adapter computes its key as + * `RangeDownloadLogic.runKey(channel.name, taskId)` (adapter:44-45), i.e. the + * `DownloadChannel` enum contributes ONLY its `.name` string. The dependency-free + * registry therefore takes the channel NAME string directly. To keep the test + * faithful to the real channels (and prove cross-channel key isolation) WITHOUT + * dragging the JNI-annotated generated enum onto the pure-JVM test classpath + * (every sibling test stays generated-type-free), the names below are the exact + * verbatim `DownloadChannel..name` strings. + * + * Cancel-detection probe: [ConcurrentRangeDownloader.CancelHandle.cancel] is + * idempotent and flips the public `aborted` AtomicBoolean — observing `aborted` + * is how we assert a handle was (or was not) cancelled, without subclassing the + * final-ish handle. `cancel()` with no attached pool is a pure flag flip, so it + * is safe + fast in a JVM unit test. + */ +class RunRegistrySingleFlightTest { + + // Verbatim DownloadChannel..name values (BUNDLE/APK/CHART). Kept as plain + // strings so this test needs no JNI-annotated generated enum on its classpath. + private val bundle = "BUNDLE" + private val apk = "APK" + private val chart = "CHART" + + // ------------------------------------------------------------------------ + // start() dedup: a second start for the same key overwrites; finish() is + // identity-checked so the STALE handle's finish is a no-op, and only the LIVE + // handle's finish removes the entry (adapter:87 + adapter:112 invariants). + // ------------------------------------------------------------------------ + @Test + fun startTwiceSameKey_secondHandleIsLive_staleFinishIsNoOp() { + val reg = RangeDownloadLogic.RunRegistry() + + val first = reg.start(bundle, "task-A") + val second = reg.start(bundle, "task-A") + + assertNotSame("a second start() must mint a fresh handle", first, second) + assertEquals("dedup: same key must hold exactly one live entry", 1, reg.size) + + // finish(firstHandle) must NOT clobber the live (second) handle — identity + // remove fails because the map now holds `second`. + reg.finish(bundle, "task-A", first) + assertEquals("stale-handle finish must be a no-op (entry still live)", 1, reg.size) + + // finish(secondHandle) — the live handle — removes the entry. + reg.finish(bundle, "task-A", second) + assertEquals("live-handle finish must remove the entry", 0, reg.size) + } + + // ------------------------------------------------------------------------ + // start → finish(sameHandle) clears the key; a subsequent cancel() is a no-op + // and must NOT throw (adapter:206 remove(...)?.cancel() null-safe path). + // ------------------------------------------------------------------------ + @Test + fun startThenFinish_keyGone_subsequentCancelIsNoOpAndDoesNotThrow() { + val reg = RangeDownloadLogic.RunRegistry() + + val handle = reg.start(apk, "task-B") + reg.finish(apk, "task-B", handle) + assertEquals("finish must clear the key", 0, reg.size) + + // No live handle → cancel() finds nothing → must be a silent no-op. + reg.cancel(apk, "task-B") + assertEquals("cancel of an absent key must not create or leak an entry", 0, reg.size) + assertFalse( + "a finished handle must NOT have been cancelled by the no-op cancel()", + handle.aborted.get(), + ) + } + + // ------------------------------------------------------------------------ + // cancel() invokes CancelHandle.cancel() exactly once AND removes the key: + // the second cancel() finds nothing (probe) and the handle's aborted flag was + // already set by the first cancel. + // ------------------------------------------------------------------------ + @Test + fun cancel_invokesHandleCancelOnceAndRemovesKey() { + val reg = RangeDownloadLogic.RunRegistry() + + val handle = reg.start(chart, "task-C") + assertFalse("handle must start un-aborted", handle.aborted.get()) + + reg.cancel(chart, "task-C") + assertTrue("cancel() must have flipped the handle's aborted flag", handle.aborted.get()) + assertEquals("cancel() must remove the key", 0, reg.size) + + // Probe: the entry is gone, so a second cancel() targets nothing. If the key + // had NOT been removed, this would re-cancel the same handle — proving the + // remove happened on the first call. (cancel() is idempotent regardless, so + // the load-bearing assertion is the size==0 above + this staying a no-op.) + reg.cancel(chart, "task-C") + assertEquals("second cancel() finds nothing — no entry resurrected", 0, reg.size) + } + + // ------------------------------------------------------------------------ + // Different channels (BUNDLE vs APK) with the SAME taskId produce distinct + // keys → both coexist; cancelling one leaves the other live & un-aborted. + // ------------------------------------------------------------------------ + @Test + fun differentChannelsSameTaskId_distinctKeys_cancelOneLeavesOtherLive() { + val reg = RangeDownloadLogic.RunRegistry() + val taskId = "shared-task" + + // Sanity: the underlying key derivation is collision-free across channels. + assertNotEquals( + "channel must contribute to the key", + RangeDownloadLogic.runKey(bundle, taskId), + RangeDownloadLogic.runKey(apk, taskId), + ) + + val bundleHandle = reg.start(bundle, taskId) + val apkHandle = reg.start(apk, taskId) + assertEquals("distinct channels must coexist under the same taskId", 2, reg.size) + + reg.cancel(bundle, taskId) + + assertTrue("the cancelled channel's handle must be aborted", bundleHandle.aborted.get()) + assertFalse("the OTHER channel's handle must remain un-aborted", apkHandle.aborted.get()) + assertEquals("only the cancelled channel's entry must be removed", 1, reg.size) + + // The surviving entry is still the APK handle and can be finished normally. + reg.finish(apk, taskId, apkHandle) + assertEquals("surviving entry finishes cleanly", 0, reg.size) + } + + // ------------------------------------------------------------------------ + // Concurrency: N threads start() the same key while one thread cancel()s it. + // Invariants under contention: + // - the registry never LEAKS an entry that no live handle owns; + // - the surviving handle (if any) is never DOUBLE-cancelled; + // - every start() handle is accounted for: it is either the live survivor, + // cancelled by the racing cancel(), or superseded (overwritten) — never + // silently kept alive AND orphaned. + // ------------------------------------------------------------------------ + @Test + fun concurrentStartsAndCancel_noLeakedEntry_noDoubleCancel() { + val reg = RangeDownloadLogic.RunRegistry() + val key = "race-task" + val n = 32 + + // Count how many distinct handles ever get cancelled. Because each handle is + // unique and cancel() is observed via its aborted flag, double-cancel of the + // SAME handle is detectable: cancelCount must never exceed the number of + // handles that were actually cancelled (we re-check by scanning). + val handles = ConcurrentHashMap.newKeySet() + val startBarrier = CountDownLatch(1) + val done = CountDownLatch(n + 1) + val pool = Executors.newFixedThreadPool(n + 1) + + repeat(n) { + pool.submit { + startBarrier.await() + handles.add(reg.start(chart, key)) + done.countDown() + } + } + // One racing canceller. + pool.submit { + startBarrier.await() + // Spin a touch so it interleaves with the starts rather than always winning. + repeat(4) { reg.cancel(chart, key) } + done.countDown() + } + + startBarrier.countDown() + assertTrue("all workers must finish", done.await(10, TimeUnit.SECONDS)) + pool.shutdownNow() + + // Drain any survivor via a final cancel so the registry must end empty. + reg.cancel(chart, key) + assertEquals("registry must never leak an entry under contention", 0, reg.size) + + // No handle is double-cancelled: aborted is a boolean flip, so re-counting is + // not enough on its own — assert instead that cancel() is idempotent by the + // contract and that AT MOST the set of minted handles were cancelled (never + // more handles than were created). The strong leak invariant above is the + // primary guarantee; here we assert every cancelled handle came from start(). + val cancelled = handles.count { it.aborted.get() } + assertTrue( + "cancelled handles ($cancelled) cannot exceed minted handles (${handles.size})", + cancelled <= handles.size, + ) + // At least one cancel landed on a real handle (the racing canceller + final + // drain cannot both miss every start under this barrier). + assertTrue("at least one minted handle must have been cancelled", cancelled >= 1) + + // Defensive accounting probe: no AtomicInteger over-count of survivors. + val live = AtomicInteger(0) + // After the final drain the key is gone, so re-finishing any handle is a safe + // no-op and must not resurrect the entry. + handles.forEach { reg.finish(chart, key, it) } + if (reg.size > 0) live.incrementAndGet() + assertEquals("post-drain finish() of any stale handle must not resurrect a key", 0, live.get()) + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt new file mode 100644 index 00000000..06f8773e --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt @@ -0,0 +1,350 @@ +package com.margelo.nitro.reactnativerangedownloader + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Unit coverage for the extracted "segmentArtifactPaths + sweepPartialArtifacts + * (.segN glob / delete)" behavior plus the two adapter invariants it ships with + * (single-flight run-key, monotonic/de-duped progress gate). + * + * IMPORTANT — drives the REAL extracted code, never re-implements it: + * - The Android extraction lives in [RangeDownloadLogic] (the dependency-free + * pieces of `ReactNativeRangeDownloader`). On Android the path computation and + * the delete side-effect are NOT two separate public functions — the verbatim + * glob predicate `f.name.startsWith(partial.name + ".seg")` and the + * `.forEach { it.delete() }` live INSIDE [RangeDownloadLogic.sweepPartialArtifacts] + * (RangeDownloadLogic.kt:46-52, mirroring adapter:209-213). So the glob is + * asserted the only way it is observable here: by which sibling files survive + * vs. vanish on disk after the REAL sweep runs. We deliberately do not + * reproduce the predicate in the test. + * - The "RunRegistry / single-flight" key is [RangeDownloadLogic.runKey]; the + * `activeDownloads` map that dedupes by it is a `ConcurrentHashMap` in the + * adapter, so the single-flight / cancel-removes-only-matching / no-co-exist + * semantics are exercised against the REAL key over that same map type. + * - The "MonotonicProgressGate" is [RangeDownloadLogic.progressPercent] feeding + * the adapter's verbatim `p > prev && compareAndSet` loop + * (ReactNativeRangeDownloader.kt:102-108). We drive the REAL percent fn and the + * REAL `AtomicInteger` CAS, never a hand-rolled monotone helper. + * + * Pure JVM JUnit4 — no Nitro / HybridObject / Context — matching the established + * convention in this module (see RangeDownloadLogicTest / IsPermanentHttpStatusTest). + */ +class SegmentArtifactSweepTest { + + private lateinit var tmpDir: File + + @Before + fun setUp() { + tmpDir = File.createTempFile("crd-sweep", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + // --- helpers ------------------------------------------------------------- + + private fun destPath(name: String = "asset.bin") = File(tmpDir, name).absolutePath + private fun partial(dest: String) = File("$dest.partial") + private fun seg(dest: String, index: Int) = File("$dest.partial.seg$index") + + // ------------------------------------------------------------------------ + // segmentArtifactPaths + sweepPartialArtifacts (.segN glob / delete) + // ------------------------------------------------------------------------ + + // Idea 1: .partial.seg0..seg11 (custom count > shipped 8) + .partial + // + an unrelated sibling → the sweep removes exactly the 12 seg files and the + // .partial, and leaves the unrelated sibling (proving the glob set = every + // per-segment file regardless of segmentCount, PLUS the .partial, and nothing + // else). + @Test + fun sweepGlobsEverySegRegardlessOfCountAndThePartialButNotUnrelated() { + val dest = destPath() + val p = partial(dest).apply { writeText("concatenated") } + // 12 > the shipped default of 8 — the prefix glob must catch all of them. + val segs = (0 until 12).map { seg(dest, it).apply { writeText("s$it") } } + // A sibling that shares the stem but is NOT a .partial / .segN artifact. + val unrelated = File(tmpDir, "asset.bin.keep").apply { writeText("keep") } + // A sibling that does not share the stem at all. + val foreign = File(tmpDir, "other.txt").apply { writeText("other") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse(".partial must be deleted", p.exists()) + segs.forEach { assertFalse("${it.name} must be deleted", it.exists()) } + assertTrue("unrelated .keep sibling must survive", unrelated.exists()) + assertTrue("foreign sibling must survive", foreign.exists()) + } + + // Idea 2: no parent dir / parentFile resolvable to null → the safe-call `?.` + // on listFiles means the seg-glob phase is skipped, and the bare `partial.delete()` + // still runs without throwing (mirrors the `?.listFiles` null-guard). We assert + // on the observable contract: no crash, and the call is a no-op for a path whose + // parent holds nothing. + @Test + fun sweepDoesNotCrashWhenNoSegArtifactsExist() { + // A real path with a real parent dir but zero artifacts: the listFiles glob + // returns an empty array and partial.delete() is a no-op — no throw. + val dest = destPath("nothing-here.bin") + RangeDownloadLogic.sweepPartialArtifacts(dest) + assertFalse(partial(dest).exists()) + + // A relative bare filename ("foo.bin") makes File("foo.bin.partial").parentFile + // null; the `?.` short-circuits the glob and only the .partial delete is + // attempted. Must not throw (the load-bearing null-safety assertion). + RangeDownloadLogic.sweepPartialArtifacts("crd-sweep-nonexistent-bare-name.bin") + } + + // Idea 3: the sweep deletes all .segN and the .partial; the final and + // unrelated siblings are untouched (the sweep never touches the promoted final). + @Test + fun sweepLeavesFinalDestAndSiblingsUntouched() { + val dest = destPath() + val finalFile = File(dest).apply { writeText("final promoted bytes") } + val p = partial(dest).apply { writeText("p") } + val segs = (0 until 4).map { seg(dest, it).apply { writeText("$it") } } + val sibling = File(tmpDir, "asset.bin.metadata").apply { writeText("m") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse(".partial must be gone", p.exists()) + segs.forEach { assertFalse("${it.name} must be gone", it.exists()) } + assertTrue("the final must NOT be swept", finalFile.exists()) + assertEquals("final content must be intact", "final promoted bytes", finalFile.readText()) + assertTrue("unrelated sibling must survive", sibling.exists()) + } + + // Idea 4: prefix-boundary — a file named ".partial.segment-notes" still + // matches because the predicate is prefix-only (startsWith ".partial.seg"), not + // ".partial.seg". This documents (behavior-preserving) that today's + // glob would sweep it too. + @Test + fun sweepPrefixIsBoundaryLooseAndAlsoMatchesSegmentNotes() { + val dest = destPath() + partial(dest).writeText("p") + val numericSeg = seg(dest, 0).apply { writeText("0") } + // Shares the ".seg" prefix but is not a numeric segment. + val prefixLookalike = File("$dest.partial.segment-notes").apply { writeText("notes") } + // Does NOT start with ".partial.seg" (the stem is "...partialX...") + // → must survive, proving the match is anchored on that exact prefix. + val nearMiss = File("$dest.partialX.seg0").apply { writeText("x") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse("numeric .seg0 must be swept", numericSeg.exists()) + assertFalse( + "prefix-only predicate also sweeps '.segment-notes' (behavior-preserving)", + prefixLookalike.exists(), + ) + assertTrue("a sibling not sharing the '.partial.seg' prefix must survive", nearMiss.exists()) + } + + // Idea 5: idempotent — sweeping twice (artifacts already gone) does not throw and + // is a no-op. + @Test + fun sweepIsIdempotentAndSafeToRepeat() { + val dest = destPath() + partial(dest).writeText("p") + (0 until 3).forEach { seg(dest, it).writeText("$it") } + + RangeDownloadLogic.sweepPartialArtifacts(dest) + // Everything is already gone; a second sweep must be a clean no-op. + RangeDownloadLogic.sweepPartialArtifacts(dest) + RangeDownloadLogic.sweepPartialArtifacts(dest) + + assertFalse(partial(dest).exists()) + (0 until 3).forEach { assertFalse(seg(dest, it).exists()) } + } + + // ------------------------------------------------------------------------ + // RunRegistry / single-flight (RangeDownloadLogic.runKey over the adapter's + // ConcurrentHashMap activeDownloads map). + // ------------------------------------------------------------------------ + + // runKey is stable + collision-free across channel/taskId. + @Test + fun runKeyIsStableAndCollisionFreeAcrossChannelAndTask() { + // Stable: same inputs → same key, every call. + assertEquals( + RangeDownloadLogic.runKey("MAIN", "task-1"), + RangeDownloadLogic.runKey("MAIN", "task-1"), + ) + assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1")) + // Collision-free across channel. + assertNotEquals( + RangeDownloadLogic.runKey("MAIN", "t"), + RangeDownloadLogic.runKey("OTHER", "t"), + ) + // Collision-free across taskId. + assertNotEquals( + RangeDownloadLogic.runKey("MAIN", "a"), + RangeDownloadLogic.runKey("MAIN", "b"), + ) + } + + // A second register for the same key dedupes (the map holds a single handle per + // key); cancel/discard removes only the matching key; concurrent registers for + // distinct keys co-exist while the same key never does. + @Test + fun singleFlightRegistryDedupesPerKeyAndRemovesOnlyTheMatchingKey() { + // Mirror the adapter's registry type exactly (ReactNativeRangeDownloader.kt:41-42). + val active = ConcurrentHashMap() + + val keyA = RangeDownloadLogic.runKey("MAIN", "task-A") + val keyB = RangeDownloadLogic.runKey("OTHER", "task-A") + + val h1 = ConcurrentRangeDownloader.CancelHandle() + val h2 = ConcurrentRangeDownloader.CancelHandle() + active[keyA] = h1 + // A second register for the SAME key replaces (does not co-exist) — only ever + // one live handle per "channel|taskId". + active[keyA] = h2 + assertEquals("same-key register must not co-exist; map holds exactly one", 1, active.keys.count { it == keyA }) + assertEquals("the latest handle wins for the same key", h2, active[keyA]) + + // A distinct key (different channel, same taskId) co-exists. + val hB = ConcurrentRangeDownloader.CancelHandle() + active[keyB] = hB + assertEquals("distinct keys co-exist", 2, active.size) + + // cancel/discard for keyA removes ONLY keyA, leaving keyB intact. + active.remove(keyA) + assertNull("cancelled key must be gone", active[keyA]) + assertEquals("only the matching key is removed", hB, active[keyB]) + assertEquals(1, active.size) + } + + // The adapter deregisters with the value-checked remove(key, value) so a + // concurrent cancel that already replaced the handle is NOT clobbered + // (ReactNativeRangeDownloader.kt:112). Drive that exact 2-arg remove contract. + @Test + fun valueCheckedRemoveOnlyDeregistersOwnHandle() { + val active = ConcurrentHashMap() + val key = RangeDownloadLogic.runKey("MAIN", "task-X") + val mine = ConcurrentRangeDownloader.CancelHandle() + active[key] = mine + // A concurrent cancel replaced our handle with a newer one. + val newer = ConcurrentRangeDownloader.CancelHandle() + active[key] = newer + // Our finally-block remove(key, mine) must be a no-op because the value moved on. + assertFalse("value-checked remove must not evict the replacement", active.remove(key, mine)) + assertEquals("the newer handle survives the stale deregister", newer, active[key]) + // The owner of `newer` removes its own handle successfully. + assertTrue(active.remove(key, newer)) + assertNull(active[key]) + } + + // ------------------------------------------------------------------------ + // MonotonicProgressGate (RangeDownloadLogic.progressPercent + the adapter's + // verbatim AtomicInteger `p > prev && compareAndSet` loop). + // ------------------------------------------------------------------------ + + // progressPercent floors, clamps to [0,100], and returns null for a non-positive + // total (the gate's input contract). + @Test + fun progressPercentFloorsClampsAndNullsNonPositiveTotal() { + assertEquals(0, RangeDownloadLogic.progressPercent(0, 100)) + assertEquals(33, RangeDownloadLogic.progressPercent(1, 3)) // floor, never rounds up + assertEquals(100, RangeDownloadLogic.progressPercent(100, 100)) + assertEquals(100, RangeDownloadLogic.progressPercent(150, 100)) // clamp overshoot + assertNull(RangeDownloadLogic.progressPercent(10, 0)) + assertNull(RangeDownloadLogic.progressPercent(10, -1)) + } + + // Progress only advances (CAS), never emits below the prior max even with + // out-of-order callbacks, and equal values do not re-emit. Single-threaded + // reproduction of the adapter's gate loop. + @Test + fun progressGateIsMonotonicAndDeduped() { + val last = AtomicInteger(-1) + val emitted = mutableListOf() + // total fixed at 100; transferred arrives out of order with repeats + a regress. + for (transferred in listOf(10L, 10L, 25L, 25L, 20L, 50L, 50L, 49L, 100L, 100L)) { + val p = RangeDownloadLogic.progressPercent(transferred, 100) ?: continue + val prev = last.get() + if (p > prev && last.compareAndSet(prev, p)) { + emitted.add(p) + } + } + // 20 (<25) and 49 (<50) never emit; repeats are de-duped; sequence is monotone. + assertEquals(listOf(10, 25, 50, 100), emitted) + } + + // Under genuine concurrency the CAS gate still never emits a value below the + // running max, and emits each percentage at most once. Many threads race the + // same gate driven by the REAL progressPercent + AtomicInteger CAS. + @Test + fun progressGateNeverGoesBackwardUnderConcurrency() { + val last = AtomicInteger(-1) + val emissions = java.util.concurrent.ConcurrentLinkedQueue() + val total = 1_000L + val threads = 16 + val pool = Executors.newFixedThreadPool(threads) + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + val regress = AtomicInteger(0) + + repeat(threads) { t -> + pool.submit { + start.await() + // Each thread fires the full 0..1000 transferred sweep (heavy overlap), so + // the gate sees the same percentages racing from many threads at once. + for (transferred in 0L..total) { + val p = RangeDownloadLogic.progressPercent(transferred, total) ?: continue + while (true) { + val prev = last.get() + if (p <= prev) break // not an advance → no emit (de-dup / monotone) + if (last.compareAndSet(prev, p)) { + emissions.add(p) + break + } + } + // A reader must NEVER observe the shared published max regress. + if (prevMaxRegressed(last)) regress.incrementAndGet() + } + done.countDown() + } + } + start.countDown() + assertTrue("workers must finish", done.await(20, TimeUnit.SECONDS)) + pool.shutdownNow() + + // The published max ends at exactly 100 and never regressed. + assertEquals("gate must settle at 100%", 100, last.get()) + assertEquals("published max must never regress", 0, regress.get()) + // Each emitted percentage is unique (CAS de-dup) and the stream is sorted + // ascending (monotone): emissions are appended only on a successful advance. + val list = emissions.toList() + assertEquals("every emission must be unique (no re-emit of an equal value)", list.toSet().size, list.size) + assertEquals("emissions must be monotone non-decreasing", list.sorted(), list) + assertTrue("the terminal 100% must be emitted exactly once", list.count { it == 100 } == 1) + } + + // Tracks the running max in a thread-confined way to detect any regression of the + // shared published value. Returns true if the AtomicInteger's value ever dropped + // below a previously observed value on this thread. + private val perThreadSeenMax = ThreadLocal.withInitial { -1 } + private fun prevMaxRegressed(last: AtomicInteger): Boolean { + val now = last.get() + val seen = perThreadSeenMax.get() + if (now < seen) return true + if (now > seen) perThreadSeenMax.set(now) + return false + } +} diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt new file mode 100644 index 00000000..5f876184 --- /dev/null +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt @@ -0,0 +1,65 @@ +package com.margelo.nitro.reactnativerangedownloader + +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Harness smoke test: confirms the [RangeFaultServer] dispatcher + MockWebServer + * + a real [ConcurrentRangeDownloader] over a plain OkHttpClient compile and run + * a clean full download. A green run here proves the JVM test harness works end + * to end (8 parallel range requests served concurrently, assembled `.partial` + * byte-identical to the source). + */ +class SmokeTest { + + private lateinit var server: MockWebServer + private lateinit var tmpDir: File + + // 3 MiB > minConcurrentBytes (2 MiB) so the core uses the concurrent path. + private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() } + + @Before + fun setUp() { + server = MockWebServer() + server.dispatcher = RangeFaultServer(content).dispatcher + server.start() + tmpDir = File.createTempFile("crd-smoke", "").let { + it.delete(); it.mkdirs(); it + } + } + + @After + fun tearDown() { + server.shutdown() + tmpDir.deleteRecursively() + } + + @Test + fun fullDownloadCompletesWithMatchingSha() { + val partial = File(tmpDir, "asset.bin.partial").absolutePath + val outcome = newDownloader().download( + url = server.url("/asset.bin").toString(), + partialFilePath = partial, + onProgress = { _, _ -> }, + ) + + assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome) + val partialFile = File(partial) + assertTrue("partial should exist", partialFile.exists()) + assertEquals(content.size.toLong(), partialFile.length()) + assertEquals( + "assembled .partial SHA must match source", + sha256(content), + sha256(partialFile), + ) + // Every `.segN` is consumed by concatenate() on success. + for (i in 0 until 8) { + assertTrue(".seg$i should be gone", !File("$partial.seg$i").exists()) + } + } +} diff --git a/native-modules/react-native-range-downloader/conformance/README.md b/native-modules/react-native-range-downloader/conformance/README.md new file mode 100644 index 00000000..91912076 --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/README.md @@ -0,0 +1,180 @@ +# OCDS Conformance — verification methods & code (Node / Android / iOS) + +This directory records **how each platform's concurrent downloader is verified +against [the OCDS standard](../SPEC.md)**, and houses the runnable verification +code. Re-run the relevant suite whenever you touch downloader code. + +The normative behaviors are SPEC §5 (coverage areas) and SPEC §6 (the 11 +conformance scenarios `#1`–`#11`). Every suite below maps back to those. + +--- + +## At a glance + +| Platform | How it's verified | Where the code lives | How to run | +|---|---|---|---| +| **Node / Desktop** | Jest e2e against a real local Range HTTP/HTTPS server driving the **real** `DesktopApiBundleUpdate` downloader (positioned writes + `.partial` manifest) | app-monorepo `packages/kit-bg/src/desktopApis/DesktopApiBundleUpdate.e2e*.test.ts` + `__e2e__/desktopBundleUpdateE2eHarness.ts` | `cd app-monorepo && yarn jest packages/kit-bg/src/desktopApis/DesktopApiBundleUpdate.e2e` | +| **Android** | Pure-JVM unit + OkHttp **MockWebServer** fault injection against the real `ConcurrentRangeDownloader` (segment-file model) | this module: `android/src/test/java/com/margelo/nitro/reactnativerangedownloader/` (`ConcurrentRangeDownloaderOcdsTest.kt`, `FaultServer.kt`, `IsPermanentHttpStatusTest.kt`, `SmokeTest.kt`, `Ocds*.kt`) | the module has **no own `gradlew`** — build via the example host: `cd example/react-native/android && ANDROID_HOME=~/Library/Android/sdk ./gradlew :onekeyfe_react-native-range-downloader:testDebugUnitTest` | +| **iOS (logic)** | SwiftPM unit tests over the dependency-free logic (`RangeDownloadLogic`), source-symlinked so tests exercise the real shipping code | this module: `tests/swiftpm/` | `swift test --package-path tests/swiftpm` | +| **iOS (end-to-end)** | Real Release `.app` on a booted **simulator**, driven by a local HTTP fault server + a multi-agent workflow; asserts on the app's `.segN`/`app-latest.log`/server log | this module: [`conformance/ios-simulator/`](./ios-simulator/) | see [`ios-simulator/README.md`](./ios-simulator/README.md) | + +> The Node downloader is a TypeScript module that lives in the app-monorepo +> (Electron), so its runnable tests stay there; the table above is the +> authoritative pointer. Android + iOS native downloaders live in **this** repo, +> so their suites live here. + +> **Out of OCDS scope (sanctioned non-OCDS downloaders).** Not every binary the app +> downloads goes through the concurrent core. The **desktop full-installer** +> (DMG/exe/AppImage) is downloaded by `electron-updater` +> (`DesktopApiAppUpdate.downloadUpdate`), **not** the OCDS path — only the desktop +> **OTA JS bundle** (`DesktopApiBundleUpdate`) is OCDS-conformant on desktop. +> electron-updater provides its own resume/verify/signature flow; OCDS's §4 +> classification, §5.3 per-segment resume, §5.8 single-flight and §5.11 give-up do +> **not** apply to it. If the installer download is ever required to be +> OCDS-conformant, it must be re-pointed at the concurrent core. + +--- + +## Latest iOS end-to-end result (simulator) + +Verified on a real `-configuration Release` build (iPhone 17 Pro sim) against the +local fault server. **9 of 11 SPEC §6 rows pass on-simulator**; 2 not reachable +without a temporary shim. (#6's earlier "deviation" was resolved at the spec +level — see below.) + +> **Scope:** this is a *fake update task running through real download code*. The +> app's own native downloader, its silent-auto-download trigger, segmentation, +> resume, retry, fallback and SHA-256 verification are all **real**; the update +> server, manifest, version, and the 16 MB bundle payload are **synthetic** (the +> payload is filler bytes, not a signed zip). It therefore verifies the +> **download layer only** — the post-download unzip → signature-verify → install → +> relaunch chain is **not** exercised (the synthetic payload fails `verifyASC` +> after a successful download, which is expected and out of OCDS scope). See +> [`ios-simulator/README.md`](./ios-simulator/README.md) → "What is real vs. +> synthetic" for the full boundary and how to extend it to a real signed bundle. + +| SPEC §6 | Behavior | Result | +|---|---|---| +| #1 | Transient error → backoff retry, no restart | ✅ PASS | +| #2 | Killed mid-run → resume from completed `.segN` (no restart-from-0) | ✅ PASS | +| #3 | `200` to a Range request → permanent → single-stream | ✅ PASS (probe-200, segment-200, 404 variants) | +| #4 | `416` / transient range error → re-evaluate, retry (not permanent) | ✅ PASS | +| #5 | short / over-long / mis-aligned / bad-total `206` → rejected, final checksum passes | ✅ PASS (4 variants) | +| #6 | Corrupted assembly → checksum mismatch → discard → terminal failure, no infinite loop | ✅ PASS — mismatch detected (`valid=false`), artifacts discarded, bounded (one concurrent attempt), terminal failure surfaced. (Conformant as of OCDS 1.2, which removed the over-specified single-stream retry-once — all 3 platforms terminate on mismatch.) | +| #7 | Network flap / lock / background → transient retry, progress never resets to 0 | ✅ PASS (literal screen-lock isn't performable on the simulator and is behaviorally redundant with the kill/flap paths) | +| #8 | User cancel mid-run | ❌ NOT COVERED — **no cancel trigger exists in the iOS app flow at all** (also a product gap); needs a temp shim calling `RangeDownloader.cancel` | +| #9 | Persistent failure → bounded terminal give-up, no infinite loop | ✅ PASS (5-attempt exponential backoff ladder then terminal failure) | +| #10 | Stalled socket → stall watchdog cancels + retries | ✅ PASS (per-segment watchdog at 30s) | +| #11 | Two concurrent `download()` for the same dest → single-flight | ❌ NOT COVERED — native single-flight guard blocks a 2nd call; needs a shim or a bg/main dual-dispatch harness | + +**Resolved (#6):** the earlier "no single-stream retry-once" finding was an +over-specification in the standard, not an implementation bug — all three +platforms (iOS, Android, Node) terminate on a checksum mismatch rather than +re-fetching the same (would-re-corrupt) object. OCDS **1.2** removed the +retry-once requirement; a mismatch is now simply Permanent → discard → terminal. + +--- + +## Audit gap closure (coverage delta) + +An adversarial audit of the Android + Node suites found gaps (dead fault modes +never armed, behaviors only asserted by decision/log not outcome, and missing +malformed-response cases). New tests were added to close the testable ones, and +each was **mutation-verified** — a targeted regression was injected into the +production code to confirm the test actually goes red (a green test that survives +its own regression is not load-bearing). + +**Android** (`android/src/test/.../Ocds*.kt`, 9 cases) — full module suite green: +| Test | SPEC | Mutation-proven | +|---|---|---| +| `OcdsTransient5xxTest` — 503 retry-in-place | #4 | ✅ 503→permanent | +| `OcdsTransient5xxTest` — budget exhaustion throws | #4/#9 | ✅ retry budget +2 | +| `OcdsTransient5xxTest` — 501 permanent bypass | #4 | ✅ remove `501,505→true` | +| `Ocds416ResumeTest` — 416 transient resume | #4/#6 | ✅ remove `416→false` | +| `Ocds416ResumeTest` — never discards seeded segs | #6 | ⚠️ green, no clean single-line regression (overlaps the resume test; only breaks under an insertion) | +| `OcdsMultipartRejectTest` | #5 | ✅ bypass multipart detection | +| `OcdsBadTotalRejectTest` | #5 | ✅ drop Content-Range total check | +| `OcdsReadOnlyFsTest` (a6a/a6b) | §5.2 | ⚠️ green, no clean single-line regression (read-only-fs guard needs an insertion-type mutation) | + +**Node** (`packages/kit-bg/src/desktopApis/DesktopApiBundleUpdate.e2e.*.test.ts`): +| Test | SPEC | Mutation-proven | +|---|---|---| +| `e2e.transient416` — 416 concurrent | #4 | ✅ 416→permanent in `classifyHttpStatus` | +| `e2e.transient416` — 416 single-stream | #4 | ✅ break the single-stream 416 finalize | +| `e2e.handoff` — concurrent→single-stream | #3 | ✅ `isConcurrentFallback`→false | + +**Android adapter layer** (`ReactNativeRangeDownloader.kt`) — **now CLOSED** via a +behavior-preserving extraction. The adapter's dependency-free pieces were moved +verbatim into `RangeDownloadLogic.kt` (the adapter delegates; diff +11/−21, pure +move) and unit-tested with **39 new pure-JVM tests** (`RangeDownloadLogicTest`, +`RunRegistrySingleFlightTest`, `MonotonicProgressGateTest`, `SegmentArtifactSweepTest`): +| Behavior | Extracted symbol | Mutation-proven | +|---|---|---| +| single-flight registry (dedup + identity-checked finish) | `RangeDownloadLogic.RunRegistry` | ✅ identity-drop mutant killed | +| progress clamp/guard feeding the (unchanged) CAS gate | `RangeDownloadLogic.progressPercent` | ✅ clamp-removal mutant killed | +| `.segN`/`.partial` artifact sweep | `RangeDownloadLogic.sweepPartialArtifacts` | ✅ glob-narrow mutant killed | +Behavior preservation is proven by the pre-existing OCDS/HTTP/Smoke suite (which +drives the real download paths) staying green. The CAS gate primitive itself and +the JNI `Promise.async`/`NitroModules.applicationContext` boundary remain in the +adapter and are genuinely not JVM-unit-testable (no JSI stand-in). + +**Still open (and why):** +- **Node #9 give-up budget** — lives in the kit caller (`runDownloadWithRetry`/`ServiceAppUpdate`), tested in `useAppUpdate.test.ts`, not a downloader concern. +- **True cross-restart resume (#2)** — a real SIGKILL-mid-write cannot be reproduced in jest (an in-process interrupt either persists an empty manifest below the 4 MiB flush threshold, or hangs on a stalled socket). The realistic case is covered by the `seedResumeState` resume test (manifest persisted) + the OCDS-T1 intra-call resume; a faithful kill-resume needs a process-level harness. + +## Android APK updater (react-native-app-update) + +The APK download path (`native-modules/react-native-app-update`) shares the OCDS +core (`ConcurrentRangeDownloader`) with the bundle path, but its ~1346-line caller +(`ReactNativeAppUpdate.kt`) was **never audited and had ZERO tests**. An +adversarial audit found 4 issues; 3 were fixed and the dependency-free pure logic +was extracted + unit-tested. + +**Bugs found & fixed:** +| # | Severity | SPEC | What | Fix | Commit | +|---|---|---|---|---|---| +| 1 | HIGH | §5.8 | Concurrent download invoked with **no `cancelHandle`** → `clearCache`/`clearApkCache` deleted `.segN` while 8 worker threads were still streaming into them (the cancel-then-delete resurrection race the standard forbids) | Wired a `CancelHandle` (mirror the bundle caller) + `cancel()` (`shutdownNow`+`awaitTermination`) before any `.segN` delete | `213acf0f3` | +| 2a | MEDIUM | — | Once cancel was wired, an intentional `clearCache`-cancel surfaced as a spurious update/error | Suppressed via `cancelHandle.aborted` (mirror bundle's `intentionallyCancelled`) | `24d5973cb` | +| 2b | MEDIUM | — | When `SHA256SUMS.asc` is offline, verification returns Indeterminate/Deferred (`ApkVerificationDeferredException`) — was mis-reported as update/error on every retry | Suppressed (type-based); byte-preservation untouched; Promise still rejects so the awaited JS caller still retries | `011c24a58` | + +**Coverage added** (`b9caf71f9`) — extracted the dependency-free pure logic to +`AppUpdateLogic.kt` (adapter delegates, pure move); added junit + **17 JVM unit +tests**; all 10 planted mutants killed (mutation-proven): +| Extracted logic | Mutation-proven | +|---|---| +| segment-name template + `CONCURRENT_SEGMENT_COUNT == 8` | ✅ | +| `416` Content-Range total parse + `is416Complete` | ✅ | +| `206` Content-Range parse + `is206StartAligned` | ✅ | + +Also wired the adapter's inline `206` parse through `parse206ContentRange` (was an +untested inline duplicate — coverage theater). + +**Still device/Robolectric-ONLY (honest boundary):** +- the JNI `downloadAPK` streaming loop (`206`-resume vs `200`-full, append-to-`.partial`), +- the actual `.segN` deletion + the §5.8 cancel-race **execution**, +- promote/verify/install File-I/O + +— all need a live `Context`. + +**Backlog (not fixed):** +- **BUG #3** — single-flight is one process-global `AtomicBoolean`, not per-dest. + Stricter (so it doesn't corrupt) but diverges from the bundle's per-dest registry + and has no per-dest cancel. +- **BUG #4** — concurrent `COMPLETED` promotes to final **before** any whole-file + checksum verify. Note the mitigation is NOT a whole-file SHA/GPG check: `verifyAPK` + (called later in the JS chain) verifies the downloaded APK's **package name + + signing certificate** against the installed app (anti-tamper / anti-substitution), + and `installAPK` adds a TOCTOU re-check. There is no verification of the assembled + bytes against a manifest-supplied SHA256/GPG signature on the APK path, unlike the + bundle path. A corrupt download is therefore caught only if the corruption breaks + the APK's signature/cert, not by a byte-checksum. + +--- + +## How to re-test after a code change + +- **Touching `ConcurrentRangeDownloader.kt` / Android segment logic** → run the Android suite. +- **Touching `ReactNativeRangeDownloader.swift` / `RangeDownloadLogic.swift`** → run the SwiftPM logic suite; for behavioral changes (retry, fallback, resume, stall) also run the iOS simulator suite. +- **Touching `ReactNativeBundleUpdate.swift` download orchestration** → run the iOS simulator suite (it exercises the real caller→core path). +- **Touching `DesktopApiBundleUpdate.ts`** → run the Node e2e suite in app-monorepo. +- **Changing the OCDS standard itself** → update [`SPEC.md`](../SPEC.md) and add/adjust scenarios in every suite. diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/.gitignore b/native-modules/react-native-range-downloader/conformance/ios-simulator/.gitignore new file mode 100644 index 00000000..76a77cbc --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/.gitignore @@ -0,0 +1,2 @@ +evidence/ +*.log diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/README.md b/native-modules/react-native-range-downloader/conformance/ios-simulator/README.md new file mode 100644 index 00000000..a1dacd4b --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/README.md @@ -0,0 +1,96 @@ +# iOS end-to-end OCDS verification on the Simulator + +Drives the **real** Release `.app` on a booted simulator through every OCDS §6 +scenario against a local HTTP fault server, and adversarially verifies the +result. The app's *own* native concurrent downloader runs — this is not a +re-implementation. + +## What is real vs. synthetic (READ THIS — scope & honesty) + +This is a **fake update task running through real download code.** Be clear on +the boundary before trusting a result: + +**Real (the thing under test):** +- The app itself and its native concurrent downloader (`RangeDownloader.swift`, `ReactNativeBundleUpdate.swift`). +- The trigger path: on launch the app does its normal update check → because the manifest declares `updateStrategy: silent`, `AppUpdateForeground` **auto-calls `downloadPackage()`** — the same code path a real silent update uses. No UI is poked; **every launch auto-starts one download.** +- 8-way concurrent Range fetch, `.segN` segment files, concatenation, SHA-256 verification, and all retry / fallback / stall / resume logic. + +**Synthetic (faked by this harness):** +- The update server, the manifest, and the version number (`localhost:8788` impersonates the update backend; version `202699999` is invented). +- The downloaded "bundle" is **16 MB of deterministic filler bytes, NOT a real signed OneKey bundle.** The manifest's `sha256` is the sha of those filler bytes, so the integrity check passes legitimately. + +**What this does NOT verify (out of OCDS scope):** +- Post-download **unzip → signature/ASC verification → install → relaunch**. Because the payload is not a valid signed zip, `verifyBundleASC` (SSZipArchive) fails *after* a successful download. That is expected and does **not** affect any download-layer conclusion — but it means the full "download a real bundle and boot into it" chain is unproven here. +- To also cover install/verify/relaunch, point the server at a **real signed test bundle** (a genuine bundle + its true `sha256`/`signature` from a test channel) instead of the filler payload. + +## Files + +| File | Role | +|---|---| +| `server.js` | Local HTTP fault server (`:8788`). Serves a 16 MB bundle over Range with per-scenario fault injection + a control plane (`/ocds/scenario`, `/ocds/log`, `/ocds/reset`, `/ocds/health`). | +| `drive.sh` | Bash helpers: `simctl` lifecycle, app-container `.segN` / `app-latest.log` inspection, scenario control, clean reinstall. | +| `capture.sh` | Deterministic per-scenario driver → writes an evidence bundle to `evidence/.json`. | +| `ocds-verify.workflow.js` | Multi-agent workflow: Preflight (prove the seam) → serial Capture (all scenarios) → parallel adversarial Verify → Synthesis (SPEC §6 table). Run with the app's Workflow tool. | + +## Why a special build is needed (the "local-verify" patches) + +iOS hard-blocks non-HTTPS downloads in native code and there is no production +seam to repoint the bundle URL. So a localhost fault server requires **temporary, +never-shipped** patches. iOS ATS already exempts `localhost` cleartext, so no +TLS/cert is needed — only these guard relaxations + a JS override: + +1. **`react-native-range-downloader/ios/ReactNativeRangeDownloader.swift`** — the URL guard (`guard urlString.hasPrefix("https://")`): add `|| urlString.hasPrefix("http://localhost")`. +2. **`react-native-bundle-update/ios/ReactNativeBundleUpdate.swift`** — the entry URL guard (`guard downloadUrl.hasPrefix("https://")`): add `|| downloadUrl.hasPrefix("http://localhost")`. +3. **`react-native-bundle-update/ios/ReactNativeBundleUpdate.swift`** — the single-stream post-download redirect guard (`responseUrl.scheme != "https"`): add `&& !(responseUrl.host == "localhost")`. +4. **app-monorepo `packages/kit-bg/src/services/ServiceAppUpdate.ts` → `fetchConfig`** — short-circuit: fetch `http://localhost:8788/ocds/manifest.json`; if up, return it as the update info (the manifest sets `updateStrategy: silent` so the app **auto-downloads on launch with zero UI**). Also relax the two JS `startsWith('https://')` guards to allow `http://localhost`. + +All four are marked `OCDS-LOCAL-VERIFY(temp)` in the source. They are local-only, +must never be committed, and are wiped from `node_modules` on the next +`yarn install`. (Patches 1–3 are compiled into the binary, so they require a +native rebuild; patch 4 ships in the JS bundle.) + +## Run it + +```bash +# 0. Apply the 4 local-verify patches above (to node_modules + the monorepo JS). +# 1. Build & deploy the Release app to a booted simulator (from app-monorepo): +./development/scripts/ios-release-build-deploy.sh xcode # native (after patches 1-3) +./development/scripts/ios-release-build-deploy.sh build # HBC bundles (after patch 4) +./development/scripts/ios-release-build-deploy.sh deploy # install + launch + +# 2. Start the fault server: +node conformance/ios-simulator/server.js # logs to stdout; listens on :8788 + +# 3a. Single scenario (deterministic, no agents): +bash conformance/ios-simulator/capture.sh clean normal +bash conformance/ios-simulator/capture.sh delay-tail kill-resume # #2 resume +# -> writes evidence/.json + +# 3b. Full sweep + adversarial verification (multi-agent): +# invoke the Workflow tool with scriptPath=.../conformance/ios-simulator/ocds-verify.workflow.js +``` + +## Scenarios (server.js) → SPEC §6 + +`clean` (#1/#2 base) · `delay-tail`+`kill-resume` (#2) · `range-ignored-probe` / +`range-ignored-segment` / `permanent-4xx` (#3) · `transient-5xx` / `range-416` +(#1/#4) · `short-body` / `overlong-206` / `misaligned-206` / `bad-total-206` (#5) +· `corrupt-bytes` (#6) · `flap` (#7) · `stall` (#10) · `give-up` (#9). + +Not covered without a shim: **#8 cancel** (no cancel trigger exists in the iOS +app), **#11 two-concurrent-download** (native single-flight guard). + +## Reading evidence + +`evidence/.json` carries: `serverRangeStarts` / `serverStatuses` (what +the server saw), `appLogTail` (filtered `app-latest.log`), `shaMatch` / +`finalSha` (assembled-file integrity), `segPeak`, and for kill-resume +`seededSegs` (head segments on disk at the force-quit — must NOT be re-fetched on +resume). A scenario passes only when server log + app log + sha agree. + +## Known traps (learned the hard way) + +- **Don't fault the probe.** The downloader probes with `bytes=0-0` (start `0`, shared with segment 0's start). Per-segment fault scenarios must exempt `start===0 && end===0`, or the probe fails and the run wrongly takes a single-stream fallback instead of exercising per-segment retry. +- **Don't treat an intermediate `retry N/5` as terminal.** `give-up`'s "retry 5/5" only *schedules* the final retry (24s backoff); wait ~28s past it for the 5th attempt to fire and the terminal failed-result to log. +- **localhost downloads finish <1s**, so on-disk `.segN` are not reliably caught by polling (`segPeak` is noisy). Judge segmentation by the distinct Range windows in the server log, not `segPeak`. +- **Resume needs the app's OWN segments**, not orphan `curl`'d files — pre-seeded orphan `.segN` are not honored. Use `delay-tail` + force-quit + relaunch (the app writes real segments, which survive `simctl terminate` and are reused). diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/capture.sh b/native-modules/react-native-range-downloader/conformance/ios-simulator/capture.sh new file mode 100644 index 00000000..0b70d34b --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/capture.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# OCDS scenario capture — deterministically drives ONE scenario on the booted +# sim and writes an evidence bundle to evidence/.json. +# usage: capture.sh [mode] +# mode: normal (default) | kill-resume +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/drive.sh" >/dev/null +SCN="${1:-clean}" +MODE="${2:-normal}" +OUT="$HERE/evidence/$SCN.json" +mkdir -p "$HERE/evidence" +EXPECT_SHA="$(curl -s "$SERVER/ocds/health" | python3 -c 'import json,sys;print(json.load(sys.stdin)["sha256"])')" + +note() { echo ">>> $*" >&2; } +snap() { seg_snapshot 2>/dev/null | sed 's/^/ /'; } + +note "[$SCN/$MODE] clean reinstall" +app_reinstall >&2 +server_reset +set_scenario "$SCN" >&2 + +# kill-resume (T8 / SPEC #2): scenario MUST be delay-tail — head segs 0..4 finish +# fast, tail 5..7 hang. Once head is on disk we force-quit, switch to clean, and +# relaunch; the resume must REUSE the head segs (no restart from 0). +SEEDED_SEGS="" +START_LINE="$(log_lines)" + +note "launch (silent strategy -> auto-download)" +app_launch >&2 + +# poll for download activity + terminal state +SEG_PEAK=0; KILLED=0; RESUMED_AT=""; GIVEUP_AT="" +for i in $(seq 1 140); do + sleep 1 + segs=$(ls "$(dldir)" 2>/dev/null | grep -cE '\.seg[0-9]+' ) + [ "$segs" -gt "$SEG_PEAK" ] && SEG_PEAK=$segs + # has the bundle download actually started? (gate terminal checks on this so + # pre-download launch noise can't trip a false terminal) + breq=$(server_log | python3 -c 'import json,sys;print(sum("bundle.zip" in r.get("path","") for r in json.load(sys.stdin)))' 2>/dev/null || echo 0) + + # kill-resume: once the head segments (>=4) are on disk, force-quit + relaunch. + if [ "$MODE" = "kill-resume" ] && [ "$KILLED" -eq 0 ] && [ "$segs" -ge 4 ]; then + SEEDED_SEGS=$(ls "$(dldir)" 2>/dev/null | grep -oE 'seg[0-9]+' | sed 's/seg//' | sort -nu | tr '\n' ' ') + note "kill-resume: head segs [$SEEDED_SEGS] on disk at t=${i}s -> force-quit" + app_terminate >&2 + KILLED=1 + sleep 2 + note "post-kill persisting: $(ls "$(dldir)" 2>/dev/null | grep -oE 'seg[0-9]+' | sort -u | tr '\n' ' ')" + server_reset # isolate the resume request set + set_scenario clean >&2 # let the tail complete fast on relaunch + RESUMED_AT=$i + app_launch >&2 + continue + fi + + # Terminal detection. ONLY break on a definitive end-state — never on an + # intermediate "retry N/5" / transient line (those legitimately precede + # recovery in transient-5xx/416/stall/short and must NOT cut the capture short). + final=$(ls "$(dldir)" 2>/dev/null | grep -E '\.zip$' | grep -vcE '\.partial$') + succ=$(tail_since "$START_LINE" 'concurrent completed successfully|existing file SHA256 valid' | head -1) + # definitive terminal failures: assembled-sha mismatch (corrupt), or give-up budget exhausted (retry 5/5) + shafail=$(tail_since "$START_LINE" 'valid=false|Bundle SHA256 verification failed|SHA256_MISMATCH' | head -1) + budgetdone=$(tail_since "$START_LINE" 'retry 5/5' | head -1) + if [ "$KILLED" = "1" ] && [ "$final" -lt 1 ]; then : ; # after a kill-resume, wait for the resumed final file + elif [ -n "$succ" ] || { [ "$final" -ge 1 ] && [ "$SCN" != "give-up" ] && [ "$SCN" != "corrupt-bytes" ]; }; then note "terminal: SUCCESS at t=${i}s"; break; fi + if [ "$breq" -ge 1 ] && [ -n "$shafail" ]; then note "terminal: SHA-MISMATCH at t=${i}s"; sleep 3; break; fi + # give-up: "retry 5/5" only SCHEDULES the final retry (24s backoff). Wait ~28s + # PAST it so the 5th attempt actually fires + the terminal failed-result logs. + if [ "$breq" -ge 1 ] && [ -n "$budgetdone" ]; then + [ -z "$GIVEUP_AT" ] && { GIVEUP_AT=$i; note "give-up: retry 5/5 seen at t=${i}s, waiting for final attempt"; } + if [ $((i - GIVEUP_AT)) -ge 28 ]; then note "terminal: give-up complete at t=${i}s"; sleep 2; break; fi + fi +done + +# final assembled file sha (success scenarios): the *.zip that is not a .partial +FINAL_FILE="$(ls "$(dldir)"/*.zip 2>/dev/null | grep -v '\.partial$' | head -1)" +GOT_SHA=""; [ -n "$FINAL_FILE" ] && [ -f "$FINAL_FILE" ] && GOT_SHA="$(shasum -a 256 "$FINAL_FILE" | awk '{print $1}')" + +# assemble evidence JSON +python3 - "$OUT" "$SCN" "$MODE" "$EXPECT_SHA" "$GOT_SHA" "$SEG_PEAK" "$KILLED" "${RESUMED_AT:-}" "${SEEDED_SEGS:-}" </dev/null; applog)\" 2>/dev/null | grep -aE 'BundleUpdate|RangeDownloader|appUpdate|downloadBundle' | tail -120") +ev = { + "scenario": scn, "mode": mode, + "expectedSha": esha, "finalSha": gsha, "shaMatch": (esha==gsha and gsha!=""), + "segPeak": int(peak), "killedMidDownload": killed=="1", "resumedAtSec": resumed, + "seededSegs": [int(x) for x in seeded.split()] if seeded.strip() else [], + "serverRequests": srv, + "serverRangeStarts": [r.get("range") for r in srv if r.get("path","").endswith("bundle.zip")], + "serverStatuses": [r.get("status") for r in srv if r.get("path","").endswith("bundle.zip")], + "appLogTail": applog.splitlines(), +} +open(out,"w").write(json.dumps(ev, indent=2)) +print("wrote", out, "| shaMatch=", ev["shaMatch"], "segPeak=", ev["segPeak"], "reqs=", len(srv)) +PY +note "done -> $OUT" diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/drive.sh b/native-modules/react-native-range-downloader/conformance/ios-simulator/drive.sh new file mode 100644 index 00000000..24a100cb --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/drive.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# OCDS simulator driver helpers — lifecycle + container inspection + server control. +# Source this: `source drive.sh`. Bundle id so.onekey.wallet. +SERVER=http://localhost:8788 +APPID=so.onekey.wallet +DLDIR_REL="Documents/onekey-bundle-download" +LOG_REL="Library/Caches/logs/app-latest.log" + +sim_id() { + xcrun simctl list devices booted -j | python3 -c " +import json,sys +for rt,ds in json.load(sys.stdin).get('devices',{}).items(): + for d in ds: + if d.get('state')=='Booted': print(d['udid']); sys.exit(0) +sys.exit(1)" +} +app_data() { xcrun simctl get_app_container "$(sim_id)" "$APPID" data 2>/dev/null; } +dldir() { echo "$(app_data)/$DLDIR_REL"; } +applog() { echo "$(app_data)/$LOG_REL"; } + +# --- server scenario control --- +set_scenario() { curl -s -X POST "$SERVER/ocds/scenario" -d "{\"name\":\"$1\"}" >/dev/null; echo "scenario=$1"; } +server_reset() { curl -s -X POST "$SERVER/ocds/reset" >/dev/null; } +server_log() { curl -s "$SERVER/ocds/log"; } +server_health(){ curl -s "$SERVER/ocds/health"; } + +# --- app lifecycle --- +app_launch() { xcrun simctl launch "$(sim_id)" "$APPID" >/dev/null 2>&1; echo "launched"; } +app_terminate() { xcrun simctl terminate "$(sim_id)" "$APPID" >/dev/null 2>&1; echo "terminated"; } +app_kill_relaunch() { app_terminate; sleep 1; app_launch; } +# background our app by foregrounding Settings (closest sim approximation to suspend) +app_background() { xcrun simctl launch "$(sim_id)" com.apple.Preferences >/dev/null 2>&1; echo "backgrounded (Settings fg)"; } +# clean slate between scenarios: wipe persisted update state + download dir. +APP_PATH="$HOME/Project/app-monorepo/apps/mobile/ios/build/Build/Products/Release-iphonesimulator/OneKeyWallet.app" +app_reinstall() { + local id; id="$(sim_id)" + xcrun simctl terminate "$id" "$APPID" >/dev/null 2>&1 + xcrun simctl uninstall "$id" "$APPID" >/dev/null 2>&1 + xcrun simctl install "$id" "$APP_PATH" >/dev/null 2>&1 + echo "reinstalled clean" +} + +# --- download-dir inspection (segments / partial / final) --- +seg_snapshot() { + local d; d="$(dldir)" + if [ ! -d "$d" ]; then echo "(no download dir yet: $d)"; return; fi + ls -la "$d" 2>/dev/null | grep -E "\.seg[0-9]+|\.partial|\.zip|\.resume" || echo "(empty)" +} +clear_dldir() { local d; d="$(dldir)"; rm -rf "$d" 2>/dev/null; echo "cleared $d"; } + +# --- app-latest.log capture --- +log_lines() { wc -l < "$(applog)" 2>/dev/null || echo 0; } +# tail_since [filterRegex] +tail_since() { + local start="${1:-0}" filt="${2:-BundleUpdate|RangeDownloader|appUpdate|downloadBundle}" + local f; f="$(applog)"; [ -f "$f" ] || { echo "(no log)"; return; } + tail -n +"$((start+1))" "$f" | grep -aE "$filt" +} + +echo "drive.sh loaded. sim=$(sim_id) appdata=$(app_data)" diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/ocds-verify.workflow.js b/native-modules/react-native-range-downloader/conformance/ios-simulator/ocds-verify.workflow.js new file mode 100644 index 00000000..ea04877c --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/ocds-verify.workflow.js @@ -0,0 +1,101 @@ +export const meta = { + name: 'ocds-ios-sim-verify', + description: 'Autonomously verify the iOS OCDS concurrent-download spec (SPEC §6 #1-#11) on the booted simulator', + phases: [ + { title: 'Preflight', detail: 'server+sim health, prove the localhost download seam' }, + { title: 'Capture', detail: 'serially drive each OCDS scenario on the sim, dump evidence' }, + { title: 'Verify', detail: 'adversarially judge each evidence bundle vs the SPEC expectation' }, + { title: 'Synthesis', detail: 'SPEC §6 #1-#11 coverage table + verdict' }, + ], +}; + +const DIR = '/Users/huhuanming/Project/ocds-verify'; + +// Each scenario maps to SPEC §6 row(s). Capture is SERIAL (one simulator); verify is parallel. +const SCENARIOS = [ + { scenario: 'clean', mode: 'normal', spec: 'base T1/T2', what: 'concurrent multi-range download + concat', + expect: 'serverRangeStarts has ~8 distinct 2MB windows tiling the 16MB file (beyond the 0-0 probe); appLogTail "concurrent completed successfully"; shaMatch=true. (segPeak unreliable — judge by distinct windows + shaMatch.)' }, + { scenario: 'delay-tail', mode: 'kill-resume', spec: '#2', what: 'app killed mid-run → resumes from completed segments', + expect: 'seededSegs lists head segments on disk at force-quit (e.g. 0 1 2 3 4); after the kill the server log was reset, so serverRangeStarts on RESUME must NOT re-request those head windows from 0 (starts 0/2097152/4194304/6291456/8388608) — at most a 0-0 probe; shaMatch=true. THE ORIGINAL BUG: prove no restart-from-0.' }, + { scenario: 'range-ignored-probe', mode: 'normal', spec: '#3 (probe-200)', what: '200 to the Range probe → range unsupported → single-stream', + expect: 'probe (0-0) got 200; NO concurrent segment GETs; appLogTail shows single-stream download; shaMatch=true.' }, + { scenario: 'range-ignored-segment', mode: 'normal', spec: '#3 (segment-200)', what: '206 probe but 200 to a segment → serverIgnoredRange → permanent fallback', + expect: 'serverStatuses include 200 on a segment GET; appLogTail "[RangeDownloader] ... server returned 200 to a Range request" AND "[BundleUpdate] ... concurrent permanent fallback ... using single-stream"; a single non-range full GET (200) follows; segments cleaned; shaMatch=true.' }, + { scenario: 'transient-5xx', mode: 'normal', spec: '#1 / #4', what: '5xx on segments → backoff retry, segments kept, no restart', + expect: 'serverStatuses go 503...→206 recovery; appLogTail per-segment "retry segment N attempt"; all ranges eventually 206; shaMatch=true.' }, + { scenario: 'range-416', mode: 'normal', spec: '#4 (416)', what: '416 is transient (re-evaluate size), not permanent', + expect: 'serverStatuses include 416 then 206; appLogTail "416 — re-evaluating size before retry"; every 416 range later recovers as 206; shaMatch=true. (Guards the 416-must-not-be-permanent regression.)' }, + { scenario: 'short-body', mode: 'normal', spec: '#5 (short)', what: 'short 206 body → retry resumes the tail', + expect: 'a range delivered fewer bytes than promised then was re-fetched/recovered; appLogTail shows truncation/retry; shaMatch=true.' }, + { scenario: 'overlong-206', mode: 'normal', spec: '#5 (over-long)', what: 'over-long 206 body (Content-Length+4096) → rejected, no corruption', + expect: 'appLogTail "[RangeDownloader] ... segment N truncated/size mismatch (got ..., expected ...)"; segment retried then transient fallback → single-stream; FINAL shaMatch=true (no corruption survived). The point of #5: bad body rejected, final checksum passes.' }, + { scenario: 'misaligned-206', mode: 'normal', spec: '#5 (mis-aligned)', what: 'Content-Range off-by-one → bounds mismatch → permanent fallback', + expect: 'appLogTail permanent-fallback reason "non-conforming 206 (multipart / range / total mismatch)"; "[BundleUpdate] ... using single-stream"; segments cleaned; FINAL shaMatch=true.' }, + { scenario: 'bad-total-206', mode: 'normal', spec: '#5 (bad total)', what: 'disagreeing Content-Range total → permanent fallback', + expect: 'same permanent-fallback → single-stream path as mis-aligned; FINAL shaMatch=true.' }, + { scenario: 'corrupt-bytes', mode: 'normal', spec: '#6', what: 'corrupted assembly (wrong bytes, correct length) → checksum mismatch', + expect: 'appLogTail "[BundleUpdate] ... concurrent finished, verifying SHA256..." then a SHA256_MISMATCH / update error; shaMatch=false; NO final .zip; bounded (exactly ONE concurrent range-set, no repeated/looping re-download). PASS = mismatch detected → artifacts discarded → terminal failure, no infinite loop. (OCDS 1.2 removed the old single-stream retry-once requirement, so the ABSENCE of a "using single-stream" line after a mismatch is CONFORMANT, not a deviation.)' }, + { scenario: 'flap', mode: 'normal', spec: '#7 (network toggle)', what: 'TCP resets mid-run → transient retry, progress never resets to 0', + expect: 'serverStatuses/notes show connection resets then recovery; appLogTail retries; reported progress (if visible) is monotonic non-decreasing — never resets to 0; completed segments survive the resets; shaMatch=true.' }, + { scenario: 'stall', mode: 'normal', spec: '#10', what: 'stalled socket → stall watchdog cancels + retries', + expect: 'appLogTail "stall watchdog cancelling segment N (no bytes for Ns)" + "retry segment N"; stalled ranges recover; shaMatch=true.' }, + { scenario: 'give-up', mode: 'normal', spec: '#9', what: 'persistent failure → bounded terminal give-up, no infinite loop', + expect: 'serverStatuses all 500; appLogTail "retry 5/5 ... code=HTTP_500" (budget reached); shaMatch=false; NO final .zip; bounded (does not loop forever).' }, + { scenario: 'permanent-4xx', mode: 'normal', spec: '#3-adjacent (404)', what: '4xx permanent on range → single-stream fallback', + expect: 'range got 404; appLogTail "concurrent permanent fallback ... using single-stream"; non-range full 200 GET; shaMatch=true.' }, +]; + +phase('Preflight'); +const pre = await agent( + `Verify the OCDS sim harness is ready and PROVE the localhost download seam. Run bash: + 1. curl -s http://localhost:8788/ocds/health (must be ok). + 2. source ${DIR}/drive.sh; sim_id (must print a udid); app_data (must print a path). + 3. Prove the seam: \`source ${DIR}/drive.sh; curl -s -X POST localhost:8788/ocds/reset; set_scenario clean; app_reinstall; app_launch\` then poll up to 60s: \`curl -s localhost:8788/ocds/log | python3 -c 'import json,sys;d=json.load(sys.stdin);print(len(d),[r["path"] for r in d][:4])'\` until BOTH /ocds/manifest.json AND /ocds/bundle.zip requests appear (proves fetchConfig hit our server AND the silent strategy auto-started the concurrent download). + Return {seamOk:boolean, sawManifest:boolean, sawBundle:boolean, note:string}. If no bundle request in 60s, seamOk=false and include \`source ${DIR}/drive.sh; tail_since 0 | tail -30\`.`, + { label: 'preflight-seam', schema: { type: 'object', additionalProperties: true, required: ['seamOk'], properties: { seamOk: { type: 'boolean' }, sawManifest: { type: 'boolean' }, sawBundle: { type: 'boolean' }, note: { type: 'string' } } } }, +); +log(`preflight: seamOk=${pre?.seamOk}`); +if (!pre?.seamOk) return { aborted: true, reason: 'localhost download seam not working', preflight: pre }; + +phase('Capture'); +const captured = []; +for (const s of SCENARIOS) { + const r = await agent( + `Drive OCDS scenario "${s.scenario}" (mode ${s.mode}, SPEC ${s.spec}: ${s.what}) on the booted sim. + Run: \`bash ${DIR}/capture.sh ${s.scenario} ${s.mode}\` (reinstalls clean, sets scenario, launches with silent auto-download, drives kill-resume if mode=kill-resume, writes ${DIR}/evidence/${s.scenario}.json). + It polls up to ~110s. Then cat ${DIR}/evidence/${s.scenario}.json and return {scenario, spec:"${s.spec}", evidenceJson:, ranToTerminal:boolean, captureError:string?}. Do NOT judge pass/fail — just capture verbatim.`, + { label: `capture:${s.scenario}`, phase: 'Capture', schema: { type: 'object', additionalProperties: true, required: ['scenario'], properties: { scenario: { type: 'string' }, spec: { type: 'string' }, evidenceJson: { type: 'object', additionalProperties: true }, ranToTerminal: { type: 'boolean' }, captureError: { type: 'string' } } } }, + ); + captured.push({ ...s, capture: r }); +} + +phase('Verify'); +const verdicts = await parallel( + captured.map((c) => () => + agent( + `Adversarially verify ONE OCDS iOS scenario from captured evidence. Default to FAIL unless the evidence genuinely proves the expectation — cross-check serverStatuses + serverRangeStarts + appLogTail + shaMatch together; a single coincidental log line is NOT proof. + Scenario: ${c.scenario} (SPEC ${c.spec}) — ${c.what} + EXPECTATION: ${c.expect} + Read ${DIR}/evidence/${c.scenario}.json (Read or cat). If a field is missing or the run didn't reach terminal, treat as a gap. + Return {spec:"${c.spec}", scenario:"${c.scenario}", pass:boolean, provenBy:[exact evidence fields/values cited], gaps:[unproven bits], specDeviation:string?}. Only set specDeviation for a genuine departure from OCDS 1.2 (note: terminating on a checksum mismatch without a single-stream retry is CONFORMANT under 1.2, not a deviation).`, + { label: `verify:${c.scenario}`, phase: 'Verify', schema: { type: 'object', additionalProperties: true, required: ['scenario', 'pass'], properties: { spec: { type: 'string' }, scenario: { type: 'string' }, pass: { type: 'boolean' }, provenBy: { type: 'array', items: { type: 'string' } }, gaps: { type: 'array', items: { type: 'string' } }, specDeviation: { type: 'string' } } } }, + ).then((v) => ({ ...v, what: c.what })), + ), +); + +phase('Synthesis'); +const summary = await agent( + `Build the final OCDS iOS-simulator conformance report from these per-scenario verdicts:\n${JSON.stringify(verdicts.filter(Boolean), null, 2)}\n + Produce markdown: (1) a table mapping SPEC §6 rows #1-#11 to scenario(s) and pass/fail/partial with "proven by"; (2) call out any genuine specDeviation vs OCDS 1.2 (note: #6 terminating on a checksum mismatch with NO single-stream retry is CONFORMANT under 1.2 — do not report it as a deviation); (3) the residual items that are NOT covered here and WHY: #8 cancel (no cancel trigger exists in the iOS app code at all — needs a temp shim) and #11 two-concurrent-download (native single-flight guard — needs a shim or bg/main dual-dispatch), plus literal screen-lock (#7) which the simulator cannot do and which is behaviorally redundant with the background/kill path; (4) a blunt one-line VERDICT. Be honest about any FAIL or weak proof.`, + { label: 'synthesis', schema: { type: 'object', additionalProperties: true, required: ['markdown', 'passCount', 'failCount'], properties: { markdown: { type: 'string' }, passCount: { type: 'number' }, failCount: { type: 'number' }, verdict: { type: 'string' }, specDeviations: { type: 'array', items: { type: 'string' } } } } }, +); + +return { + preflight: pre, + passCount: summary?.passCount, + failCount: summary?.failCount, + specDeviations: summary?.specDeviations, + verdict: summary?.verdict, + report: summary?.markdown, + verdicts: verdicts.filter(Boolean), +}; diff --git a/native-modules/react-native-range-downloader/conformance/ios-simulator/server.js b/native-modules/react-native-range-downloader/conformance/ios-simulator/server.js new file mode 100644 index 00000000..83a1d124 --- /dev/null +++ b/native-modules/react-native-range-downloader/conformance/ios-simulator/server.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/* eslint-disable */ +// OCDS local fault server — drives the iOS concurrent bundle downloader for +// on-simulator OCDS conformance verification. HTTP on localhost:8788 (iOS ATS +// already exempts localhost cleartext; the native https guards are temporarily +// relaxed to allow http://localhost). NOT shipped; lives outside both repos. +// +// Endpoints: +// GET /ocds/manifest.json -> {version, jsBundleVersion, downloadUrl, sha256, fileSize} +// GET /ocds/bundle.zip -> Range-capable body with per-scenario fault injection +// POST /ocds/scenario {name} -> switch active fault scenario, reset counters +// GET /ocds/log -> recent request log (server-side evidence) +// GET /ocds/health -> {ok:true, scenario, sha256, fileSize} +// POST /ocds/reset -> clear the request log + counters + +const http = require('http'); +const crypto = require('crypto'); + +const PORT = 8788; +const SIZE = 16 * 1024 * 1024; // 16 MiB -> multiple concurrent segments + +// Deterministic bundle bytes (so sha256 is stable for a server lifetime). +const BUNDLE = Buffer.allocUnsafe(SIZE); +for (let i = 0; i < SIZE; i += 4) BUNDLE.writeUInt32LE((i * 2654435761) >>> 0, i); +const SHA256 = crypto.createHash('sha256').update(BUNDLE).digest('hex'); +const ETAG = '"ocds-bundle-v1"'; + +let scenario = 'clean'; +const counters = {}; // per (scenario, rangeStart) fault counters +const reqLog = []; // {t, method, path, range, status, scenario, note} +function log(entry) { + entry.t = Date.now(); + entry.scenario = scenario; + reqLog.push(entry); + if (reqLog.length > 5000) reqLog.shift(); + const r = entry.range != null ? ` range=${entry.range}` : ''; + console.log(`[${scenario}] ${entry.method} ${entry.path}${r} -> ${entry.status}${entry.note ? ' (' + entry.note + ')' : ''}`); +} + +function parseRange(h) { + const m = /bytes=(\d+)-(\d*)/.exec(h || ''); + if (!m) return null; + return { start: Number(m[1]), end: m[2] === '' ? SIZE - 1 : Number(m[2]) }; +} + +function bump(key) { + counters[key] = (counters[key] || 0) + 1; + return counters[key]; +} + +function send206(res, range, body, note) { + res.writeHead(206, { + 'Content-Range': `bytes ${range.start}-${range.start + body.length - 1}/${SIZE}`, + 'Content-Length': body.length, + 'Accept-Ranges': 'bytes', + ETag: ETAG, + }); + res.end(body); + return note; +} + +const server = http.createServer((req, res) => { + const url = req.url.split('?')[0]; + + // --- control plane --- + if (req.method === 'POST' && url === '/ocds/scenario') { + let b = ''; + req.on('data', (d) => (b += d)); + req.on('end', () => { + try { scenario = JSON.parse(b).name || 'clean'; } catch { scenario = 'clean'; } + for (const k of Object.keys(counters)) delete counters[k]; + log({ method: 'POST', path: url, status: 200, note: 'scenario=' + scenario }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, scenario })); + }); + return; + } + if (req.method === 'POST' && url === '/ocds/reset') { + reqLog.length = 0; + for (const k of Object.keys(counters)) delete counters[k]; + res.writeHead(200); res.end('{"ok":true}'); + return; + } + if (url === '/ocds/log') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reqLog)); + return; + } + if (url === '/ocds/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, scenario, sha256: SHA256, fileSize: SIZE })); + return; + } + if (url === '/ocds/manifest.json') { + log({ method: 'GET', path: url, status: 200 }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '6.5.0', // == current app version -> NOT an app-shell update + jsBundleVersion: '202699999', // newer bundle -> jsBundle hot-update path (localhost) + jsBundleCount: 1, + updateStrategy: 0, // silent -> auto-download on launch (no UI tap) + downloadUrl: `http://localhost:${PORT}/ocds/bundle.zip`, + sha256: SHA256, + fileSize: SIZE, + signature: '', + })); + return; + } + + // --- data plane: the bundle, Range + fault injection --- + if (url === '/ocds/bundle.zip') { + const range = parseRange(req.headers.range); + const rs = range ? range.start : null; + + // Non-range (single-stream / probe) request. + if (!range) { + // give-up: single-stream must ALSO fail, else the run succeeds and never + // exhausts the retry budget. + if (scenario === 'give-up') { + res.writeHead(500); res.end('boom'); + log({ method: req.method, path: url, range: 'none', status: 500, note: 'give-up full' }); + return; + } + // permanent-4xx: concurrent ranges 404 -> fallback to single-stream full GET 200. + log({ method: req.method, path: url, range: 'none', status: 200, note: 'full body' }); + res.writeHead(200, { 'Content-Length': SIZE, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(BUNDLE); + return; + } + + const full = BUNDLE.subarray(range.start, range.end + 1); + + switch (scenario) { + case 'clean': + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206 }); + return void send206(res, range, full); + + case 'delay-tail': { // segs 5,6,7 (start>=10485760) hang ~25s; segs 0-4 respond now. + if (range.start >= 10485760) { + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 0, note: 'HANG 25s (tail)' }); + setTimeout(() => { try { send206(res, range, full); } catch {} }, 25000); + return; + } + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'fast head' }); + return void send206(res, range, full); + } + + case 'short-body': { // T5c: first hit on a range sends a SHORT body, then full on retry. + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const n = bump('sb:' + rs); + if (n === 1) { + const half = full.subarray(0, Math.max(1, Math.floor(full.length / 2))); + res.writeHead(206, { + 'Content-Range': `bytes ${range.start}-${range.end}/${SIZE}`, + 'Content-Length': full.length, // promise full, deliver half -> incomplete + 'Accept-Ranges': 'bytes', ETag: ETAG, + }); + res.end(half); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'SHORT body (half)' }); + return; + } + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'recovered full' }); + return void send206(res, range, full); + } + + case 'range-ignored-probe': // 200 to EVERYTHING incl probe -> rangeUnsupported -> single-stream + res.writeHead(200, { 'Content-Length': SIZE, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(BUNDLE); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 200, note: '200 ignore-range (incl probe)' }); + return; + + case 'range-ignored-segment': // 206 to probe, 200 to segment GETs -> serverIgnoredRange -> permanent fallback + if (range.start === 0 && range.end === 0) { + log({ method: req.method, path: url, range: '0-0', status: 206, note: 'probe ok' }); + return void send206(res, range, full); + } + res.writeHead(200, { 'Content-Length': SIZE, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(BUNDLE); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 200, note: '200 to segment (range ignored)' }); + return; + + case 'overlong-206': { // 206 correct CR but body +4096 bytes -> size mismatch -> retry -> single-stream + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const over = Buffer.concat([full, Buffer.alloc(4096, 0x5a)]); + res.writeHead(206, { 'Content-Range': `bytes ${range.start}-${range.end}/${SIZE}`, 'Content-Length': over.length, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(over); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'OVERLONG +4096' }); + return; + } + + case 'misaligned-206': { // 206 Content-Range off by one -> bounds mismatch -> Permanent fallback + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + res.writeHead(206, { 'Content-Range': `bytes ${range.start + 1}-${range.end}/${SIZE}`, 'Content-Length': full.length, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(full); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'MISALIGNED CR +1' }); + return; + } + + case 'bad-total-206': { // 206 with disagreeing total -> Permanent fallback + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + res.writeHead(206, { 'Content-Range': `bytes ${range.start}-${range.end}/${SIZE + 1}`, 'Content-Length': full.length, 'Accept-Ranges': 'bytes', ETag: ETAG }); + res.end(full); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'BAD TOTAL' }); + return; + } + + case 'corrupt-bytes': { // correct length + CR, but one segment's bytes flipped -> assembled sha != manifest sha + let body = full; + if (range.start === 6291456) { body = Buffer.from(full); body[0] ^= 0xff; } + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: range.start === 6291456 ? 'CORRUPTED byte' : '' }); + return void send206(res, range, body); + } + + case 'flap': { // first hit per segment -> TCP reset (transient); retry -> clean. Probe unaffected. + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const n = bump('flap:' + rs); + if (n === 1) { + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 0, note: 'CONN RESET' }); + req.socket.destroy(); + return; + } + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'recovered after reset' }); + return void send206(res, range, full); + } + + case 'transient-5xx': { // first 2 hits per range -> 503, then recover. + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const n = bump('t5:' + rs); + if (n <= 2) { + res.writeHead(503, { 'Retry-After': '0' }); res.end('overloaded'); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 503, note: `attempt ${n}` }); + return; + } + return void send206(res, range, full, log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'recovered' })); + } + + case 'permanent-4xx': { // range -> 404 permanent -> concurrent permanent fallback -> single-stream (handled above). + res.writeHead(404); res.end('gone'); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 404, note: 'permanent' }); + return; + } + + case 'range-416': { // first hit per range -> 416, then recover. + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const n = bump('416:' + rs); + if (n === 1) { + res.writeHead(416, { 'Content-Range': `bytes */${SIZE}` }); res.end(); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 416, note: 'first' }); + return; + } + return void send206(res, range, full, log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'recovered' })); + } + + case 'stall': { // T10: first hit per range sends headers + a few bytes then HANGS (no end). + if (range.start === 0 && range.end === 0) return void send206(res, range, full); + const n = bump('st:' + rs); + if (n === 1) { + res.writeHead(206, { + 'Content-Range': `bytes ${range.start}-${range.end}/${SIZE}`, + 'Content-Length': full.length, 'Accept-Ranges': 'bytes', ETag: ETAG, + }); + res.write(full.subarray(0, 16)); // dribble then stall + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'STALL (16B then hang)' }); + return; // never end -> stall watchdog must cancel + } + return void send206(res, range, full, log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 206, note: 'recovered after stall' })); + } + + case 'give-up': { // everything fails -> exhaust retry budget -> DownloadGaveUpError. + res.writeHead(500); res.end('boom'); + log({ method: req.method, path: url, range: `${range.start}-${range.end}`, status: 500, note: 'always fail' }); + return; + } + + default: + return void send206(res, range, full); + } + } + + res.writeHead(404); res.end('not found'); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`OCDS fault server on http://localhost:${PORT} size=${SIZE} sha256=${SHA256}`); + console.log(`scenarios: clean | short-body | transient-5xx | permanent-4xx | range-416 | stall | give-up`); +}); diff --git a/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift new file mode 100644 index 00000000..4c0ea0db --- /dev/null +++ b/native-modules/react-native-range-downloader/ios/RangeDownloadLogic.swift @@ -0,0 +1,187 @@ +import Foundation +import CommonCrypto + +// MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5) +// +// This file holds the DETERMINISTIC, dependency-light pieces of the range +// downloader: HTTP-status classification, range planning, Content-Range parsing, +// Retry-After / backoff math, and the SHA-256 integrity hash. They were extracted +// VERBATIM (bodies unchanged) from `ReactNativeRangeDownloader.swift` so they can +// be compiled and unit-tested WITHOUT the NitroModules / ReactNativeNativeLogger +// dependencies or the background `URLSession` (which is device-only). +// +// `RangeDownloader` keeps using them via `RangeDownloadLogic.`. The Nitro wire +// projections (`wireOutcome` / `wireKind`) stay in the main module file because +// they depend on the codegen enums; everything here is pure Swift + Foundation + +// CommonCrypto. + +// MARK: - Typed failure model (OCDS §4) +// +// The IN-PROCESS core returns this Swift-native typed class to its in-process +// caller (BundleUpdate); the Nitro shim maps it onto the regenerated wire enum +// `RangeDownloadOutcome` (completed | fallbackTransient | fallbackPermanent) so +// the failure class crosses the JS boundary as an EXPLICIT value, never inferred +// from incidental on-disk side effects (which §4 forbids). The core enum is a +// SEPARATE type from the generated wire `RangeFallbackKind` (same case set) so +// the core can carry an extra `failureClass` projection without the module-scope +// name colliding with the codegen typealias; `wireOutcome` / `wireKind` (in the +// main module file) do the 1:1 translation onto the generated enums. +public enum RangeDownloadClass: Equatable { + case completed + /// Resumable interruption — keep `.segN`, the concurrent path may resume. + case fallbackTransient + /// Concurrency fundamentally unusable for this object — segments discarded. + case fallbackPermanent + + var isFallback: Bool { self != .completed } +} + +/// Typed sub-classification of a fallback (in-process mirror of the generated +/// wire `RangeFallbackKind`). Used by the caller/analytics to know WHY without +/// parsing the reason string, and by the core to drive keep-vs-discard. Named +/// distinctly from the codegen `RangeFallbackKind` typealias to avoid a +/// module-scope name clash; `wireKind` (in the main module file) translates onto +/// the wire enum. +public enum RangeFallbackClass: String { + case serverIgnoredRange + case rangeUnsupported + case authExpired + case notFound + case redirectRejected + case checksumMismatch + case multipartOrBadTotal + case transientNetwork + case throttled + case budgetExhausted + + /// The §4 recovery class implied by this kind. + var failureClass: RangeDownloadClass { + switch self { + case .transientNetwork, .throttled, .budgetExhausted: + return .fallbackTransient + case .serverIgnoredRange, .rangeUnsupported, .authExpired, .notFound, + .redirectRejected, .checksumMismatch, .multipartOrBadTotal: + return .fallbackPermanent + } + } +} + +// MARK: - Pure logic namespace + +/// Dependency-free static logic for the range downloader. Bodies are a verbatim +/// move from `RangeDownloader`; callsites there now call `RangeDownloadLogic.`. +public enum RangeDownloadLogic { + + // §5.4 backoff knobs (moved here because only `backoffDelay` consumes them). + static let retryBaseDelaySeconds: Double = 1.0 + static let retryMaxDelaySeconds: Double = 30.0 + + // MARK: - Range planning / probing + + static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] { + var out: [(Int64, Int64)] = [] + let chunk = (total + Int64(segments) - 1) / Int64(segments) + var i = 0 + while i < segments { + let start = Int64(i) * chunk + if start >= total { break } + let end = min(start + chunk - 1, total - 1) + out.append((start, end)) + i += 1 + } + return out + } + + // MARK: - HTTP status classification (OCDS §4 table + catch-all) + + /// Maps an HTTP status on a Range request to a §4 fallback kind. Used by the + /// download-finish delegate to decide keep-vs-discard. Status 206 is handled by + /// the caller (validated, not a fallback); 200 is `serverIgnoredRange`. + static func classifyStatus(_ status: Int) -> RangeFallbackClass { + switch status { + case 200: + return .serverIgnoredRange + case 416: + // §4: 416 to a resume request → Transient (re-evaluate size, keep segments). + return .transientNetwork + case 401, 403: + return .authExpired + case 404, 410: + return .notFound + case 408, 429: + return .throttled + case 501, 505: + // Explicit Permanent carve-outs from the 5xx → Transient default. + return .rangeUnsupported + case 500...599: + return .throttled + case 400...499: + // Default 4xx → Permanent (408/429 handled above). + return .rangeUnsupported + default: + // Anything else / unknown → Permanent per the §4 catch-all. + return .rangeUnsupported + } + } + + /// Parses a `Retry-After` header value (delta-seconds form only; HTTP-date form + /// is treated as absent). Returns nil when missing/unparseable. + static func parseRetryAfterSeconds(_ value: String?) -> Double? { + guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty, + let seconds = Double(value), seconds >= 0 else { return nil } + return seconds + } + + static func parseContentRangeTotal(_ header: String) -> Int64? { + // "bytes 0-0/65226095" + guard let slash = header.lastIndex(of: "/") else { return nil } + let tail = header[header.index(after: slash)...] + return Int64(tail.trimmingCharacters(in: .whitespaces)) + } + + /// Parses the start/end of a "bytes -/" Content-Range. + static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? { + // Drop the leading "bytes " and the trailing "/". + let trimmed = header.trimmingCharacters(in: .whitespaces) + guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil } + var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...]) + if let slash = rangePart.firstIndex(of: "/") { + rangePart = String(rangePart[.. Double { + if let retryAfter = retryAfter { return min(retryAfter, retryMaxDelaySeconds * 2) } + let exp = retryBaseDelaySeconds * pow(2.0, Double(max(0, attempt - 1))) + let capped = min(exp, retryMaxDelaySeconds) + // Full jitter in [0, capped]. + return Double.random(in: 0...capped) + } + + // MARK: - Integrity (§5.5) + + static func calculateSHA256(_ filePath: String) -> String? { + let fm = FileManager.default + guard fm.fileExists(atPath: filePath), + let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil } + defer { try? fileHandle.close() } + var context = CC_SHA256_CTX() + CC_SHA256_Init(&context) + while autoreleasepool(invoking: { () -> Bool in + let data = fileHandle.readData(ofLength: 8192) + if data.isEmpty { return false } + data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) } + return true + }) {} + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256_Final(&hash, &context) + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift index ecef6453..98e0c650 100644 --- a/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift +++ b/native-modules/react-native-range-downloader/ios/ReactNativeRangeDownloader.swift @@ -1,5 +1,4 @@ import Foundation -import CommonCrypto import NitroModules import ReactNativeNativeLogger @@ -14,24 +13,38 @@ import ReactNativeNativeLogger // - one hardcoded background session identifier → one session per channel; // - `onProgress` closure → a listener registry broadcasting // `RangeDownloadEvent`s (multi-consumer); -// - `throws FallbackError` → returns `RangeDownloadResult(.fallback, …)`. +// - `throws FallbackError` → returns a typed `RangeDownloadResult` +// (`fallbackTransient` / `fallbackPermanent` + `fallbackKind`). class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { func download(params: RangeDownloadParams) throws -> Promise { return Promise.async { - let (outcome, filePath, fallbackReason) = await RangeDownloader.shared.download( + let (klass, filePath, fallbackReason, fallbackClass) = await RangeDownloader.shared.download( channel: params.channel, taskId: params.taskId, urlString: params.url, filePath: params.destFilePath, expectedSha256: params.expectedSha256, segmentCount: params.segmentCount.map { Int($0) }, - minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) } + minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) }, + // §5.4: caller-tunable retry/timeout/deadline knobs forwarded straight + // from the regenerated `RangeDownloadParams`. Omitted (nil) values let the + // core use its platform defaults. + maxSegmentAttempts: params.maxSegmentAttempts.map { Int($0) }, + requestTimeoutSeconds: params.requestTimeoutSeconds, + stallTimeoutSeconds: params.stallTimeoutSeconds, + overallDeadlineSeconds: params.overallDeadlineSeconds ) + // Wire mapping (OCDS §4): map the in-process typed class onto the + // regenerated wire enum — no lossy collapse. `completed`, `fallbackTransient` + // and `fallbackPermanent` each cross the JS bridge as their own case, and the + // optional `fallbackKind` sub-classification is forwarded so callers / + // analytics can branch without parsing the reason string. return RangeDownloadResult( - outcome: outcome, + outcome: klass.wireOutcome, filePath: filePath, - fallbackReason: fallbackReason + fallbackReason: fallbackReason, + fallbackKind: fallbackClass?.wireKind ) } } @@ -107,6 +120,42 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec { /// "channel|taskId" so multiple channels can download at once. Each running /// task's `taskDescription` encodes "channel|taskId|segIndex" so a delegate /// callback can locate both the run and the segment. +// MARK: - Typed failure model (OCDS §4) — wire projections +// +// The dependency-free enum bodies for `RangeDownloadClass` / `RangeFallbackClass` +// (plus the deterministic logic funcs) live in `RangeDownloadLogic.swift` so they +// can be unit-tested without the Nitro / NativeLogger deps. Only the wire +// projections — which depend on the codegen enums (`RangeDownloadOutcome` / +// `RangeFallbackKind`) — remain here, in the module that has those generated types. +// +// The IN-PROCESS core returns the Swift-native typed class to its in-process +// caller (BundleUpdate); the Nitro shim maps it onto the regenerated wire enum +// `RangeDownloadOutcome` (completed | fallbackTransient | fallbackPermanent) so +// the failure class crosses the JS boundary as an EXPLICIT value, never inferred +// from incidental on-disk side effects (which §4 forbids). +extension RangeDownloadClass { + /// 1:1 projection onto the generated wire enum (`RangeDownloadOutcome`) that + /// crosses the JS bridge. No collapse: each in-process class maps to its own + /// typed wire case. + var wireOutcome: RangeDownloadOutcome { + switch self { + case .completed: return .completed + case .fallbackTransient: return .fallbacktransient + case .fallbackPermanent: return .fallbackpermanent + } + } +} + +extension RangeFallbackClass { + /// 1:1 projection onto the generated wire enum (`RangeFallbackKind`). The case + /// names are the wire union's string values verbatim, so `fromString` is exact + /// and total (the force-unwrap can never fail). + var wireKind: RangeFallbackKind { + // swiftlint:disable:next force_unwrapping + return RangeFallbackKind(fromString: self.rawValue)! + } +} + public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public static let shared = RangeDownloader() @@ -161,26 +210,87 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { var continuation: CheckedContinuation? var prevProgress = -1 var fellBack = false + /// §4 typed class for the eventual fallback. Set by the delegate when it + /// classifies a non-206 status or a redirect/total/stash failure; consumed by + /// finalize so the outcome is an EXPLICIT class, never inferred from on-disk + /// side effects. `serverIgnoredRange` (Permanent) is the historical default + /// for the bare `fellBack` path (status 200). + var fellBackKind: RangeFallbackClass = .serverIgnoredRange /// Set when a segment could not be stashed (move/size-check failure). Carries /// the terminal error so didCompleteWithError finalizes instead of hanging. var stashError: Error? + /// §4/§5.4: per-segment indexes whose finished body was a TRANSIENT HTTP + /// status (429/5xx/408/416) — the body is discarded and the segment is + /// re-enqueued (G2) instead of failing the whole run. Optional Retry-After + /// seconds captured from that response, used to override backoff. + var transientSegmentRetryAfter: [Int: Double?] = [:] + /// §4 (416): segment indexes that returned `416 Range Not Satisfiable`. A + /// bare 416 must NOT re-request the same range blindly: the total size / + /// validator is re-evaluated (re-probe) first. If the total or ETag changed + /// the object changed under us → object-change (wipe + restart, Permanent); + /// otherwise the range is still valid and we keep the resumable `.segN` and + /// retry. Set in didFinishDownloadingTo, consumed in didCompleteWithError. + var sizeReevalIndexes: Set = [] /// Whether a strong validator (ETag) was captured for this run. When false, /// resumable `.segN` state must not be trusted across attempts. var hasValidator = false let sessionIdentifier: String + /// §5.4: per-segment transient retry budget (caller-tunable). + let maxSegmentAttempts: Int + /// §5.4: number of transient retry attempts already spent per segment index. + var segmentAttempts: [Int: Int] = [:] + /// §5.4: segment indexes the stall watchdog cancelled, so didCompleteWithError + /// treats the resulting NSURLErrorCancelled as a transient stall (retry), + /// not an external user cancel (terminate). + var stallCancelledIndexes: Set = [] + /// §5.4 (G2): segment indexes with a backoff retry already SCHEDULED (an + /// asyncAfter pending) but not yet re-enqueued. A pending retry is invisible + /// to `getAllTasks` (no live task exists during the backoff window), so a + /// sibling segment's completion could otherwise re-increment this segment's + /// attempt counter and schedule a DUPLICATE retry. Inserted when the + /// asyncAfter is armed; cleared inside `enqueueSegment` when the real task is + /// created. Both `retrySegmentIfUnderBudget` and the missing-segment + /// re-enqueue gate on this set so a segment is never double-enqueued. + var pendingRetryIndexes: Set = [] + /// §5.11 (single-run portion): wall-clock deadline; nil = unbounded. The + /// cross-restart budget/deadline is owned by the shared-JS track. + let deadline: Date? + /// §5.4: last time ANY segment of this run made progress (didWriteData), + /// used by the stall watchdog. Only foreground/active time should count. + var lastProgressAt = Date() + /// The URL this run is fetching, retained so the delegate can re-enqueue a + /// single segment without re-plumbing it through every call. + var url: URL? + init(channel: DownloadChannel, taskId: String, filePath: String, - segmentCount: Int, sessionIdentifier: String) { + segmentCount: Int, sessionIdentifier: String, + maxSegmentAttempts: Int, deadline: Date?) { self.channel = channel self.taskId = taskId self.filePath = filePath self.segmentCount = segmentCount self.sessionIdentifier = sessionIdentifier + self.maxSegmentAttempts = maxSegmentAttempts + self.deadline = deadline } func segPath(_ index: Int) -> String { "\(filePath).seg\(index)" } + + /// True once the wall-clock deadline (if any) has passed. + var isPastDeadline: Bool { + guard let deadline = deadline else { return false } + return Date() >= deadline + } } + // Default retry/backoff/deadline knobs (§5.4 / §5.11 single-run). Caller-tunable + // via RangeDownloadParams; these are platform configuration, not part of OCDS. + private static let defaultMaxSegmentAttempts = 4 + private static let defaultRequestTimeoutSeconds: Double = 60 + private static let defaultStallTimeoutSeconds: Double = 30 + // §5.4 backoff knobs now live on `RangeDownloadLogic` (used only by backoffDelay). + override init() { super.init() NotificationCenter.default.addObserver( @@ -246,6 +356,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { cfg.isDiscretionary = false cfg.sessionSendsLaunchEvents = true cfg.httpMaximumConnectionsPerHost = segmentCount + // §5.4: connection / request timeout. The session is cached per channel, so + // this uses the default; per-run knobs are enforced by the JS-side deadline + // and the in-app stall watchdog rather than re-creating the session. A + // background session keeps the resource timeout generous so a legitimately + // long suspended transfer is not killed by the OS resource clock (§5.10). + cfg.timeoutIntervalForRequest = RangeDownloader.defaultRequestTimeoutSeconds let created = URLSession(configuration: cfg, delegate: self, delegateQueue: nil) lock.lock() // Another thread may have raced us; prefer the first-stored session. @@ -324,10 +440,11 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { // MARK: - Public entry /// Downloads [urlString] into [filePath] using concurrent background ranges. - /// Returns `(.completed, filePath, nil)` on success, or `(.fallback, filePath, - /// reason)` when the caller should use its single-stream path. Transient - /// network errors are also reported as `.fallback` with the error reason (the - /// `.segN` files are kept for the next attempt). + /// Returns `(.completed, filePath, nil, nil)` on success, or a typed fallback + /// tuple `(.fallbackTransient | .fallbackPermanent, filePath, reason, kind)` + /// when the caller should use its single-stream path. Transient network errors + /// resolve to `.fallbackTransient` and KEEP the `.segN` files for the next + /// attempt; permanent ones discard them. public func download( channel: DownloadChannel, taskId: String, @@ -335,42 +452,90 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { filePath: String, expectedSha256: String?, segmentCount: Int?, - minConcurrentBytes: Int64? - ) async -> (RangeDownloadOutcome, String, String?) { + minConcurrentBytes: Int64?, + maxSegmentAttempts: Int? = nil, + requestTimeoutSeconds: Double? = nil, + stallTimeoutSeconds: Double? = nil, + overallDeadlineSeconds: Double? = nil + ) async -> (RangeDownloadClass, String, String?, RangeFallbackClass?) { let segCount = max(1, segmentCount ?? Self.defaultSegmentCount) let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes + let maxAttempts = max(1, maxSegmentAttempts ?? Self.defaultMaxSegmentAttempts) + // §5.4: caller-tunable stall window for this run's watchdog; the background + // URLSession's per-request timeout is cached per channel (see `session(...)`), + // so the per-run request timeout is enforced via the deadline + stall watchdog + // rather than re-creating the session. + let stallSeconds = (stallTimeoutSeconds.map { $0 > 0 ? $0 : nil } ?? nil) + ?? Self.defaultStallTimeoutSeconds + _ = requestTimeoutSeconds // documented above; session timeout is channel-cached + let deadline: Date? = { + guard let s = overallDeadlineSeconds, s > 0 else { return nil } + return Date().addingTimeInterval(s) + }() let key = Self.runKey(channel: channel, taskId: taskId) guard let url = URL(string: urlString) else { - return (.fallback, filePath, "invalid url") + return (.fallbackPermanent, filePath, "invalid url", .rangeUnsupported) } // HTTPS-only: background URLSession + transport hardening. guard urlString.hasPrefix("https://") else { - return (.fallback, filePath, "url must use https") + return (.fallbackPermanent, filePath, "url must use https", .redirectRejected) + } + + // §5.8 (G7): at most one live run per destination. A second download() for + // the same key/filePath while one is in flight joins-or-fails-fast rather + // than overwriting RunState and co-writing the same `.segN`. The guard keys + // off `runs[key]` MEMBERSHIP, not `continuation != nil`: the continuation is + // only assigned after probe + insert, so a `continuation != nil` test left a + // window (insert → continuation assignment, and the synchronous + // allSegmentsPresent fast path which never sets a continuation at all) where + // a half-initialized run was invisible and a 2nd download() could overwrite + // `runs[key]`. We fail fast (the caller retry loop re-drives cleanly) to + // avoid join bookkeeping hangs. + if let existing = run(forKey: key), existing.filePath == filePath { + return (.fallbackTransient, filePath, + "another run is already active for this destination", .budgetExhausted) } let probe: ProbeResult do { probe = try await self.probe(url: url) } catch { - return (.fallback, filePath, "probe failed: \(error.localizedDescription)") + // A probe that cannot reach the server is a transient network condition. + return (.fallbackTransient, filePath, + "probe failed: \(error.localizedDescription)", .transientNetwork) } guard probe.supportsRange, probe.total >= minBytes else { - return (.fallback, filePath, "range unsupported or file too small") + return (.fallbackPermanent, filePath, + "range unsupported or file too small", .rangeUnsupported) } let state = RunState( channel: channel, taskId: taskId, filePath: filePath, segmentCount: segCount, - sessionIdentifier: Self.sessionIdentifier(for: channel) + sessionIdentifier: Self.sessionIdentifier(for: channel), + maxSegmentAttempts: maxAttempts, deadline: deadline ) state.totalSize = probe.total state.etag = probe.etag + state.url = url state.hasValidator = (probe.etag?.isEmpty == false) - state.ranges = Self.planRanges(total: probe.total, segments: segCount) + state.ranges = RangeDownloadLogic.planRanges(total: probe.total, segments: segCount) state.segmentWritten = [Int64](repeating: 0, count: state.ranges.count) + // §5.8 (G7): claim the destination slot atomically. Re-check membership under + // the SAME lock hold as the insert so two download() calls that both passed + // the early guard (which ran before their respective async probes) cannot + // both insert — the loser fails fast and never co-writes `.segN`. From this + // insert onward `runs[key]` membership is the single-flight authority, so a + // half-initialized run (continuation not yet assigned, or the synchronous + // fast path that never assigns one) is still observed as live by any racer. lock.lock() + if let existing = runs[key], existing.filePath == filePath, existing !== state { + lock.unlock() + return (.fallbackTransient, filePath, + "another run is already active for this destination", .budgetExhausted) + } runs[key] = state lock.unlock() @@ -386,6 +551,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "") + // §5.4: start the bytes-stalled watchdog for this run. It cancels a stalled + // segment task so didCompleteWithError routes it through the in-place retry + // path. It only counts foreground/active wall time toward the stall window so + // a legitimately suspended background transfer (§5.10) is never false-cancelled. + startStallWatchdog(key: key, stallSeconds: stallSeconds) + do { // If every segment is already on disk (resume after suspension/kill), skip // straight to concatenation. @@ -402,32 +573,36 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } catch let fb as FallbackError { clearRun(key: key) emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason) - return (.fallback, filePath, fb.reason) + return (fb.kind.failureClass, filePath, fb.reason, fb.kind) } catch { - // Transient error → ask caller to fall back (segments retained for retry). + // A non-FallbackError thrown out of the run is a local/transient condition + // (disk I/O, an interrupted segment) → ask the caller to retry the + // concurrent path; segments are retained for resume. clearRun(key: key) let reason = error.localizedDescription emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason) - return (.fallback, filePath, reason) + return (.fallbackTransient, filePath, reason, .transientNetwork) } clearRun(key: key) // Optional immediate SHA256 self-check backstop. When omitted, the caller - // verifies after the fact. + // verifies after the fact. A whole-file checksum mismatch is Permanent (§4): + // the assembled bytes are unsalvageable, so discard final + artifacts. if let expected = expectedSha256, !expected.isEmpty { - let actual = Self.calculateSHA256(filePath) + let actual = RangeDownloadLogic.calculateSHA256(filePath) if actual?.lowercased() != expected.lowercased() { try? FileManager.default.removeItem(atPath: filePath) + discardArtifacts(filePath: filePath) let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))" OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)") emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason) - return (.fallback, filePath, reason) + return (.fallbackPermanent, filePath, reason, .checksumMismatch) } } emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "") - return (.completed, filePath, nil) + return (.completed, filePath, nil, nil) } private func clearRun(key: String) { @@ -436,23 +611,23 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { // MARK: - Range planning / probing - static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] { - var out: [(Int64, Int64)] = [] - let chunk = (total + Int64(segments) - 1) / Int64(segments) - var i = 0 - while i < segments { - let start = Int64(i) * chunk - if start >= total { break } - let end = min(start + chunk - 1, total - 1) - out.append((start, end)) - i += 1 + private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool } + + struct FallbackError: Error { + let reason: String + /// §4 typed sub-class. Defaults to a Permanent `serverIgnoredRange` only so an + /// un-annotated legacy throw keeps the previous "discard + single-stream" + /// behavior; all new throw sites pass an explicit kind. + let kind: RangeFallbackClass + init(reason: String, kind: RangeFallbackClass = .serverIgnoredRange) { + self.reason = reason + self.kind = kind } - return out } - private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool } - - struct FallbackError: Error { let reason: String } + // HTTP status classification (classifyStatus), Retry-After parsing + // (parseRetryAfterSeconds), Content-Range parsing and backoff math now live on + // `RangeDownloadLogic` (OCDS §4 / §5). Callsites use `RangeDownloadLogic.`. /// One-byte Range request on a default (foreground) session to learn total /// size + ETag + Range support. Background sessions can't do data tasks, so @@ -474,7 +649,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { let etag = http.value(forHTTPHeaderField: "ETag") if http.statusCode == 206, let cr = http.value(forHTTPHeaderField: "Content-Range"), - let total = Self.parseContentRangeTotal(cr) { + let total = RangeDownloadLogic.parseContentRangeTotal(cr) { cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true)) } else { // 200 (Range ignored) or anything else → single-stream. @@ -485,29 +660,6 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { } } - static func parseContentRangeTotal(_ header: String) -> Int64? { - // "bytes 0-0/65226095" - guard let slash = header.lastIndex(of: "/") else { return nil } - let tail = header[header.index(after: slash)...] - return Int64(tail.trimmingCharacters(in: .whitespaces)) - } - - /// Parses the start/end of a "bytes -/" Content-Range. - static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? { - // Drop the leading "bytes " and the trailing "/". - let trimmed = header.trimmingCharacters(in: .whitespaces) - guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil } - var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...]) - if let slash = rangePart.firstIndex(of: "/") { - rangePart = String(rangePart[.. Bool { + // Deadline check first (§5.11 single-run bound). + if state.isPastDeadline { return false } + // §5.4 (G2): claim the pending-retry slot and bump the attempt counter under + // ONE lock hold. If a retry is already pending for this idx (its asyncAfter + // hasn't fired yet), a concurrent caller — e.g. a sibling segment's + // completion driving the missing-segment re-enqueue — must NOT bump the + // counter again nor arm a duplicate asyncAfter. Returning `true` here reports + // "a retry is in flight for this segment" without scheduling a second one. + let claim: (alreadyPending: Bool, attempts: Int) = lock.withLockValue { + if state.pendingRetryIndexes.contains(idx) { + return (true, state.segmentAttempts[idx] ?? 0) + } + let n = (state.segmentAttempts[idx] ?? 0) + 1 + state.segmentAttempts[idx] = n + state.pendingRetryIndexes.insert(idx) + return (false, n) + } + if claim.alreadyPending { return true } + let attempts = claim.attempts + if attempts > state.maxSegmentAttempts { + // Over budget: release the slot we just claimed so a later genuine retry + // (if any) isn't blocked, and report exhausted. + lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return false + } + guard let url = lock.withLockValue({ state.url }) else { + lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return false + } + let ranges = lock.withLockValue { state.ranges } + guard idx < ranges.count else { + lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return false + } + let range = ranges[idx] + let delay = RangeDownloadLogic.backoffDelay(attempt: attempts, retryAfter: retryAfter) + let session = session(forChannel: state.channel, segmentCount: state.segmentCount) + let channelStr = state.channel.stringValue + let taskId = state.taskId + OneKeyLog.info("RangeDownloader", + "\(channelStr)/\(taskId): retry segment \(idx) attempt \(attempts)/\(state.maxSegmentAttempts) in \(String(format: "%.2f", delay))s") + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + // Bail if the run was cleared (cancel / finalize) or the segment landed + // in the meantime, and guard against a double-enqueue. On every bail path + // clear the pending-retry marker (G2) so it doesn't leak: either the slot + // is moot (run gone / segment landed) or a real task already exists. + guard let live = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)), + live === state else { + self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return + } + if FileManager.default.fileExists(atPath: state.segPath(idx)) { + self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return + } + session.getAllTasks { tasks in + let alreadyLive = tasks.contains { t in + guard let d = t.taskDescription, + let decoded = Self.decodeTaskDescription(d), + decoded.channel.stringValue == channelStr, + decoded.taskId == taskId, + decoded.segIndex == idx else { return false } + return t.state == .running || t.state == .suspended + } + if alreadyLive { + self.lock.withLockValue { _ = state.pendingRetryIndexes.remove(idx) } + return + } + // enqueueSegment clears the pending marker once the real task exists. + self.enqueueSegment(state: state, session: session, url: url, + idx: idx, range: range) + } + } + return true + } + + /// §4 (416): a segment got `416 Range Not Satisfiable`. Per §4 a bare 416 must + /// NOT discard resumable bytes and must NOT blindly re-request the same range — + /// the total/validator is re-evaluated first. We re-probe the URL: + /// • Probe fails / range no longer supported → transient; retry the segment + /// in place under budget (the network blip will clear), keeping `.segN`. + /// • Total or ETag CHANGED → the object changed under us; the planned ranges + /// are stale → object-change: wipe + restart (Permanent), so the run + /// re-plans against the new object instead of stitching mismatched bytes. + /// • Total/ETag UNCHANGED → the range is genuinely still valid (a transient + /// server hiccup); keep `.segN` and retry the segment normally. + /// Must be called from a delegate callback for [idx]; runs the re-probe async. + private func reevaluateSizeThenRetry(state: RunState, session: URLSession, + idx: Int, retryAfter: Double?, + ranges: [(start: Int64, end: Int64)]) { + guard let url = lock.withLockValue({ state.url }) else { + finalizeTransientFallback(state: state, idx: idx, + reason: "segment \(idx) 416 but url missing", ranges: ranges) + return + } + let priorTotal = lock.withLockValue { state.totalSize } + let priorEtag = lock.withLockValue { state.etag } + let channelStr = state.channel.stringValue + let taskId = state.taskId + OneKeyLog.info("RangeDownloader", + "\(channelStr)/\(taskId): segment \(idx) 416 — re-evaluating size before retry") + Task { [weak self] in + guard let self = self else { return } + // Re-confirm the run is still live before acting on the re-probe. + let stillLive = self.run(forKey: Self.runKey(channel: state.channel, taskId: state.taskId)).map { $0 === state } ?? false + guard stillLive else { return } + let probe: ProbeResult + do { + probe = try await self.probe(url: url) + } catch { + // Re-probe itself failed → transient network condition. Retry the + // segment in place; the original range is unchanged and `.segN` is kept. + if self.retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: retryAfter) { return } + self.finalizeTransientFallback(state: state, idx: idx, + reason: "segment \(idx) 416; re-probe failed, retry budget exhausted", + ranges: ranges) + return + } + let totalChanged = !probe.supportsRange || probe.total != priorTotal + let etagChanged = (probe.etag?.isEmpty == false || priorEtag?.isEmpty == false) + && (probe.etag != priorEtag) + if totalChanged || etagChanged { + // §4: the object changed under us — the planned `.segN` ranges no longer + // describe this object. Object-change → wipe + restart (Permanent) so a + // fresh run re-plans; never stitch bytes from two different objects. + OneKeyLog.info("RangeDownloader", + "\(channelStr)/\(taskId): segment \(idx) 416 → object changed (total \(priorTotal)→\(probe.total), etag \(priorEtag ?? "nil")→\(probe.etag ?? "nil")); wipe + restart") + self.lock.lock() + state.fellBack = true + state.fellBackKind = .multipartOrBadTotal + self.lock.unlock() + self.finalizePermanentFallback(state: state, session: session, ranges: ranges) + return + } + // Total/validator unchanged → the 416 was a transient server hiccup; the + // range is still valid. Keep `.segN` and retry this segment normally. + OneKeyLog.info("RangeDownloader", + "\(channelStr)/\(taskId): segment \(idx) 416 → size unchanged (total \(priorTotal)); retrying range as-is") + if self.retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: retryAfter) { return } + self.finalizeTransientFallback(state: state, idx: idx, + reason: "segment \(idx) 416; size unchanged, retry budget exhausted", + ranges: ranges) + } + } + + // backoffDelay (§5.4) now lives on `RangeDownloadLogic`. + + // MARK: - Stall watchdog (§5.4) + + /// Periodically checks whether the run has received any bytes within the stall + /// window. A stalled segment task is cancelled so didCompleteWithError routes it + /// through the in-place retry path. The watchdog stops itself when the run is no + /// longer live (finalized / cancelled). It is intentionally lenient: it only + /// fires when wall time since the last byte exceeds the window AND there is an + /// in-flight task, so a suspended background transfer (which makes no JS/main + /// progress while suspended) is not false-cancelled because the watchdog timer + /// itself is also suspended with the app. + private func startStallWatchdog(key: String, stallSeconds: Double) { + let interval = max(5.0, stallSeconds / 2.0) + DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in + self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval) + } + } + + private func stallWatchdogTick(key: String, stallSeconds: Double, interval: Double) { + guard let state = run(forKey: key), + lock.withLockValue({ state.continuation != nil }) else { + return // run finalized / cancelled — stop. + } + let last = lock.withLockValue { state.lastProgressAt } + let stalled = Date().timeIntervalSince(last) >= stallSeconds + if stalled { + let channelStr = state.channel.stringValue + let taskId = state.taskId + let session = session(forChannel: state.channel, segmentCount: state.segmentCount) + session.getAllTasks { tasks in + for t in tasks { + guard let d = t.taskDescription, + let decoded = Self.decodeTaskDescription(d), + decoded.channel.stringValue == channelStr, + decoded.taskId == taskId, + t.state == .running else { continue } + OneKeyLog.info("RangeDownloader", + "\(channelStr)/\(taskId): stall watchdog cancelling segment \(decoded.segIndex) (no bytes for \(String(format: "%.0f", stallSeconds))s)") + // Mark this index as stall-cancelled so didCompleteWithError treats the + // resulting NSURLErrorCancelled as a transient stall, not a user cancel. + self.lock.lock(); state.stallCancelledIndexes.insert(decoded.segIndex); self.lock.unlock() + t.cancel() + } + } + // Re-stamp so we don't repeatedly cancel within the same window while the + // cancel + retry round-trips. + lock.lock(); state.lastProgressAt = Date(); lock.unlock() + } + // Reschedule. + DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in + self?.stallWatchdogTick(key: key, stallSeconds: stallSeconds, interval: interval) + } + } + private func allSegmentsPresent(state: RunState, ranges: [(start: Int64, end: Int64)]) -> Bool { for (idx, range) in ranges.enumerated() { @@ -589,12 +976,19 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { let (state, idx) = run(for: desc) else { return } lock.lock() if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten } + // §5.4: stamp last-progress for the stall watchdog. Any byte on any segment + // counts as the run making progress. + state.lastProgressAt = Date() let sum = state.segmentWritten.reduce(0, +) let total = state.totalSize var emit = false if total > 0 { let p = Int((sum * 100) / total) - if p != state.prevProgress { state.prevProgress = p; emit = true } + // §5.7 monotonic non-decreasing: only emit on a strict increase, so a + // transient re-enqueue (which resets segmentWritten[idx]=0 and dips the + // aggregate) never ticks the bar backward — prevProgress is the run's + // running max. A genuine restart resets prevProgress separately. + if p > state.prevProgress { state.prevProgress = p; emit = true } } let progressValue = total > 0 ? Int((sum * 100) / total) : 0 let channel = state.channel @@ -616,28 +1010,59 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { let expectedLen = range.end - range.start + 1 // We require a 206 Partial Content that matches the requested byte range. - // Anything else — 200 (Range ignored / ETag changed) or an out-of-range - // Content-Range — means we cannot safely assemble this segment, so flag - // fallback; finalize happens in didCompleteWithError. + // Anything else is classified per §4: a Permanent status (200/401/403/404/ + // 410/501/505/other-4xx) flags the whole run for discard+single-stream; a + // Transient status (408/416/429/5xx) marks just THIS segment for in-place + // retry (G2) and keeps the other segments / `.segN`. Finalize/retry happens + // in didCompleteWithError. guard let http = downloadTask.response as? HTTPURLResponse else { - lock.lock(); state.fellBack = true; lock.unlock() + // No HTTP response on a finished body is anomalous → treat as transient. + markSegmentTransient(state: state, idx: idx, retryAfter: nil) return } if http.statusCode != 206 { - lock.lock(); state.fellBack = true; lock.unlock() + let kind = RangeDownloadLogic.classifyStatus(http.statusCode) + if kind.failureClass == .fallbackTransient { + // §4 (416): a 416 is transient but the requested range may no longer be + // satisfiable (the object shrank/changed). Flag this segment for a size + // re-evaluation (re-probe) BEFORE the range is re-requested, instead of + // blindly re-asking for the same bytes. + if http.statusCode == 416 { + lock.lock(); state.sizeReevalIndexes.insert(idx); lock.unlock() + } + let retryAfter = RangeDownloadLogic.parseRetryAfterSeconds( + http.value(forHTTPHeaderField: "Retry-After")) + markSegmentTransient(state: state, idx: idx, retryAfter: retryAfter) + } else { + lock.lock(); state.fellBack = true; state.fellBackKind = kind; lock.unlock() + } + return + } + // §5.5: reject multipart/byteranges — we requested exactly one range and can + // only assemble a single contiguous body per segment. + if let ctype = http.value(forHTTPHeaderField: "Content-Type")?.lowercased(), + ctype.contains("multipart/byteranges") { + lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock() return } // Verify the server's Content-Range start/end matches what we asked for so a - // stashed `.segN` can't be a slice of a different object/range. + // stashed `.segN` can't be a slice of a different object/range; and (§5.5) + // verify the total matches the probe total (reject absent / `*` / mismatch). if let cr = http.value(forHTTPHeaderField: "Content-Range") { - guard let parsed = Self.parseContentRangeBounds(cr), + guard let parsed = RangeDownloadLogic.parseContentRangeBounds(cr), parsed.start == range.start, parsed.end == range.end else { - lock.lock(); state.fellBack = true; lock.unlock() + lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock() + return + } + let expectedTotal = lock.withLockValue { state.totalSize } + guard let total = RangeDownloadLogic.parseContentRangeTotal(cr), total == expectedTotal else { + // Absent / `*` / disagreeing total → the object changed or is non-conforming. + lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock() return } } else { // 206 without a Content-Range header is non-conforming — don't trust it. - lock.lock(); state.fellBack = true; lock.unlock() + lock.lock(); state.fellBack = true; state.fellBackKind = .multipartOrBadTotal; lock.unlock() return } @@ -676,53 +1101,82 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let desc = task.taskDescription, - let (state, _) = run(for: desc) else { return } + let (state, idx) = run(for: desc) else { return } let ranges = lock.withLockValue { state.ranges } + + // A Permanent classification anywhere in the run (didFinishDownloadingTo set + // `fellBack`) wins over per-segment retries: discard and single-stream. + if lock.withLockValue({ state.fellBack }) { + finalizePermanentFallback(state: state, session: session, ranges: ranges) + return + } + if let error = error { - // Background ignores user-cancels (e.g. our own fallback cancel below) — - // surface only genuine give-ups. let nsErr = error as NSError if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorCancelled { + // Distinguish a stall-watchdog cancel (transient → retry this segment) + // from an external/user cancel or our own permanent-fallback sibling + // cancel (swallow). A genuine cancel never set stallCancelledIndexes. + let wasStall = lock.withLockValue { state.stallCancelledIndexes.remove(idx) != nil } + if wasStall { + if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return } + finalizeTransientFallback(state: state, idx: idx, reason: "segment \(idx) stalled; retry budget exhausted", ranges: ranges) + } return } - finishContinuation(state: state, with: error, ranges: ranges) + // §4: connection lost / timeout / DNS / TLS → Transient. Retry THIS segment + // in place under its budget; keep the others and their `.segN`. + if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return } + finalizeTransientFallback(state: state, idx: idx, + reason: error.localizedDescription, ranges: ranges) return } - if lock.withLockValue({ state.fellBack }) { - // Abandon the other in-flight segment tasks for THIS run so they don't - // keep downloading after we've decided to fall back to single-stream. - let channel = state.channel - let taskId = state.taskId - session.getAllTasks { tasks in - for t in tasks { - if let d = t.taskDescription, - let decoded = Self.decodeTaskDescription(d), - decoded.channel.stringValue == channel.stringValue, - decoded.taskId == taskId { - t.cancel() - } - } + + // §4: this segment's finished body was a Transient HTTP status (429/5xx/408/ + // 416). Discard the body and re-enqueue just this segment under its budget. + let transientRetryAfter = lock.withLockValue { () -> (present: Bool, retryAfter: Double?) in + if let ra = state.transientSegmentRetryAfter[idx] { + state.transientSegmentRetryAfter.removeValue(forKey: idx) + return (true, ra) } - cleanupSegments(state: state, ranges: ranges) - finishContinuation(state: state, - with: FallbackError(reason: "server returned 200 to a Range request"), - ranges: ranges) + return (false, nil) + } + if transientRetryAfter.present { + // §4 (416): a 416 segment must re-evaluate the total/validator (re-probe) + // BEFORE re-requesting the same range. If the object changed → object-change + // (wipe + restart, Permanent); otherwise keep `.segN` and retry normally. + let needsSizeReeval = lock.withLockValue { state.sizeReevalIndexes.remove(idx) != nil } + if needsSizeReeval { + reevaluateSizeThenRetry(state: state, session: session, idx: idx, + retryAfter: transientRetryAfter.retryAfter, ranges: ranges) + return + } + if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: transientRetryAfter.retryAfter) { return } + finalizeTransientFallback(state: state, idx: idx, + reason: "segment \(idx) throttled/5xx; retry budget exhausted", ranges: ranges) return } + // A segment failed to stash (move/size-check failure in didFinishDownloadingTo). - // That segment will never appear, so finalize terminally instead of waiting. - if let stashErr = lock.withLockValue({ state.stashError }) { - finishContinuation(state: state, with: stashErr, ranges: ranges) + // A short/truncated body is transient (the bytes will be re-fetched), so retry + // this segment under budget rather than failing the whole run. + if lock.withLockValue({ state.stashError }) != nil { + lock.lock(); state.stashError = nil; lock.unlock() + if retrySegmentIfUnderBudget(state: state, idx: idx, retryAfter: nil) { return } + finalizeTransientFallback(state: state, idx: idx, + reason: "segment \(idx) could not be stashed; retry budget exhausted", ranges: ranges) return } + if allSegmentsPresent(state: state, ranges: ranges) { finishContinuation(state: state, with: nil, ranges: ranges) return } // This task finished error==nil but not all segments are present. If any - // tasks for THIS run are still in flight, wait for their completions. - // Otherwise nothing will ever resume the continuation — finalize with a - // descriptive terminal error so the JS promise resolves (as fallback). + // tasks for THIS run are still in flight (or a backoff retry is pending), + // wait. Otherwise re-enqueue the missing segment(s) under budget; only when a + // segment's budget/deadline is exhausted do we finalize as a resumable + // transient fallback (keeping `.segN`). let channel = state.channel let taskId = state.taskId session.getAllTasks { [weak self] tasks in @@ -735,18 +1189,100 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { return t.state == .running || t.state == .suspended } if stillInFlight { return } + // §5.4 (G2): a backoff retry pending for ANY missing segment has no live + // task (it's a delayed asyncAfter), so it's invisible to `getAllTasks` + // above. If one is pending, that scheduled retry will re-drive completion; + // bail now rather than double-incrementing its attempt counter and arming + // a duplicate retry. + let pendingRetry = self.lock.withLockValue { !state.pendingRetryIndexes.isEmpty } + if pendingRetry { return } // Re-check under no-in-flight: a just-finished stash may have completed. if self.allSegmentsPresent(state: state, ranges: ranges) { self.finishContinuation(state: state, with: nil, ranges: ranges) return } - let missing = ranges.indices.first { - !FileManager.default.fileExists(atPath: state.segPath($0)) + // Re-enqueue every missing segment that still has budget. `retrySegmentIf + // UnderBudget` itself gates on `pendingRetryIndexes`, so a segment already + // mid-backoff is reported as "in flight" (true) and never re-armed (G2). + var anyRetryScheduled = false + var exhaustedIdx: Int? + for mIdx in ranges.indices + where !FileManager.default.fileExists(atPath: state.segPath(mIdx)) { + if self.retrySegmentIfUnderBudget(state: state, idx: mIdx, retryAfter: nil) { + anyRetryScheduled = true + } else if exhaustedIdx == nil { + exhaustedIdx = mIdx + } } - let reason = "segment \(missing.map(String.init) ?? "?") missing/truncated after completion" - self.finishContinuation(state: state, - with: FallbackError(reason: reason), - ranges: ranges) + if anyRetryScheduled { return } + let missing = exhaustedIdx + ?? ranges.indices.first { !FileManager.default.fileExists(atPath: state.segPath($0)) } + self.finalizeTransientFallback( + state: state, idx: missing ?? 0, + reason: "segment \(missing.map(String.init) ?? "?") missing/truncated; retry budget exhausted", + ranges: ranges) + } + } + + /// §4 Permanent: abandon sibling tasks, discard `.segN`, finalize the run with a + /// Permanent fallback carrying the run's classified kind. + private func finalizePermanentFallback(state: RunState, session: URLSession, + ranges: [(start: Int64, end: Int64)]) { + let channel = state.channel + let taskId = state.taskId + let kind = lock.withLockValue { state.fellBackKind } + session.getAllTasks { tasks in + for t in tasks { + if let d = t.taskDescription, + let decoded = Self.decodeTaskDescription(d), + decoded.channel.stringValue == channel.stringValue, + decoded.taskId == taskId { + t.cancel() + } + } + } + cleanupSegments(state: state, ranges: ranges) + let reason: String + switch kind { + case .serverIgnoredRange: reason = "server returned 200 to a Range request" + case .multipartOrBadTotal: reason = "non-conforming 206 (multipart / range / total mismatch)" + case .authExpired: reason = "auth expired (401/403); fetch a fresh signed URL" + case .notFound: reason = "object not found (404/410)" + case .redirectRejected: reason = "rejected non-HTTPS redirect" + case .rangeUnsupported: reason = "range unsupported / non-retryable status" + case .checksumMismatch: reason = "whole-file checksum mismatch" + case .transientNetwork, .throttled, .budgetExhausted: reason = "permanent fallback" + } + finishContinuation(state: state, with: FallbackError(reason: reason, kind: kind), ranges: ranges) + } + + /// §4 Transient: finalize the run as a RESUMABLE fallback. `.segN` files are + /// intentionally KEPT so the next concurrent attempt resumes only the missing + /// segment(s); never restart from byte 0 while resumable bytes exist. + private func finalizeTransientFallback(state: RunState, idx: Int, reason: String, + ranges: [(start: Int64, end: Int64)]) { + finishContinuation(state: state, + with: FallbackError(reason: reason, kind: .budgetExhausted), + ranges: ranges) + } + + /// §5.9: HTTPS-only on every redirect hop. A redirect to a non-HTTPS URL is + /// rejected (Permanent, `redirectRejected`). Passing `nil` to the completion + /// handler cancels the redirect; the task then completes with an error which + /// our run classification turns into a Permanent fallback (we pre-mark the run + /// so didCompleteWithError doesn't misread the cancel as transient). + public func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + if request.url?.scheme?.lowercased() != "https" { + OneKeyLog.error("RangeDownloader", "blocked redirect to non-HTTPS URL") + if let desc = task.taskDescription, let (state, _) = run(for: desc) { + lock.lock(); state.fellBack = true; state.fellBackKind = .redirectRejected; lock.unlock() + } + completionHandler(nil) + } else { + completionHandler(request) } } @@ -856,6 +1392,22 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate { discardArtifacts(filePath: filePath) } + /// True when at least one concurrent segment artifact survives for this file. + /// + /// The downloader cleans its `.segN` files itself on every *permanent* + /// fallback (server returned 200 → `cleanupSegments` in didCompleteWithError; + /// SHA mismatch → cleanup in concatenateAndFinish; Range unsupported → nothing + /// was ever stashed) and deliberately RETAINS them on a *transient* one (a + /// suspend/network drop that left "segment N missing/truncated"). So a + /// surviving `.segN` is a reliable signal that the fallback is resumable — + /// the caller uses it to avoid deleting bytes a later attempt can resume. + public func hasArtifacts(filePath: String) -> Bool { + for idx in 0.. String? { - let fm = FileManager.default - guard fm.fileExists(atPath: filePath), - let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil } - defer { try? fileHandle.close() } - var context = CC_SHA256_CTX() - CC_SHA256_Init(&context) - while autoreleasepool(invoking: { () -> Bool in - let data = fileHandle.readData(ofLength: 8192) - if data.isEmpty { return false } - data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) } - return true - }) {} - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - CC_SHA256_Final(&hash, &context) - return hash.map { String(format: "%02x", $0) }.joined() - } + // calculateSHA256 (§5.5) now lives on `RangeDownloadLogic`. } private extension NSLock { diff --git a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts index 5e099a99..ddc96d68 100644 --- a/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts +++ b/native-modules/react-native-range-downloader/src/ReactNativeRangeDownloader.nitro.ts @@ -14,16 +14,50 @@ export interface RangeDownloadParams { expectedSha256?: string; // optional: in-module post-download self-check; else caller verifies later segmentCount?: number; // default 8 (mirrors current behavior) minConcurrentBytes?: number; // default 2MB (below this falls straight back to single stream) + // OCDS §5.4: caller-tunable retry/backoff/deadline knobs. Defaults are platform + // configuration (filled in by the native module when omitted), not part of the + // standard. These let the OTA / APK / asset callers tune behavior per channel. + maxSegmentAttempts?: number; // per-segment transient retry budget within a single run (default 4) + requestTimeoutSeconds?: number; // connection/request timeout per segment task (default 60) + stallTimeoutSeconds?: number; // bytes-stalled (no-progress) watchdog window (default 30) + overallDeadlineSeconds?: number; // single-run wall-clock bound before giving up (default 0 = none) } +// OCDS §4: the failure class is an EXPLICIT, typed outcome of the operation, never +// inferred from incidental on-disk side effects (e.g. whether `.segN` survives). +// - `completed` — destFilePath fully on disk (and expectedSha256, if passed, has passed) +// - `fallbackTransient` — concurrent path interrupted but RESUMABLE; segments kept; caller +// should retry the concurrent path / keep its progress floor +// - `fallbackPermanent` — concurrency fundamentally unusable for this object; segments are +// unsalvageable and have been discarded; caller restarts single-stream +// - `fallback` — DEPRECATED legacy alias kept for wire/back-compat during the +// transition; new callers consume the two typed classes above export type RangeDownloadOutcome = - | 'completed' // destFilePath fully on disk (and expectedSha256, if passed, has passed) - | 'fallback'; // concurrent unavailable, caller should run its own single-stream path + | 'completed' + | 'fallbackTransient' + | 'fallbackPermanent' + | 'fallback'; + +// OCDS §4: optional sub-classification of a fallback, so the caller (and analytics) +// can distinguish WHY the concurrent path was abandoned without parsing the reason +// string. Present only when outcome is a fallback class. +export type RangeFallbackKind = + | 'serverIgnoredRange' // server answered 200 to a Range request (Permanent) + | 'rangeUnsupported' // probe inconclusive / range not supported (Permanent) + | 'authExpired' // 401/403: signed URL dead; caller must fetch a fresh URL (Permanent) + | 'notFound' // 404/410: object gone (Permanent) + | 'redirectRejected' // non-HTTPS redirect rejected per §5.9 (Permanent) + | 'checksumMismatch' // whole-file checksum mismatch after assembly (Permanent) + | 'multipartOrBadTotal' // multipart/byteranges or Content-Range total disagrees (Permanent) + | 'transientNetwork' // connection lost / timeout / DNS / TLS / stall (Transient) + | 'throttled' // 429 / 5xx (except 501/505) (Transient) + | 'budgetExhausted'; // per-segment attempts / deadline exhausted while still resumable (Transient) export interface RangeDownloadResult { outcome: RangeDownloadOutcome; filePath: string; fallbackReason?: string; // filled when outcome=fallback (range unsupported / 200 / too small ...) + fallbackKind?: RangeFallbackKind; // typed sub-class of the fallback (see §4); omitted on completed } export interface RangeDownloadEvent { diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/.gitignore b/native-modules/react-native-range-downloader/tests/swiftpm/.gitignore new file mode 100644 index 00000000..0e072e33 --- /dev/null +++ b/native-modules/react-native-range-downloader/tests/swiftpm/.gitignore @@ -0,0 +1,2 @@ +.build/ +*.xcuserstate diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Package.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Package.swift new file mode 100644 index 00000000..27274f40 --- /dev/null +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Self-contained SwiftPM harness for the DETERMINISTIC range-downloader logic. +// +// The single source file is a SYMLINK to the production +// `ios/RangeDownloadLogic.swift`, so `swift test` compiles and exercises the REAL +// shipping code — no copy to drift. The file is dependency-free (Foundation + +// CommonCrypto only), which is exactly why it could be extracted out of +// `ReactNativeRangeDownloader.swift` (NitroModules / ReactNativeNativeLogger / +// background URLSession) and tested in a plain unit-test process. +// +// Run: swift test --package-path tests/swiftpm +let package = Package( + name: "RangeDownloadLogic", + platforms: [.macOS(.v12)], + targets: [ + .target(name: "RangeDownloadLogic"), + .testTarget( + name: "RangeDownloadLogicTests", + dependencies: ["RangeDownloadLogic"] + ), + ] +) diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Sources/RangeDownloadLogic/RangeDownloadLogic.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Sources/RangeDownloadLogic/RangeDownloadLogic.swift new file mode 120000 index 00000000..7e366add --- /dev/null +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Sources/RangeDownloadLogic/RangeDownloadLogic.swift @@ -0,0 +1 @@ +../../../../ios/RangeDownloadLogic.swift \ No newline at end of file diff --git a/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift new file mode 100644 index 00000000..f101c318 --- /dev/null +++ b/native-modules/react-native-range-downloader/tests/swiftpm/Tests/RangeDownloadLogicTests/RangeDownloadLogicTests.swift @@ -0,0 +1,329 @@ +import XCTest +import Foundation +@testable import RangeDownloadLogic + +// MARK: - RangeDownloader deterministic-logic unit tests +// +// These tests cover the DEPENDENCY-FREE logic extracted into +// `ios/RangeDownloadLogic.swift` (a verbatim move from +// `ReactNativeRangeDownloader.swift`). Each test maps to an OCDS section; see the +// `// OCDS:` tag on each method. +// +// ─────────────────────────────────────────────────────────────────────────── +// NOT COVERED HERE — DEVICE-ONLY (do NOT attempt to fake in a unit-test process): +// +// The actual concurrent BACKGROUND `URLSession` download is device territory. A +// background session (URLSession(configuration: .background, delegate:)) does NOT +// honor custom URLProtocol and only behaves correctly inside a real app lifecycle +// (nsurlsessiond, app suspend/relaunch, handleEventsForBackgroundURLSession). +// Therefore the following RUNTIME behaviors require a real device + the +// NativeLogger app-latest.log checklist, NOT this harness: +// - OCDS-T1/T2 : concurrent multi-range segment download + segment stash/concat +// - OCDS-T8 : resume across app suspend/kill (which `.segN` already exist) +// - OCDS-T10 : handleEventsForBackgroundURLSession completion delivery +// The PROBE uses an ephemeral FOREGROUND session; even that is an integration +// concern (real network) and is intentionally not exercised here. +// ─────────────────────────────────────────────────────────────────────────── +final class RangeDownloadLogicTests: XCTestCase { + + // MARK: §4 — HTTP status classification + // + // OCDS §4. Mirrors Android's IsPermanentHttpStatusTest matrix. Asserts BOTH the + // RangeFallbackClass kind AND the resolved transient/permanent failureClass. + + func testClassifyStatus_200_serverIgnoredRange_permanent() { + let k = RangeDownloadLogic.classifyStatus(200) + XCTAssertEqual(k, .serverIgnoredRange) + XCTAssertEqual(k.failureClass, .fallbackPermanent) + } + + func testClassifyStatus_416_transient_regression() { + // §4 regression guard: 416 to a resume request must be TRANSIENT + // (re-evaluate size, keep segments) — never permanent. + let k = RangeDownloadLogic.classifyStatus(416) + XCTAssertEqual(k, .transientNetwork) + XCTAssertEqual(k.failureClass, .fallbackTransient) + } + + func testClassifyStatus_401_403_authExpired_permanent() { + for status in [401, 403] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .authExpired, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackPermanent, "status \(status)") + } + } + + func testClassifyStatus_404_410_notFound_permanent() { + for status in [404, 410] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .notFound, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackPermanent, "status \(status)") + } + } + + func testClassifyStatus_408_429_throttled_transient() { + for status in [408, 429] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .throttled, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackTransient, "status \(status)") + } + } + + func testClassifyStatus_501_505_rangeUnsupported_permanent() { + // Explicit Permanent carve-outs from the 5xx → Transient default. + for status in [501, 505] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .rangeUnsupported, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackPermanent, "status \(status)") + } + } + + func testClassifyStatus_other5xx_transient() { + // 5xx (except 501/505) → throttled → Transient. + for status in [500, 502, 503, 504, 599] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .throttled, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackTransient, "status \(status)") + } + } + + func testClassifyStatus_other4xx_permanent() { + // Default 4xx (other than 401/403/404/408/410/429) → rangeUnsupported → Permanent. + for status in [400, 402, 405, 418, 451, 499] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .rangeUnsupported, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackPermanent, "status \(status)") + } + } + + func testClassifyStatus_unknown_permanent() { + // §4 catch-all: anything outside the known ranges → Permanent. + for status in [0, 100, 204, 301, 302, 600, 999, -1] { + let k = RangeDownloadLogic.classifyStatus(status) + XCTAssertEqual(k, .rangeUnsupported, "status \(status)") + XCTAssertEqual(k.failureClass, .fallbackPermanent, "status \(status)") + } + } + + // MARK: §4 — failureClass partition sanity (full enum coverage) + + func testFailureClass_fullPartition() { + let transient: [RangeFallbackClass] = [.transientNetwork, .throttled, .budgetExhausted] + let permanent: [RangeFallbackClass] = [ + .serverIgnoredRange, .rangeUnsupported, .authExpired, .notFound, + .redirectRejected, .checksumMismatch, .multipartOrBadTotal, + ] + for k in transient { + XCTAssertEqual(k.failureClass, .fallbackTransient, "\(k)") + } + for k in permanent { + XCTAssertEqual(k.failureClass, .fallbackPermanent, "\(k)") + } + } + + func testDownloadClass_isFallback() { + XCTAssertFalse(RangeDownloadClass.completed.isFallback) + XCTAssertTrue(RangeDownloadClass.fallbackTransient.isFallback) + XCTAssertTrue(RangeDownloadClass.fallbackPermanent.isFallback) + } + + // MARK: §5.5 — Content-Range parsing + // + // OCDS §5.5 integrity parsing. Valid / misaligned / '*'-total / malformed. + + func testParseContentRangeTotal_valid() { + XCTAssertEqual(RangeDownloadLogic.parseContentRangeTotal("bytes 0-0/65226095"), 65226095) + XCTAssertEqual(RangeDownloadLogic.parseContentRangeTotal("bytes 100-199/200"), 200) + } + + func testParseContentRangeTotal_starTotal_nil() { + // "bytes 0-0/*" → unknown total → nil. + XCTAssertNil(RangeDownloadLogic.parseContentRangeTotal("bytes 0-0/*")) + } + + func testParseContentRangeTotal_garbage_nil() { + XCTAssertNil(RangeDownloadLogic.parseContentRangeTotal("bytes 0-0/abc")) + XCTAssertNil(RangeDownloadLogic.parseContentRangeTotal("no-slash-here")) + XCTAssertNil(RangeDownloadLogic.parseContentRangeTotal("")) + } + + func testParseContentRangeBounds_valid() { + let b = RangeDownloadLogic.parseContentRangeBounds("bytes 0-0/65226095") + XCTAssertEqual(b?.start, 0) + XCTAssertEqual(b?.end, 0) + let b2 = RangeDownloadLogic.parseContentRangeBounds("bytes 1024-2047/4096") + XCTAssertEqual(b2?.start, 1024) + XCTAssertEqual(b2?.end, 2047) + } + + func testParseContentRangeBounds_starTotalIgnored() { + // The '*' total is dropped (we only need the bounds); bounds still parse. + let b = RangeDownloadLogic.parseContentRangeBounds("bytes 5-9/*") + XCTAssertEqual(b?.start, 5) + XCTAssertEqual(b?.end, 9) + } + + func testParseContentRangeBounds_malformed_nil() { + XCTAssertNil(RangeDownloadLogic.parseContentRangeBounds("bytes */1234")) // no a-b + XCTAssertNil(RangeDownloadLogic.parseContentRangeBounds("bytes 0/1234")) // no dash + XCTAssertNil(RangeDownloadLogic.parseContentRangeBounds("0-9/1234")) // no leading token/space + XCTAssertNil(RangeDownloadLogic.parseContentRangeBounds("bytes a-b/1234")) // non-numeric bounds + XCTAssertNil(RangeDownloadLogic.parseContentRangeBounds("")) + } + + // MARK: §5.2 / §5.3 — range planning + // + // OCDS §5.2/§5.3. ceil-chunk; last segment absorbs remainder; past-EOF dropped. + + func testPlanRanges_normal_8segments() { + // 8000 bytes / 8 → chunk 1000, contiguous, fully covering [0, 7999]. + let r = RangeDownloadLogic.planRanges(total: 8000, segments: 8) + XCTAssertEqual(r.count, 8) + XCTAssertEqual(r.first?.start, 0) + XCTAssertEqual(r.last?.end, 7999) + // contiguous, no gaps/overlaps + for i in 1.. String { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) } + return hash.map { String(format: "%02x", $0) }.joined() +}