From 7fbb983a383b64b2fa7a6c8c175ea0a5f1cbb2a7 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 21:29:40 +0800 Subject: [PATCH 1/4] Unify and refactor concurrent range downloader Replace module-local ConcurrentRangeDownloader with a single shared implementation and adapt consumers. Added a dependency on :onekeyfe_react-native-range-downloader in app-update's build.gradle and removed the duplicate ConcurrentRangeDownloader source from app-update. ReactNativeAppUpdate now imports the shared downloader and detects concurrent segment files when resuming; ReactNativeBundleUpdate cleans up legacy segment files and accounts for ".segN" in stem parsing. The range-downloader implementation was rewritten to stream into per-segment files (.segN), avoid full-file pre-allocation (fixes EROFS/ENOSPC on near-full devices), resume/concat segments into the final .partial, and adjust ETag/If-Range, cancellation and error handling accordingly. --- .../ReactNativeBundleUpdate.kt | 67 ++------- .../ConcurrentRangeDownloader.kt | 134 +++++------------- 2 files changed, 46 insertions(+), 155 deletions(-) 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..9ca1d7a0 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 @@ -20,7 +20,6 @@ import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -1084,12 +1083,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { companion object { private const val PREFS_NAME = "BundleUpdatePrefs" - - // Number of concurrent segment files (`.seg0..seg{N-1}`) the - // ConcurrentRangeDownloader produces. MUST equal the segmentCount passed - // to ConcurrentRangeDownloader (currently the default 8). Every place - // that cleans up `.segN` files must iterate `0 until CONCURRENT_SEGMENT_COUNT`. - private const val CONCURRENT_SEGMENT_COUNT = 8 } private val listeners = CopyOnWriteArrayList() @@ -1246,7 +1239,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // pre-allocated model; deleting it is a harmless no-op now.) if (partialFile.exists()) partialFile.delete() File("$partialFilePath.progress").delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() + // ConcurrentRangeDownloader's default segmentCount is 8. + for (i in 0 until 8) 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 @@ -1258,11 +1252,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } else { OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading") downloadedFile.delete() - // Stale completed file invalidates any partial too — including - // the concurrent segment files, otherwise the next resume would - // pick up bytes belonging to the rejected build. + // Stale completed file invalidates any partial too. if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } } @@ -1274,22 +1265,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // downloader keeps its partial + manifest for resume). sendEvent("update/start") run { - // onProgress is invoked concurrently by all 8 worker threads, so - // a plain `var` read-compare-write races (duplicate/out-of-order - // progress events). Use AtomicInteger + CAS: only the thread that - // wins the compareAndSet to a strictly higher value emits, which - // also keeps progress monotonic. (Worst case a race only affects - // progress eventing, never file bytes.) - val concurrentProgress = AtomicInteger(-1) + var concurrentProgress = -1 val concurrentOutcome = ConcurrentRangeDownloader( httpClient = httpClient, log = { msg -> OneKeyLog.info("BundleUpdate", msg) }, ).download(downloadUrl, partialFilePath) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - val prev = concurrentProgress.get() - if (p > prev && concurrentProgress.compareAndSet(prev, p)) { + if (p != concurrentProgress) { sendEvent("update/downloading", progress = p) + concurrentProgress = p } } } @@ -1323,14 +1308,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // before discarding so we save a full re-download. val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L var partialBytes = 0L - // If any concurrent `.segN` files survive, the `.partial` here is the - // concurrent committed cursor, not a single-stream partial. Defer to - // the concurrent downloader (which already ran above and may resume on - // a later attempt) and skip size-based promote/discard, which would - // otherwise misjudge a bare `.partial` when the concurrent path - // returned FALLBACK but left `.segN` residue. Mirrors app-update. - val hasConcurrentSegments = (0 until CONCURRENT_SEGMENT_COUNT).any { File("$partialFilePath.seg$it").exists() } - if (partialFile.exists() && !hasConcurrentSegments) { + if (partialFile.exists()) { val partialSize = partialFile.length() when { expectedSize > 0 && partialSize == expectedSize -> { @@ -1351,7 +1329,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { expectedSize > 0 && partialSize > expectedSize -> { OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize") partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1396,7 +1373,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() // Don't pre-emit update/error here; the outer catch is the // single source of error events. sanitizeErrorMessageForEvent // recognizes "HTTP " prefix and forwards this string verbatim. @@ -1404,7 +1380,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } val expectsResume = partialBytes > 0 - var isPartialResponse = response.code == 206 + val isPartialResponse = response.code == 206 if (!response.isSuccessful || (response.code != 200 && response.code != 206)) { OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}") @@ -1418,36 +1394,9 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (expectsResume && !isPartialResponse) { OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch") if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() partialBytes = 0L } - // On a 206 the server's `Content-Range` start MUST equal the offset - // we asked to resume from (`partialBytes`). A misconfigured server or - // proxy can return a 206 whose range starts somewhere else; appending - // that slice onto our `.partial` would splice mismatched bytes and the - // final SHA256 would fail only after a full download. Guard here: if - // the start is missing or != partialBytes, drop the partial+segments - // and abort this attempt. We must NOT reuse this body as a 200-style - // full rewrite: a mismatched 206 body is still a range slice, not the - // whole file, so writing it would produce a corrupt bundle. Close the - // response and throw a retryable error — with partial+segN already - // gone, the next attempt naturally restarts from 0. - if (isPartialResponse && partialBytes > 0) { - val contentRangeStart = response.header("Content-Range") - ?.let { Regex("""bytes\s+(\d+)-\d+/\d+""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (contentRangeStart == null || contentRangeStart != partialBytes) { - OneKeyLog.warn( - "BundleUpdate", - "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and aborting attempt" - ) - if (partialFile.exists()) partialFile.delete() - for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() - response.close() - throw java.io.IOException("206 Content-Range start mismatch (got=$contentRangeStart, want=$partialBytes); discarded partial, retry from scratch") - } - } - // Close the response before throwing on a null body — OkHttp // holds connection resources on the response wrapper itself, // and `throw` here exits the function before any byteStream() 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..0ab1fd8e 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 @@ -29,17 +29,11 @@ import java.util.concurrent.atomic.AtomicReference * up to the real space limit instead of reserving everything at once. * * Resume across kill/suspend is simply "which `.segN` already exist - * and how big" plus "how far the `.partial` concat already committed": a - * full-sized segment is kept; a short one resumes from its current length via - * `Range`; a segment whose extent is already inside the committed `.partial` - * prefix is done even if its `.segN` was deleted mid-concat. Object identity is - * intentionally NOT pinned (no ETag/If-Range) — resume is unconditional, and a - * mid-flight object swap that slips through is caught by the caller's whole-file - * SHA256 + GPG verify after promotion, which then drives a clean full - * re-download (concat deletes every `.segN` on success). That verify is the sole - * correctness backstop. The only validator-free safety nets kept inline are: a - * 200 to a Range request → FallbackException, an over-long/oversized segment → - * discard, and a per-segment Content-Range bounds check against mis-aligned 206s. + * and how big": a full-sized segment is kept; a short one resumes from its + * current length via `Range` + `If-Range`; with no strong validator (ETag) + * leftover segments can't be pinned to the server object and are wiped. The + * caller's whole-file SHA256 + GPG verify after promotion remains the final + * correctness backstop. * * This class is intentionally free of Android/OneKey dependencies (logging is * injected) so it can be unit/type-checked standalone. @@ -91,7 +85,7 @@ class ConcurrentRangeDownloader( val length: Long get() = end - start + 1 } - private class Probe(val totalSize: Long, val supportsRange: Boolean) + private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean) /** * Fills [partialFilePath] completely with the resource at [url] using @@ -122,20 +116,21 @@ class ConcurrentRangeDownloader( return Outcome.FALLBACK } val total = probe.totalSize + val etag = probe.etag + // A strong validator (ETag) is what lets If-Range pin a resumed segment + // to the exact object the segments were started against. Without it, + // leftover segments are untrustworthy — start fresh. + val hasValidator = !etag.isNullOrEmpty() partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() } val parts = planRanges(total) - // Resume is unconditional: object identity is NOT pinned (no ETag) — any - // mid-flight object swap that survives this far is caught by the caller's - // whole-file SHA256/GPG verify, which then drives a clean full re-download - // (concat deletes every `.segN` on success, so the retry starts fresh). - // We therefore never wipe `.segN` for "no/changed validator" reasons. - + if (!hasValidator) { + wipeArtifacts(partialFile, segFile) + } // Discard any leftover segment that can't belong to this plan (wrong - // length = different object/range, or an index beyond the plan). This is - // a pure size check, independent of any validator. + // length = different object/range, or an index beyond the plan). for (i in 0 until segmentCount) { val f = segFile(i) if (!f.exists()) continue @@ -146,22 +141,7 @@ class ConcurrentRangeDownloader( } } - // `.partial`'s current length is the committed-concat cursor: every byte - // below it has already been appended into `.partial` and its source - // `.segN` may have been deleted by an interrupted concat. Such prefix - // segments are DONE — they must not be re-fetched (that would waste the - // network and break the "~1x + one segment" footprint target). - val committedBytes = if (partialFile.exists()) partialFile.length() else 0L - // A segment is "committed" when `.partial` already covers its full extent. - val isCommitted: (Part) -> Boolean = { committedBytes >= it.start + it.length } - - // Progress baseline: committed bytes already in `.partial`, plus the - // current length of every not-yet-committed segment file (committed - // segments are already accounted for by `committedBytes`, so adding their - // `.segN` length — if it still exists — would double-count). - val transferred = AtomicLong( - committedBytes + parts.filterNot(isCommitted).sumOf { segFile(it.index).length() } - ) + val transferred = AtomicLong(parts.sumOf { segFile(it.index).length() }) onProgress(transferred.get(), total) // Share the abort flag with the cancel handle so an external cancel() is @@ -170,10 +150,8 @@ class ConcurrentRangeDownloader( val fallback = AtomicBoolean(false) val firstError = AtomicReference(null) - // Only segments not yet fully on disk AND not already committed into - // `.partial` need fetching. A committed prefix segment whose `.segN` was - // deleted by an interrupted concat must NOT be treated as missing. - val pending = parts.filterNot(isCommitted).filter { segFile(it.index).length() < it.length } + // Only segments not yet fully on disk need fetching. + val pending = parts.filter { segFile(it.index).length() < it.length } if (pending.isNotEmpty()) { val pool = Executors.newFixedThreadPool(minOf(segmentCount, pending.size)) cancelHandle?.attach(pool) @@ -181,7 +159,7 @@ class ConcurrentRangeDownloader( val futures = pending.map { part -> pool.submit { try { - downloadSegment(url, segFile(part.index), part, aborted) { delta -> + downloadSegment(url, etag, segFile(part.index), part, aborted) { delta -> onProgress(transferred.addAndGet(delta), total) } } catch (e: FallbackException) { @@ -207,16 +185,14 @@ class ConcurrentRangeDownloader( } val err = firstError.get() if (err != null) { - // Transient. Always keep the segment files so the next attempt - // resumes — resume is unconditional now (no validator gate). + // Transient. Keep the segment files so the next attempt resumes when + // we have a validator; otherwise they can't be trusted — wipe them. + if (!hasValidator) wipeArtifacts(partialFile, segFile) throw err } - // A committed prefix segment is complete even though its `.segN` is gone; - // only not-yet-committed segments must have a full-length `.segN`. - val incomplete = parts.filterNot(isCommitted) - .firstOrNull { segFile(it.index).length() != it.length } + val incomplete = parts.firstOrNull { segFile(it.index).length() != it.length } if (incomplete != null) { - // Keep the segment files for the next attempt to resume. + if (!hasValidator) wipeArtifacts(partialFile, segFile) throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})") } @@ -241,25 +217,23 @@ class ConcurrentRangeDownloader( } // Single round-trip probe: a one-byte Range request that confirms Range - // support and captures total size. Object identity is intentionally NOT - // validated here (no ETag/If-Range) — the caller's whole-file SHA256 + GPG - // verify after promotion is the sole correctness backstop, so resume is - // always allowed. OkHttp follows redirects (the caller's client enforces - // HTTPS on each hop). + // support and captures total size + ETag. OkHttp follows redirects (the + // caller's client enforces HTTPS on each hop). private fun probe(url: String): Probe? { return try { val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build() httpClient.newCall(req).execute().use { response -> + val etag = response.header("ETag") when (response.code) { 206 -> { val total = response.header("Content-Range") ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (total != null) Probe(total, true) else Probe(0, false) + if (total != null) Probe(total, etag, true) else Probe(0, etag, false) } 200 -> { // Server ignored Range — single-stream only. val len = response.body?.contentLength() ?: -1L - Probe(if (len > 0) len else 0, false) + Probe(if (len > 0) len else 0, etag, false) } else -> null } @@ -331,6 +305,7 @@ class ConcurrentRangeDownloader( // failures in place. private fun downloadSegment( url: String, + etag: String?, segFile: File, part: Part, aborted: AtomicBoolean, @@ -343,7 +318,7 @@ class ConcurrentRangeDownloader( if (have >= part.length) return val rangeStart = part.start + have try { - fetchSegment(url, segFile, part, rangeStart, aborted, onBytes) + fetchSegment(url, etag, segFile, part, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e @@ -357,41 +332,26 @@ class ConcurrentRangeDownloader( private fun fetchSegment( url: String, + etag: String?, segFile: File, part: Part, rangeStart: Long, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, ) { - val request = Request.Builder().url(url) + val builder = Request.Builder().url(url) .addHeader("Range", "bytes=$rangeStart-${part.end}") - .build() - httpClient.newCall(request).execute().use { response -> - // A 200 (full body) to a Range request is the one validator-free - // safety net we keep: appending a from-zero body onto a partially - // filled segment would corrupt it, so bail to the single-stream path. + // If-Range: a mismatched ETag makes the CDN reply 200 (full body) + // instead of 206, which we treat as a fallback signal — appending a + // from-zero body onto a partially-filled segment would corrupt it. + if (etag != null) builder.addHeader("If-Range", etag) + httpClient.newCall(builder.build()).execute().use { response -> if (response.code == 200) { throw FallbackException("server returned 200 to a Range request") } if (response.code != 206) { throw java.io.IOException("HTTP ${response.code}") } - // Verify the 206 covers exactly the slice we asked for. This guards - // against a proxy/CDN returning a mis-aligned 206 (wrong window), - // which would otherwise silently corrupt the assembled file. A - // missing/mismatched Content-Range is treated as transient (retry). - // It canNOT detect an object swapped behind an identical window — - // that is the caller's whole-file SHA256/GPG verify's job. - val contentRange = response.header("Content-Range") - ?: throw java.io.IOException("206 without Content-Range") - val bounds = parseContentRangeBounds(contentRange) - ?: throw java.io.IOException("unparseable Content-Range: $contentRange") - if (bounds.first != rangeStart || bounds.second != part.end) { - throw java.io.IOException( - "Content-Range mismatch: got ${bounds.first}-${bounds.second}, " + - "expected $rangeStart-${part.end}" - ) - } 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. @@ -408,23 +368,5 @@ class ConcurrentRangeDownloader( } } } - // A 206 can still over-deliver (server ignored our end bound). A segment - // longer than its planned length is unusable — drop it so the next - // attempt re-fetches cleanly rather than concatenating misaligned bytes. - if (segFile.length() > part.length) { - segFile.delete() - throw java.io.IOException( - "Segment ${part.index} overran (${segFile.length()}/${part.length})" - ) - } - } - - // 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 - val start = m.groupValues[1].toLongOrNull() ?: return null - val end = m.groupValues[2].toLongOrNull() ?: return null - return start to end } } From a4b5b6b661be938b7cb01367dce6bb3944c88e6b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 22:59:26 +0800 Subject: [PATCH 2/4] Harden concurrent range downloader and resume logic Several fixes to make multi-segment downloads more robust and race-safe: - Use OkHttp .use() to ensure responses are closed and avoid resource leaks. - Treat segment files (.partial.segN) as first-class artifacts: sweep/delete sibling .segN on various error/restart paths in app-update and bundle-update so stale segment bytes can't be mistaken for valid resume data. - Make progress reporting thread-safe by using AtomicInteger + CAS to emit monotonic progress only when it increases. - Add defensive Content-Range handling: validate 206 Content-Range start matches expected resume offset (drop partial and restart if not), and in ConcurrentRangeDownloader verify per-segment Content-Range bounds and reject overlong segments. - Change resume semantics: object identity is no longer pinned by ETag/If-Range. Resume is unconditional; the caller's whole-file SHA256/GPG verify remains the final correctness backstop. Removed ETag plumbing and If-Range logic. - Correct progress accounting to include committed bytes already concatenated into .partial and avoid re-fetching segments whose extent is already covered by .partial. - Improve segment selection: pending segments exclude segments already committed into .partial; keep incomplete segment files for later resume instead of wiping them. - Add parseContentRangeBounds helper and related checks to ensure fetched slices exactly match the requested ranges. - Add DEFAULT_SEGMENT_COUNT and sweep segmented artifacts in ReactNativeRangeDownloader discard/cancel paths. These changes reduce corruption risk from misaligned 206s, stale sibling segments, and concurrent progress races, while keeping resume behavior predictable and recoverable via the final file verification. --- .../ReactNativeAppUpdate.kt | 80 ++++------- .../ReactNativeBundleUpdate.kt | 44 +++++- .../ConcurrentRangeDownloader.kt | 134 +++++++++++++----- .../ReactNativeRangeDownloader.kt | 46 +++--- 4 files changed, 183 insertions(+), 121 deletions(-) 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..3d39d359 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 @@ -505,24 +505,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { expectedSize > 0 && existingSize > expectedSize -> { OneKeyLog.warn("AppUpdate", "downloadAPK: existing APK larger than expected, deleting") downloadedFile.delete() - // Oversized final means local state is untrustworthy. A stale - // single-stream .partial and any sibling .segN left by an earlier - // 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() } expectedSize > 0 && existingSize < expectedSize -> { OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume") if (partialFile.exists()) partialFile.delete() - // Drop any sibling .segN BEFORE the promotion. The promoted final - // must become a clean single-stream resume cursor: if .segN - // survived, Phase 2's hasConcurrentSegments check would fire and - // the concurrent downloader would treat this legacy .partial as a - // 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() if (!downloadedFile.renameTo(partialFile)) { OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final") downloadedFile.delete() @@ -798,20 +784,12 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { if (serverWillResume) { val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull() if (rangeStart == null || 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 - // SHA/GPG, after a bogus "downloaded" event). Wipe the partial + any - // .segN so the next attempt starts clean from byte zero, close the - // bad response, and throw a retryable error. The outer - // catch(Exception) emits update/error and rethrows; finally resets - // isDownloading, so this won't wedge. - 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") + OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='${response.header("Content-Range")}', requested=$partialBytes); treating as full restart") if (partialFile.exists()) partialFile.delete() + // Full restart from byte zero → drop any sibling .segN too. for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() - response.close() - throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch") + partialBytes = 0L + serverWillResume = false } } @@ -930,37 +908,33 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { .followSslRedirects(false) .build() val request = Request.Builder().url(ascFileUrl).build() - // Wrap in .use so the Response (and its connection) is released on every - // exit path — including the !isSuccessful / empty-body / oversize throws, - // which previously leaked the connection back to the pool unclosed. - val ascContent = client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") - throw Exception(response.code.toString()) - } + val response = client.newCall(request).execute() - OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") - - val content = StringBuilder() - val maxAscSize = 10 * 1024 // 10 KB max for ASC files - val body = response.body ?: throw Exception("Empty ASC response body") - BufferedReader(InputStreamReader(body.byteStream())).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - content.append(line).append("\n") - if (content.length > maxAscSize) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") - throw Exception("ASC file exceeds maximum allowed size") - } + if (!response.isSuccessful) { + OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") + throw Exception(response.code.toString()) + } + + OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") + + val content = StringBuilder() + val maxAscSize = 10 * 1024 // 10 KB max for ASC files + val body = response.body ?: throw Exception("Empty ASC response body") + BufferedReader(InputStreamReader(body.byteStream())).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + content.append(line).append("\n") + if (content.length > maxAscSize) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") + throw Exception("ASC file exceeds maximum allowed size") } } + } - val parsed = content.toString() - if (parsed.isEmpty()) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") - throw Exception("Empty ASC file") - } - parsed + val ascContent = content.toString() + if (ascContent.isEmpty()) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") + throw Exception("Empty ASC file") } OneKeyLog.info("AppUpdate", "downloadASC: ASC content size=${ascContent.length} bytes") 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 9ca1d7a0..cc9547ad 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 @@ -20,6 +20,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -1252,8 +1253,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } else { OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading") downloadedFile.delete() - // Stale completed file invalidates any partial too. + // Stale completed file invalidates any partial too — including + // the concurrent segment files, otherwise the next resume would + // pick up bytes belonging to the rejected build. if (partialFile.exists()) partialFile.delete() + for (i in 0 until 8) File("$partialFilePath.seg$i").delete() } } @@ -1265,16 +1269,22 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // downloader keeps its partial + manifest for resume). sendEvent("update/start") run { - var concurrentProgress = -1 + // onProgress is invoked concurrently by all 8 worker threads, so + // a plain `var` read-compare-write races (duplicate/out-of-order + // progress events). Use AtomicInteger + CAS: only the thread that + // wins the compareAndSet to a strictly higher value emits, which + // also keeps progress monotonic. (Worst case a race only affects + // progress eventing, never file bytes.) + val concurrentProgress = AtomicInteger(-1) val concurrentOutcome = ConcurrentRangeDownloader( httpClient = httpClient, log = { msg -> OneKeyLog.info("BundleUpdate", msg) }, ).download(downloadUrl, partialFilePath) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - if (p != concurrentProgress) { + val prev = concurrentProgress.get() + if (p > prev && concurrentProgress.compareAndSet(prev, p)) { sendEvent("update/downloading", progress = p) - concurrentProgress = p } } } @@ -1329,6 +1339,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { expectedSize > 0 && partialSize > expectedSize -> { OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize") partialFile.delete() + for (i in 0 until 8) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1373,6 +1384,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") if (partialFile.exists()) partialFile.delete() + for (i in 0 until 8) File("$partialFilePath.seg$i").delete() // Don't pre-emit update/error here; the outer catch is the // single source of error events. sanitizeErrorMessageForEvent // recognizes "HTTP " prefix and forwards this string verbatim. @@ -1380,7 +1392,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } val expectsResume = partialBytes > 0 - val isPartialResponse = response.code == 206 + var isPartialResponse = response.code == 206 if (!response.isSuccessful || (response.code != 200 && response.code != 206)) { OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}") @@ -1397,6 +1409,28 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { partialBytes = 0L } + // On a 206 the server's `Content-Range` start MUST equal the offset + // we asked to resume from (`partialBytes`). A misconfigured server or + // proxy can return a 206 whose range starts somewhere else; appending + // that slice onto our `.partial` would splice mismatched bytes and the + // final SHA256 would fail only after a full download. Guard here: if + // the start is missing or != partialBytes, drop the partial and treat + // this response as a fresh full rewrite (200 semantics). + if (isPartialResponse && partialBytes > 0) { + val contentRangeStart = response.header("Content-Range") + ?.let { Regex("""bytes\s+(\d+)-\d+/\d+""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } + if (contentRangeStart == null || contentRangeStart != partialBytes) { + OneKeyLog.warn( + "BundleUpdate", + "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and restarting from scratch" + ) + if (partialFile.exists()) partialFile.delete() + for (i in 0 until 8) File("$partialFilePath.seg$i").delete() + partialBytes = 0L + isPartialResponse = false + } + } + // Close the response before throwing on a null body — OkHttp // holds connection resources on the response wrapper itself, // and `throw` here exits the function before any byteStream() 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 0ab1fd8e..eabd5188 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 @@ -29,11 +29,17 @@ import java.util.concurrent.atomic.AtomicReference * up to the real space limit instead of reserving everything at once. * * Resume across kill/suspend is simply "which `.segN` already exist - * and how big": a full-sized segment is kept; a short one resumes from its - * current length via `Range` + `If-Range`; with no strong validator (ETag) - * leftover segments can't be pinned to the server object and are wiped. The - * caller's whole-file SHA256 + GPG verify after promotion remains the final - * correctness backstop. + * and how big" plus "how far the `.partial` concat already committed": a + * full-sized segment is kept; a short one resumes from its current length via + * `Range`; a segment whose extent is already inside the committed `.partial` + * prefix is done even if its `.segN` was deleted mid-concat. Object identity is + * intentionally NOT pinned (no ETag/If-Range) — resume is unconditional, and a + * mid-flight object swap that slips through is caught by the caller's whole-file + * SHA256 + GPG verify after promotion, which then drives a clean full + * re-download (concat deletes every `.segN` on success). That verify is the sole + * correctness backstop. The only validator-free safety nets kept inline are: a + * 200 to a Range request → FallbackException, an over-long/oversized segment → + * discard, and a per-segment Content-Range bounds check against mis-aligned 206s. * * This class is intentionally free of Android/OneKey dependencies (logging is * injected) so it can be unit/type-checked standalone. @@ -85,7 +91,7 @@ class ConcurrentRangeDownloader( val length: Long get() = end - start + 1 } - private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean) + private class Probe(val totalSize: Long, val supportsRange: Boolean) /** * Fills [partialFilePath] completely with the resource at [url] using @@ -116,21 +122,20 @@ class ConcurrentRangeDownloader( return Outcome.FALLBACK } val total = probe.totalSize - val etag = probe.etag - // A strong validator (ETag) is what lets If-Range pin a resumed segment - // to the exact object the segments were started against. Without it, - // leftover segments are untrustworthy — start fresh. - val hasValidator = !etag.isNullOrEmpty() partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() } val parts = planRanges(total) - if (!hasValidator) { - wipeArtifacts(partialFile, segFile) - } + // Resume is unconditional: object identity is NOT pinned (no ETag) — any + // mid-flight object swap that survives this far is caught by the caller's + // whole-file SHA256/GPG verify, which then drives a clean full re-download + // (concat deletes every `.segN` on success, so the retry starts fresh). + // We therefore never wipe `.segN` for "no/changed validator" reasons. + // Discard any leftover segment that can't belong to this plan (wrong - // length = different object/range, or an index beyond the plan). + // length = different object/range, or an index beyond the plan). This is + // a pure size check, independent of any validator. for (i in 0 until segmentCount) { val f = segFile(i) if (!f.exists()) continue @@ -141,7 +146,22 @@ class ConcurrentRangeDownloader( } } - val transferred = AtomicLong(parts.sumOf { segFile(it.index).length() }) + // `.partial`'s current length is the committed-concat cursor: every byte + // below it has already been appended into `.partial` and its source + // `.segN` may have been deleted by an interrupted concat. Such prefix + // segments are DONE — they must not be re-fetched (that would waste the + // network and break the "~1x + one segment" footprint target). + val committedBytes = if (partialFile.exists()) partialFile.length() else 0L + // A segment is "committed" when `.partial` already covers its full extent. + val isCommitted: (Part) -> Boolean = { committedBytes >= it.start + it.length } + + // Progress baseline: committed bytes already in `.partial`, plus the + // current length of every not-yet-committed segment file (committed + // segments are already accounted for by `committedBytes`, so adding their + // `.segN` length — if it still exists — would double-count). + val transferred = AtomicLong( + committedBytes + parts.filterNot(isCommitted).sumOf { segFile(it.index).length() } + ) onProgress(transferred.get(), total) // Share the abort flag with the cancel handle so an external cancel() is @@ -150,8 +170,10 @@ class ConcurrentRangeDownloader( val fallback = AtomicBoolean(false) val firstError = AtomicReference(null) - // Only segments not yet fully on disk need fetching. - val pending = parts.filter { segFile(it.index).length() < it.length } + // Only segments not yet fully on disk AND not already committed into + // `.partial` need fetching. A committed prefix segment whose `.segN` was + // deleted by an interrupted concat must NOT be treated as missing. + val pending = parts.filterNot(isCommitted).filter { segFile(it.index).length() < it.length } if (pending.isNotEmpty()) { val pool = Executors.newFixedThreadPool(minOf(segmentCount, pending.size)) cancelHandle?.attach(pool) @@ -159,7 +181,7 @@ class ConcurrentRangeDownloader( val futures = pending.map { part -> pool.submit { try { - downloadSegment(url, etag, segFile(part.index), part, aborted) { delta -> + downloadSegment(url, segFile(part.index), part, aborted) { delta -> onProgress(transferred.addAndGet(delta), total) } } catch (e: FallbackException) { @@ -185,14 +207,16 @@ class ConcurrentRangeDownloader( } val err = firstError.get() if (err != null) { - // Transient. Keep the segment files so the next attempt resumes when - // we have a validator; otherwise they can't be trusted — wipe them. - if (!hasValidator) wipeArtifacts(partialFile, segFile) + // Transient. Always keep the segment files so the next attempt + // resumes — resume is unconditional now (no validator gate). throw err } - val incomplete = parts.firstOrNull { segFile(it.index).length() != it.length } + // A committed prefix segment is complete even though its `.segN` is gone; + // only not-yet-committed segments must have a full-length `.segN`. + val incomplete = parts.filterNot(isCommitted) + .firstOrNull { segFile(it.index).length() != it.length } if (incomplete != null) { - if (!hasValidator) wipeArtifacts(partialFile, segFile) + // Keep the segment files for the next attempt to resume. throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})") } @@ -217,23 +241,25 @@ class ConcurrentRangeDownloader( } // Single round-trip probe: a one-byte Range request that confirms Range - // support and captures total size + ETag. OkHttp follows redirects (the - // caller's client enforces HTTPS on each hop). + // support and captures total size. Object identity is intentionally NOT + // validated here (no ETag/If-Range) — the caller's whole-file SHA256 + GPG + // verify after promotion is the sole correctness backstop, so resume is + // always allowed. OkHttp follows redirects (the caller's client enforces + // HTTPS on each hop). private fun probe(url: String): Probe? { return try { val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build() httpClient.newCall(req).execute().use { response -> - val etag = response.header("ETag") when (response.code) { 206 -> { val total = response.header("Content-Range") ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (total != null) Probe(total, etag, true) else Probe(0, etag, false) + if (total != null) Probe(total, true) else Probe(0, false) } 200 -> { // Server ignored Range — single-stream only. val len = response.body?.contentLength() ?: -1L - Probe(if (len > 0) len else 0, etag, false) + Probe(if (len > 0) len else 0, false) } else -> null } @@ -305,7 +331,6 @@ class ConcurrentRangeDownloader( // failures in place. private fun downloadSegment( url: String, - etag: String?, segFile: File, part: Part, aborted: AtomicBoolean, @@ -318,7 +343,7 @@ class ConcurrentRangeDownloader( if (have >= part.length) return val rangeStart = part.start + have try { - fetchSegment(url, etag, segFile, part, rangeStart, aborted, onBytes) + fetchSegment(url, segFile, part, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e @@ -332,26 +357,41 @@ class ConcurrentRangeDownloader( private fun fetchSegment( url: String, - etag: String?, segFile: File, part: Part, rangeStart: Long, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, ) { - val builder = Request.Builder().url(url) + val request = Request.Builder().url(url) .addHeader("Range", "bytes=$rangeStart-${part.end}") - // If-Range: a mismatched ETag makes the CDN reply 200 (full body) - // instead of 206, which we treat as a fallback signal — appending a - // from-zero body onto a partially-filled segment would corrupt it. - if (etag != null) builder.addHeader("If-Range", etag) - httpClient.newCall(builder.build()).execute().use { response -> + .build() + httpClient.newCall(request).execute().use { response -> + // A 200 (full body) to a Range request is the one validator-free + // safety net we keep: appending a from-zero body onto a partially + // filled segment would corrupt it, so bail to the single-stream path. if (response.code == 200) { throw FallbackException("server returned 200 to a Range request") } if (response.code != 206) { throw java.io.IOException("HTTP ${response.code}") } + // Verify the 206 covers exactly the slice we asked for. This guards + // against a proxy/CDN returning a mis-aligned 206 (wrong window), + // which would otherwise silently corrupt the assembled file. A + // missing/mismatched Content-Range is treated as transient (retry). + // It canNOT detect an object swapped behind an identical window — + // that is the caller's whole-file SHA256/GPG verify's job. + val contentRange = response.header("Content-Range") + ?: throw java.io.IOException("206 without Content-Range") + val bounds = parseContentRangeBounds(contentRange) + ?: throw java.io.IOException("unparseable Content-Range: $contentRange") + if (bounds.first != rangeStart || bounds.second != part.end) { + throw java.io.IOException( + "Content-Range mismatch: got ${bounds.first}-${bounds.second}, " + + "expected $rangeStart-${part.end}" + ) + } 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. @@ -368,5 +408,23 @@ class ConcurrentRangeDownloader( } } } + // A 206 can still over-deliver (server ignored our end bound). A segment + // longer than its planned length is unusable — drop it so the next + // attempt re-fetches cleanly rather than concatenating misaligned bytes. + if (segFile.length() > part.length) { + segFile.delete() + throw java.io.IOException( + "Segment ${part.index} overran (${segFile.length()}/${part.length})" + ) + } + } + + // 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 + val start = m.groupValues[1].toLongOrNull() ?: return null + val end = m.groupValues[2].toLongOrNull() ?: return null + return start to end } } 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..0a7241b6 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 @@ -30,6 +30,17 @@ import java.util.concurrent.atomic.AtomicLong @DoNotStrip class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { + companion object { + // Segment count used by cancel/discardArtifacts when sweeping the sibling + // `.partial.segN` files. download() lets callers override segmentCount, + // but cancel/discard don't receive params, so we sweep the shipped default + // (matches ConcurrentRangeDownloader's default and download()'s `?: 8`), + // which covers the overwhelming majority of runs. A larger custom count + // would leave a few high-index `.segN` behind, but the next resume only + // trusts segments it re-reads, so the worst case is a little stale disk. + private const val DEFAULT_SEGMENT_COUNT = 8 + } + private class Listener(val id: Double, val callback: (RangeDownloadEvent) -> Unit) private val listeners = CopyOnWriteArrayList() @@ -86,12 +97,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { val cancelHandle = ConcurrentRangeDownloader.CancelHandle() activeDownloads[runKey] = cancelHandle - // The progress callback is invoked concurrently by the helper's worker - // threads, so guard lastProgress with an AtomicInteger + CAS: only the - // thread that advances the percentage to a strictly higher value wins the - // CAS and emits the event, which keeps progress monotonic and de-duped - // without a lock (this only affects event ordering, never file bytes). - val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1) + var lastProgress = -1 val outcome = try { ConcurrentRangeDownloader( httpClient = httpClient, @@ -101,9 +107,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - val prev = lastProgress.get() - if (p > prev && lastProgress.compareAndSet(prev, p)) { + if (p != lastProgress) { sendEvent(channel, taskId, type = "progress", progress = p) + lastProgress = p } } } @@ -168,9 +174,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { cancelActive(channel, taskId) // Sweep the per-segment `.segN` files plus the concatenated `.partial` so a // future resume can't re-trust stale bytes (no `.progress` manifest exists - // anymore in the segmented model). Glob by filename prefix so any custom - // segmentCount is fully cleared, not just the shipped default of 8. - sweepPartialArtifacts(destFilePath) + // anymore in the segmented model). + for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() + File("$destFilePath.partial").delete() OneKeyLog.info("RangeDownloader", "discardArtifacts: channel=$channel taskId=$taskId") Unit } @@ -184,9 +190,10 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return Promise.async { // Stop workers first, then delete artifacts so nothing resurrects them. cancelActive(channel, taskId) - // Same segmented-artifact sweep as discardArtifacts: glob every per-segment - // `.segN` file by prefix plus the concatenated `.partial`. - sweepPartialArtifacts(destFilePath) + // Same segmented-artifact sweep as discardArtifacts: per-segment `.segN` + // files plus the concatenated `.partial`. + for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() + File("$destFilePath.partial").delete() OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId") Unit } @@ -197,17 +204,6 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { activeDownloads.remove(runKey(channel, taskId))?.cancel() } - // 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() - } - // 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 // (single-step rename onto the destination). On older APIs (java.nio.file is From 605870811fdffd418c553eb3807cdf5dd8050dac Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:12:26 +0800 Subject: [PATCH 3/4] Cleanup partial .segN files, close responses, fix progress Improve robustness of range/partial handling across native modules: - AppUpdate & BundleUpdate: ensure leftover concurrent segment files (.segN) are removed when promoting/discarding partials, and treat 206 Content-Range start mismatches as retryable errors (close response, delete partial+segments, and throw) to avoid writing mis-aligned bytes. BundleUpdate adds a CONCURRENT_SEGMENT_COUNT constant and defers promotion if concurrent segments exist. - RangeDownloader: replace hardcoded default segment sweep with sweepPartialArtifacts (glob by prefix) to clear any custom segment counts, and make progress reporting thread-safe using an AtomicInteger + CAS to dedupe/monotonize progress events. - AppUpdate: read ASC files with response.use to always close connections, enforce a max ASC size, and avoid leaking response bodies. These changes prevent mixing segmented and single-stream artifacts, avoid corrupted downloads from CDN/proxy 206 bugs, and fix leaked connections and duplicate progress events. --- .../ReactNativeAppUpdate.kt | 80 ++++++++++++------- .../ReactNativeBundleUpdate.kt | 41 +++++++--- .../ReactNativeRangeDownloader.kt | 46 ++++++----- 3 files changed, 107 insertions(+), 60 deletions(-) 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 3d39d359..65424b6e 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 @@ -505,10 +505,24 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { expectedSize > 0 && existingSize > expectedSize -> { OneKeyLog.warn("AppUpdate", "downloadAPK: existing APK larger than expected, deleting") downloadedFile.delete() + // Oversized final means local state is untrustworthy. A stale + // single-stream .partial and any sibling .segN left by an earlier + // 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() } expectedSize > 0 && existingSize < expectedSize -> { OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume") if (partialFile.exists()) partialFile.delete() + // Drop any sibling .segN BEFORE the promotion. The promoted final + // must become a clean single-stream resume cursor: if .segN + // survived, Phase 2's hasConcurrentSegments check would fire and + // the concurrent downloader would treat this legacy .partial as a + // 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() if (!downloadedFile.renameTo(partialFile)) { OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final") downloadedFile.delete() @@ -784,12 +798,20 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { if (serverWillResume) { val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull() if (rangeStart == null || rangeStart != partialBytes) { - OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='${response.header("Content-Range")}', requested=$partialBytes); treating as full restart") + // 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 + // SHA/GPG, after a bogus "downloaded" event). Wipe the partial + any + // .segN so the next attempt starts clean from byte zero, close the + // bad response, and throw a retryable error. The outer + // catch(Exception) emits update/error and rethrows; finally resets + // isDownloading, so this won't wedge. + 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() - // Full restart from byte zero → drop any sibling .segN too. for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() - partialBytes = 0L - serverWillResume = false + response.close() + throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch") } } @@ -908,33 +930,37 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { .followSslRedirects(false) .build() val request = Request.Builder().url(ascFileUrl).build() - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") - throw Exception(response.code.toString()) - } - - OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") + // Wrap in .use so the Response (and its connection) is released on every + // exit path — including the !isSuccessful / empty-body / oversize throws, + // which previously leaked the connection back to the pool unclosed. + val ascContent = client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") + throw Exception(response.code.toString()) + } - val content = StringBuilder() - val maxAscSize = 10 * 1024 // 10 KB max for ASC files - val body = response.body ?: throw Exception("Empty ASC response body") - BufferedReader(InputStreamReader(body.byteStream())).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - content.append(line).append("\n") - if (content.length > maxAscSize) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") - throw Exception("ASC file exceeds maximum allowed size") + OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") + + val content = StringBuilder() + val maxAscSize = 10 * 1024 // 10 KB max for ASC files + val body = response.body ?: throw Exception("Empty ASC response body") + BufferedReader(InputStreamReader(body.byteStream())).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + content.append(line).append("\n") + if (content.length > maxAscSize) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") + throw Exception("ASC file exceeds maximum allowed size") + } } } - } - val ascContent = content.toString() - if (ascContent.isEmpty()) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") - throw Exception("Empty ASC file") + val parsed = content.toString() + if (parsed.isEmpty()) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") + throw Exception("Empty ASC file") + } + parsed } OneKeyLog.info("AppUpdate", "downloadASC: ASC content size=${ascContent.length} bytes") 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 cc9547ad..9d2d6813 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 @@ -1084,6 +1084,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { companion object { private const val PREFS_NAME = "BundleUpdatePrefs" + + // Number of concurrent segment files (`.seg0..seg{N-1}`) the + // ConcurrentRangeDownloader produces. MUST equal the segmentCount passed + // to ConcurrentRangeDownloader (currently the default 8). Every place + // that cleans up `.segN` files must iterate `0 until CONCURRENT_SEGMENT_COUNT`. + private const val CONCURRENT_SEGMENT_COUNT = 8 } private val listeners = CopyOnWriteArrayList() @@ -1240,8 +1246,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // pre-allocated model; deleting it is a harmless no-op now.) if (partialFile.exists()) partialFile.delete() File("$partialFilePath.progress").delete() - // ConcurrentRangeDownloader's default segmentCount is 8. - for (i in 0 until 8) File("$partialFilePath.seg$i").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 @@ -1257,7 +1262,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // the concurrent segment files, otherwise the next resume would // pick up bytes belonging to the rejected build. if (partialFile.exists()) partialFile.delete() - for (i in 0 until 8) File("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } } @@ -1318,7 +1323,14 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // before discarding so we save a full re-download. val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L var partialBytes = 0L - if (partialFile.exists()) { + // If any concurrent `.segN` files survive, the `.partial` here is the + // concurrent committed cursor, not a single-stream partial. Defer to + // the concurrent downloader (which already ran above and may resume on + // a later attempt) and skip size-based promote/discard, which would + // otherwise misjudge a bare `.partial` when the concurrent path + // returned FALLBACK but left `.segN` residue. Mirrors app-update. + val hasConcurrentSegments = (0 until CONCURRENT_SEGMENT_COUNT).any { File("$partialFilePath.seg$it").exists() } + if (partialFile.exists() && !hasConcurrentSegments) { val partialSize = partialFile.length() when { expectedSize > 0 && partialSize == expectedSize -> { @@ -1339,7 +1351,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { expectedSize > 0 && partialSize > expectedSize -> { OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize") partialFile.delete() - for (i in 0 until 8) File("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1384,7 +1396,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") if (partialFile.exists()) partialFile.delete() - for (i in 0 until 8) File("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() // Don't pre-emit update/error here; the outer catch is the // single source of error events. sanitizeErrorMessageForEvent // recognizes "HTTP " prefix and forwards this string verbatim. @@ -1406,6 +1418,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (expectsResume && !isPartialResponse) { OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch") if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() partialBytes = 0L } @@ -1414,20 +1427,24 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // proxy can return a 206 whose range starts somewhere else; appending // that slice onto our `.partial` would splice mismatched bytes and the // final SHA256 would fail only after a full download. Guard here: if - // the start is missing or != partialBytes, drop the partial and treat - // this response as a fresh full rewrite (200 semantics). + // the start is missing or != partialBytes, drop the partial+segments + // and abort this attempt. We must NOT reuse this body as a 200-style + // full rewrite: a mismatched 206 body is still a range slice, not the + // whole file, so writing it would produce a corrupt bundle. Close the + // response and throw a retryable error — with partial+segN already + // gone, the next attempt naturally restarts from 0. if (isPartialResponse && partialBytes > 0) { val contentRangeStart = response.header("Content-Range") ?.let { Regex("""bytes\s+(\d+)-\d+/\d+""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } if (contentRangeStart == null || contentRangeStart != partialBytes) { OneKeyLog.warn( "BundleUpdate", - "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and restarting from scratch" + "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and aborting attempt" ) if (partialFile.exists()) partialFile.delete() - for (i in 0 until 8) File("$partialFilePath.seg$i").delete() - partialBytes = 0L - isPartialResponse = false + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() + response.close() + throw java.io.IOException("206 Content-Range start mismatch (got=$contentRangeStart, want=$partialBytes); discarded partial, retry from scratch") } } 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 0a7241b6..f80fc2aa 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 @@ -30,17 +30,6 @@ import java.util.concurrent.atomic.AtomicLong @DoNotStrip class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { - companion object { - // Segment count used by cancel/discardArtifacts when sweeping the sibling - // `.partial.segN` files. download() lets callers override segmentCount, - // but cancel/discard don't receive params, so we sweep the shipped default - // (matches ConcurrentRangeDownloader's default and download()'s `?: 8`), - // which covers the overwhelming majority of runs. A larger custom count - // would leave a few high-index `.segN` behind, but the next resume only - // trusts segments it re-reads, so the worst case is a little stale disk. - private const val DEFAULT_SEGMENT_COUNT = 8 - } - private class Listener(val id: Double, val callback: (RangeDownloadEvent) -> Unit) private val listeners = CopyOnWriteArrayList() @@ -97,7 +86,12 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { val cancelHandle = ConcurrentRangeDownloader.CancelHandle() activeDownloads[runKey] = cancelHandle - var lastProgress = -1 + // The progress callback is invoked concurrently by the helper's worker + // threads, so guard lastProgress with an AtomicInteger + CAS: only the + // thread that advances the percentage to a strictly higher value wins the + // CAS and emits the event, which keeps progress monotonic and de-duped + // without a lock (this only affects event ordering, never file bytes). + val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1) val outcome = try { ConcurrentRangeDownloader( httpClient = httpClient, @@ -107,9 +101,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - if (p != lastProgress) { + val prev = lastProgress.get() + if (p > prev && lastProgress.compareAndSet(prev, p)) { sendEvent(channel, taskId, type = "progress", progress = p) - lastProgress = p } } } @@ -174,9 +168,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { cancelActive(channel, taskId) // Sweep the per-segment `.segN` files plus the concatenated `.partial` so a // future resume can't re-trust stale bytes (no `.progress` manifest exists - // anymore in the segmented model). - for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() - File("$destFilePath.partial").delete() + // anymore in the segmented model). Glob by filename prefix so any custom + // segmentCount is fully cleared, not just the shipped default of 8. + sweepPartialArtifacts(destFilePath) OneKeyLog.info("RangeDownloader", "discardArtifacts: channel=$channel taskId=$taskId") Unit } @@ -190,10 +184,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return Promise.async { // Stop workers first, then delete artifacts so nothing resurrects them. cancelActive(channel, taskId) - // Same segmented-artifact sweep as discardArtifacts: per-segment `.segN` - // files plus the concatenated `.partial`. - for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() - File("$destFilePath.partial").delete() + // Same segmented-artifact sweep as discardArtifacts: glob every per-segment + // `.segN` file by prefix plus the concatenated `.partial`. + sweepPartialArtifacts(destFilePath) OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId") Unit } @@ -204,6 +197,17 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { activeDownloads.remove(runKey(channel, taskId))?.cancel() } + // 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() + } + // 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 // (single-step rename onto the destination). On older APIs (java.nio.file is From e112de4c6b1d55324eaa41c82dc173ea08531783 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:40:38 +0800 Subject: [PATCH 4/4] docs: backfill changelog from 3.0.54 to 3.0.66 --- CHANGELOG.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e2205b..fce820ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,128 @@ All notable changes to this project will be documented in this file. +## [3.0.66] - 2026-06-12 + +### Features +- **range-downloader**: Unified shared `ConcurrentRangeDownloader` implementation used by both `app-update` and `bundle-update`; segments stream into `.segN` files and are concatenated into the final `.partial`, removing full-file pre-allocation and EROFS/ENOSPC risk on near-full devices. +- **app-update (Android)**: APK downloads moved from `cacheDir/apks` to `filesDir/apks` to avoid system cache reclamation (EROFS/ENOSPC) during downloads; `FileProvider` path updated while keeping legacy cache-path compatibility. + +### Bug Fixes +- **app-update / bundle-update / range-downloader**: Harden concurrent range downloader and resume logic — use OkHttp `.use()` to close responses, validate 206 `Content-Range` start bounds, sweep stale sibling `.segN` artifacts, make progress reporting thread-safe via `AtomicInteger` + CAS, and remove unconditional ETag/`If-Range` pinning so final SHA256/GPG verify remains the correctness backstop. +- **app-update / bundle-update / range-downloader**: Clean up partial `.segN` files on promote/discard, close leaked ASC responses, fix duplicate progress events, and treat 206 `Content-Range` mismatches as retryable errors that wipe partial artifacts. +- **app-update (Android)**: Pass absolute path to the concurrent downloader so segment files are written under `filesDir` instead of the read-only root filesystem. + +### Chores +- Bump all packages to 3.0.66. + +## [3.0.65] - 2026-06-12 + +### Bug Fixes +- **split-bundle-loader (iOS)**: Replace the bare `dispatch_after` segment-eval watchdog with `SBLActiveWatchdog`, a `CLOCK_UPTIME_RAW` active-time accumulator that pauses on `WillResignActive` and resumes with a 500 ms grace on `DidBecomeActive`, preventing false `SPLIT_BUNDLE_TIMEOUT` white-screens when the app is suspended during cold start. +- **split-bundle-loader (iOS)**: Pause the active-time watchdog synchronously on lifecycle transitions so a pending timer tick cannot fold suspended time into the active interval. + +### Chores +- Bump all packages to 3.0.65. + +## [3.0.64] - 2026-06-12 + +### Bug Fixes +- **background-thread (Android)**: Harden coalesced runtime work against five concurrency issues — guard `gBgTimerExecutor` assignment under the timer mutex, keep queue items intact across transient `ptr==0` drains, clear orphaned `gPendingWork` entries after `nativeInvalidateSharedRpc`, re-arm drains on bridge install when the queue is non-empty, and guard against stale-id reschedules on empty queues. +- **background-thread (Android)**: Prevent background segment-eval replay and latch-stall across reload by treating an already-erased `gPendingBgEvals` entry as "drain claimed" and force-arming a drain whenever the coalesced queue is non-empty after bridge install. + +### Chores +- Bump all packages to 3.0.64. + +## [3.0.63] - 2026-06-12 + +### Bug Fixes +- **background-thread**: Coalesce background-thread runtime work so multiple `SharedRPC` calls and timer callbacks are batched into a single JS executor drain, reducing native→JS thread hopping and closing races where rapid consecutive calls could overrun the executor. + +### Chores +- Bump all packages to 3.0.63. + +## [3.0.61] - 2026-06-12 + +### Features +- **split-bundle-loader / background-thread**: Evaluate split-bundle segments before resolving `loadSegment` promises on iOS main/background and Android main/background, fixing the uncatchable "Requiring unknown module" Hermes fatal when Metro `import()` ran before the segment's `__d` definitions; adds a watchdog, retryable-vs-fatal error codes, and off-JS-thread file reads. + +### Chores +- **example**: Wire every native module into the example app for iOS/Android build verification. +- Bump all packages to 3.0.61. + +## [3.0.60] - 2026-06-11 + +### Chores +- Refresh example iOS `Podfile.lock` checksums and clarify bundle directory comments. +- Bump all packages to 3.0.60. + +## [3.0.59] - 2026-06-11 + +### Features +- **app-update (Android) / bundle-update**: Add cache-pruning APIs — `clearApkCache` and `pruneStaleAppVersionBundles` — to safely reclaim disk space from old APK and OTA-bundle artifacts while protecting the current app/bundle versions and in-progress downloads. + +### Bug Fixes +- **app-update (Android)**: Protect verified/pending-install APKs during cache cleanup; abort cleanup while a download is active and log skipped files to avoid delete races. +- **bundle-update (Android)**: Make `deleteDirectory` report real success/failure and warn on incomplete deletes instead of always reporting success. +- **bundle-update (iOS)**: Fix `appVersionFromStem` static method scope reference. + +### Chores +- Bump all packages to 3.0.59. + +## [3.0.58] - 2026-06-10 + +### Bug Fixes +- **device-utils (iOS)**: Guard `getAndClearColdStartLocalNotification` with `os_unfair_lock` so the deep-link payload is read exactly once even under concurrent calls. +- **chart-webview (Android)**: Harden pooled WebView reuse — use weak owner/warm-driver refs to avoid pinning disposed host contexts, validate `assetHost` to a bare hostname before it becomes the privileged-bridge origin, normalize `localBundle` to avoid double-slash URLs, and retry `forceDetach` before giving up on a stuck parent. + +### Chores +- Bump all packages to 3.0.58. + +## [3.0.57] - 2026-06-10 + +### Features +- **chart-webview**: Add configurable `androidAssetHost` prop so the WebView asset-loader domain can be overridden instead of hard-coded. + +### Chores +- Bump all packages to 3.0.57. + +## [3.0.56] - 2026-06-10 + +### Features +- **chart-webview**: Add warm-driver support so the pooled offline page boots off-screen and page→native callbacks are routed to the owner or warm-driver; source/bridge setters now apply synchronously, dropping the reconcile loop that caused infinite loops on Android. +- **chart-webview (Android)**: Pause the pooled WebView renderer when no host owns it and resume on claim, stopping off-screen WebView CPU/RAM growth; add `webviewDebuggingEnabled` prop and retry reparenting up to 12 frames. + +### Bug Fixes +- **chart-webview (Android)**: Fix pooled WebView re-parenting white-screen/stuck-loading by forcefully clearing the parent on dispose before re-attaching. +- **chart-webview (Android)**: Remove ChartDBG diagnostic instrumentation while keeping operational pool logs. + +### Chores +- Bump all packages to 3.0.56. + +## [3.0.55] - 2026-06-09 + +### Features +- **device-utils (iOS)**: Add `getAndClearColdStartLocalNotification()` to read and clear a killed-app local-notification tap payload exactly once, enabling cold-start deep-link routing; the slot is in-memory only so a fresh process cannot replay a stale tap. + +### Chores +- Bump all packages to 3.0.55. + +## [3.0.54] - 2026-06-09 + +### Features +- **segment-slider**: Migrate to an uncontrolled model — `value` is replaced by `defaultValue` plus an imperative `setValue(value)` ref method, removing the controlled-value-vs-drag conflict and avoiding Fabric prop commits for programmatic updates. + +### Bug Fixes +- **range-downloader / app-update**: Address concurrent-download audit findings — detect concurrent `.partial` via the `.progress` sidecar before size classification, terminate iOS `download()` completion when a segment goes missing/truncated, stream segment concatenation, require 206 + `Content-Range` match before stashing, and add `cancel(channel, taskId)`. +- **bundle-crypto**: Audit fixes — exact-basename skip rules, `removePrefix` relative paths, iOS path-traversal separator boundary, and RFC 4880 cleartext canonicalization/framing. +- **bundle-update**: Audit fixes — fail-closed on unlistable dirs, exact-basename skip, `removePrefix`, path-confinement separator boundary, hold `isDownloading` across skip delay, and signature-first atomic version persistence. +- **chart-webview**: Scope the privileged bridge to trusted origins / main frame only, refcount the pool, and wire teardown so the WebView is destroyed on host drop. +- **zip-archive (iOS)**: Preserve zip unzip error details in thrown messages. + +### Chores +- **example**: Remove the TradingView asset fetch script. +- Bump all packages to 3.0.54. + ## [3.0.53] - 2026-06-08 ### Features