@onekeyfe/react-native-split-bundle-loader 3.0.136 → 3.0.137

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import java.io.FileInputStream
13
13
  import java.io.FileOutputStream
14
14
  import java.io.IOException
15
15
  import java.security.MessageDigest
16
+ import java.util.concurrent.ConcurrentHashMap
16
17
  import java.util.concurrent.Semaphore
17
18
  import java.util.concurrent.atomic.AtomicBoolean
18
19
  import android.os.Handler
@@ -94,6 +95,18 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
94
95
  private const val MAX_CONCURRENT_EXTRACTS = 2
95
96
  private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS)
96
97
 
98
+ // Per-path extraction locks. The semaphore above is an I/O THROTTLE,
99
+ // not mutual exclusion: with N permits, N threads can extract the SAME
100
+ // relativePath at once. Main and background runtimes resolve segments
101
+ // independently, so two concurrent extractions of one segment is the
102
+ // normal case on the first launch after an APK replace (the
103
+ // install-stamp wipe empties the whole tree, so every segment misses).
104
+ // Serializing per path means exactly one thread extracts and the rest
105
+ // wake up on the exists() fast path. Entries are never removed:
106
+ // dropping one would let a waiter lock an Object another thread has
107
+ // already replaced, and the map is bounded by the segment count.
108
+ private val extractPathLocks = ConcurrentHashMap<String, Any>()
109
+
97
110
  // Wipe-on-APK-replace: avoids stale extracted HBC after overwrite install.
98
111
  // `lastUpdateTime` changes on every APK replacement (adb install -r,
99
112
  // Play Store upgrade, sideload, TestFlight-equivalent). If it differs
@@ -479,56 +492,101 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
479
492
  val extractDir = File(context.filesDir, "$BUILTIN_EXTRACT_DIR/$nativeVersion")
480
493
  val extractedFile = File(extractDir, relativePath)
481
494
 
482
- // #16: If file exists, verify it's not truncated by checking size against asset
495
+ // #16: A complete extraction is reusable with no locking at all. Only
496
+ // the happy case short-circuits here — a size mismatch deliberately
497
+ // falls through to the lock rather than deleting, because deleting
498
+ // outside the per-path lock can destroy a file another thread has just
499
+ // published (its writer would then hand out a path we unlinked).
483
500
  if (extractedFile.exists()) {
484
501
  val assetSize = getAssetSize(context.assets, relativePath)
485
502
  if (assetSize >= 0 && extractedFile.length() == assetSize) {
486
503
  return extractedFile.absolutePath
487
504
  }
488
- // Truncated or size mismatch — delete and re-extract
489
- SBLLogger.warn("Extracted file size mismatch for $relativePath, re-extracting")
490
- extractedFile.delete()
491
505
  }
492
506
 
493
- // #18: Limit concurrent extractions
494
- extractSemaphore.acquire()
495
- try {
496
- // Double-check after acquiring semaphore (another thread may have extracted)
507
+ // Serialize every extraction of THIS path (see extractPathLocks). The
508
+ // lock is taken OUTSIDE the semaphore so a thread waiting here never
509
+ // sits on a permit it isn't using, and so the re-check below can hand
510
+ // back an already-extracted file without spending one.
511
+ synchronized(extractPathLocks.computeIfAbsent(relativePath) { Any() }) {
512
+ // Re-check under the lock: another thread may have extracted while
513
+ // we waited. Nothing in this process can be mid-publish for this
514
+ // path now, so a stale file is safe to delete and re-extract.
497
515
  if (extractedFile.exists()) {
498
- return extractedFile.absolutePath
516
+ val assetSize = getAssetSize(context.assets, relativePath)
517
+ if (assetSize >= 0 && extractedFile.length() == assetSize) {
518
+ return extractedFile.absolutePath
519
+ }
520
+ SBLLogger.warn("Extracted file size mismatch for $relativePath, re-extracting")
521
+ extractedFile.delete()
499
522
  }
500
523
 
501
- val assets: AssetManager = context.assets
502
- return try {
503
- // Extract to temp file first, then atomically rename
504
- val tempFile = File(extractedFile.parentFile, "${extractedFile.name}.tmp")
505
- assets.open(relativePath).use { input ->
506
- extractedFile.parentFile?.let { parent ->
507
- if (!parent.exists()) parent.mkdirs()
524
+ // #18: Limit concurrent extractions
525
+ extractSemaphore.acquire()
526
+ // Extract to a temp file first, then atomically rename. The temp
527
+ // name must be UNIQUE per attempt: a shared "<name>.tmp" lets two
528
+ // writers open the same file with O_TRUNC and interleave into each
529
+ // other's stream, so the winner can publish a partially zeroed HBC
530
+ // while the loser's renameTo fails on a source that was already
531
+ // moved away. extractPathLocks covers this process; the unique name
532
+ // keeps it safe across processes too. Because a unique temp file is
533
+ // never reused by a later attempt, it MUST be cleaned up on every
534
+ // failure path or partial writes accumulate until the next APK
535
+ // replace — hence the finally below.
536
+ val tempFile = File(
537
+ extractedFile.parentFile,
538
+ "${extractedFile.name}.${android.os.Process.myTid()}-${System.nanoTime()}.tmp"
539
+ )
540
+ try {
541
+ val assets: AssetManager = context.assets
542
+ return try {
543
+ var written = 0L
544
+ assets.open(relativePath).use { input ->
545
+ extractedFile.parentFile?.let { parent ->
546
+ if (!parent.exists()) parent.mkdirs()
547
+ }
548
+ FileOutputStream(tempFile).use { output ->
549
+ val buffer = ByteArray(8192)
550
+ var len: Int
551
+ while (input.read(buffer).also { len = it } != -1) {
552
+ output.write(buffer, 0, len)
553
+ written += len
554
+ }
555
+ }
508
556
  }
509
- FileOutputStream(tempFile).use { output ->
510
- val buffer = ByteArray(8192)
511
- var len: Int
512
- while (input.read(buffer).also { len = it } != -1) {
513
- output.write(buffer, 0, len)
557
+ // Atomic rename prevents partial file observation
558
+ if (tempFile.renameTo(extractedFile)) {
559
+ SBLLogger.info("[extractBuiltin] extracted $relativePath → ${extractedFile.absolutePath} (${extractedFile.length()} bytes)")
560
+ extractedFile.absolutePath
561
+ } else {
562
+ // A failed rename is NOT proof the segment is missing:
563
+ // another process may have published the same bytes at
564
+ // the destination. Returning null here would turn a
565
+ // transient race into SPLIT_BUNDLE_NOT_FOUND, which the
566
+ // JS loader caches as a permanent failure. Compare
567
+ // against what we just wrote rather than re-reading the
568
+ // asset, and require an exact match — accepting an
569
+ // unknown size would be laxer than the checks above.
570
+ if (extractedFile.exists() && extractedFile.length() == written) {
571
+ SBLLogger.warn("[extractBuiltin] rename lost the race for $relativePath, using file published by another writer: ${extractedFile.absolutePath} ($written bytes)")
572
+ extractedFile.absolutePath
573
+ } else {
574
+ SBLLogger.warn("[extractBuiltin] rename failed for $relativePath: ${tempFile.absolutePath} → ${extractedFile.absolutePath}")
575
+ null
514
576
  }
515
577
  }
578
+ } catch (e: IOException) {
579
+ SBLLogger.warn("[extractBuiltin] IOException for $relativePath: ${e.javaClass.simpleName}: ${e.message}")
580
+ null
516
581
  }
517
- // Atomic rename prevents partial file observation
518
- if (tempFile.renameTo(extractedFile)) {
519
- SBLLogger.info("[extractBuiltin] extracted $relativePath ${extractedFile.absolutePath} (${extractedFile.length()} bytes)")
520
- extractedFile.absolutePath
521
- } else {
522
- SBLLogger.warn("[extractBuiltin] rename failed for $relativePath: ${tempFile.absolutePath} → ${extractedFile.absolutePath}")
582
+ } finally {
583
+ // No-op after a successful rename (the temp file is gone);
584
+ // reclaims the partial write on every other path.
585
+ if (tempFile.exists()) {
523
586
  tempFile.delete()
524
- null
525
587
  }
526
- } catch (e: IOException) {
527
- SBLLogger.warn("[extractBuiltin] IOException for $relativePath: ${e.javaClass.simpleName}: ${e.message}")
528
- null
588
+ extractSemaphore.release()
529
589
  }
530
- } finally {
531
- extractSemaphore.release()
532
590
  }
533
591
  }
534
592
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-split-bundle-loader",
3
- "version": "3.0.136",
3
+ "version": "3.0.137",
4
4
  "description": "react-native-split-bundle-loader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",