@hot-updater/react-native 0.36.13 → 0.36.15

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.
@@ -485,6 +485,7 @@ class BundleFileStorageService(
485
485
  checkAndCleanupIfIsolationKeyChanged()
486
486
  }
487
487
 
488
+ private var hasPreparedLaunch = false
488
489
  private var currentLaunchReport: LaunchReport? = null
489
490
 
490
491
  @Volatile
@@ -930,6 +931,7 @@ class BundleFileStorageService(
930
931
  stableBundleId = currentVerifiedBundleId,
931
932
  stagingBundleId = bundleId,
932
933
  verificationPending = true,
934
+ launchInProgress = false,
933
935
  updatedAt = System.currentTimeMillis(),
934
936
  )
935
937
  }
@@ -956,6 +958,7 @@ class BundleFileStorageService(
956
958
  stableBundleId = null,
957
959
  stagingBundleId = fallbackBundleId,
958
960
  verificationPending = false,
961
+ launchInProgress = false,
959
962
  updatedAt = System.currentTimeMillis(),
960
963
  )
961
964
  saveMetadata(updatedMetadata)
@@ -1052,6 +1055,7 @@ class BundleFileStorageService(
1052
1055
  saveMetadata(
1053
1056
  metadata.copy(
1054
1057
  verificationPending = false,
1058
+ launchInProgress = false,
1055
1059
  updatedAt = System.currentTimeMillis(),
1056
1060
  ),
1057
1061
  )
@@ -1106,8 +1110,22 @@ class BundleFileStorageService(
1106
1110
  override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1107
1111
  saveLaunchReport(null)
1108
1112
  applyPendingRecoveryIfNeeded(pendingRecovery)
1113
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1114
+ // lookups or bridge reloads in this process must not reject its own launch.
1115
+ if (!hasPreparedLaunch) {
1116
+ val metadata = loadMetadataOrNull()
1117
+ if (metadata?.verificationPending == true && metadata.launchInProgress) {
1118
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
1119
+ }
1120
+ }
1121
+ hasPreparedLaunch = true
1109
1122
 
1110
1123
  val selection = selectLaunch()
1124
+ if (selection.shouldRollbackOnCrash) {
1125
+ loadMetadataOrNull()?.let { metadata ->
1126
+ saveMetadata(metadata.copy(launchInProgress = true))
1127
+ }
1128
+ }
1111
1129
  Log.d(
1112
1130
  TAG,
1113
1131
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -1937,6 +1955,7 @@ class BundleFileStorageService(
1937
1955
  stableBundleId = null,
1938
1956
  stagingBundleId = null,
1939
1957
  verificationPending = false,
1958
+ launchInProgress = false,
1940
1959
  )
1941
1960
 
1942
1961
  if (!saveMetadata(clearedMetadata)) {
@@ -14,6 +14,7 @@ data class BundleMetadata(
14
14
  val stableBundleId: String? = null,
15
15
  val stagingBundleId: String? = null,
16
16
  val verificationPending: Boolean = false,
17
+ val launchInProgress: Boolean = false,
17
18
  val updatedAt: Long = System.currentTimeMillis(),
18
19
  ) {
19
20
  companion object {
@@ -43,6 +44,7 @@ data class BundleMetadata(
43
44
  null
44
45
  },
45
46
  verificationPending = json.optBoolean("verificationPending", false),
47
+ launchInProgress = json.optBoolean("launchInProgress", false),
46
48
  updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
47
49
  )
48
50
 
@@ -86,6 +88,7 @@ data class BundleMetadata(
86
88
  put("stableBundleId", stableBundleId ?: JSONObject.NULL)
87
89
  put("stagingBundleId", stagingBundleId ?: JSONObject.NULL)
88
90
  put("verificationPending", verificationPending)
91
+ put("launchInProgress", launchInProgress)
89
92
  put("updatedAt", updatedAt)
90
93
  }
91
94
 
@@ -23,6 +23,71 @@ class BundleFileStorageServiceTest {
23
23
  @get:Rule
24
24
  val temporaryFolder = TemporaryFolder()
25
25
 
26
+ @Test
27
+ fun `unfinished staging launch rolls back to stable on cold start`() {
28
+ assertStagingLaunchAcrossColdStart(hasStableBundle = true)
29
+ }
30
+
31
+ @Test
32
+ fun `unfinished staging launch rolls back to built in on cold start`() {
33
+ assertStagingLaunchAcrossColdStart(hasStableBundle = false)
34
+ }
35
+
36
+ @Test
37
+ fun `completed staging launch remains trusted on cold start`() {
38
+ assertStagingLaunchAcrossColdStart(hasStableBundle = true, completesLaunch = true)
39
+ }
40
+
41
+ @Test
42
+ fun `completed first OTA launch remains trusted on cold start`() {
43
+ assertStagingLaunchAcrossColdStart(hasStableBundle = false, completesLaunch = true)
44
+ }
45
+
46
+ private fun assertStagingLaunchAcrossColdStart(
47
+ hasStableBundle: Boolean,
48
+ completesLaunch: Boolean = false,
49
+ ) {
50
+ val rootDir = temporaryFolder.newFolder()
51
+ val preferences = InMemoryPreferencesService()
52
+ val stableBundleId = if (hasStableBundle) "stable-bundle" else null
53
+ listOfNotNull(stableBundleId, "hung-bundle").forEach { bundleId ->
54
+ val directory = createBundleDir(rootDir, bundleId)
55
+ writeFile(directory, "index.android.bundle")
56
+ writeManifest(directory, listOf("index.android.bundle"))
57
+ }
58
+ writeMetadata(
59
+ rootDir,
60
+ BundleMetadata(
61
+ isolationKey = TEST_ISOLATION_KEY,
62
+ stableBundleId = stableBundleId,
63
+ stagingBundleId = "hung-bundle",
64
+ verificationPending = true,
65
+ ),
66
+ )
67
+ val firstProcess = createService(rootDir, preferences)
68
+ val firstLaunch = firstProcess.prepareLaunch(null)
69
+ assertEquals("hung-bundle", firstLaunch.launchedBundleId)
70
+ assertTrue(firstLaunch.shouldRollbackOnCrash)
71
+
72
+ assertEquals("hung-bundle", firstProcess.prepareLaunch(null).launchedBundleId)
73
+ assertFalse(firstProcess.getCrashHistory().contains("hung-bundle"))
74
+ if (completesLaunch) {
75
+ firstProcess.markLaunchCompleted("hung-bundle")
76
+ }
77
+
78
+ // Issue #1321: simulate process termination without a crash marker.
79
+ val secondProcess = createService(rootDir, preferences)
80
+ val nextLaunch = secondProcess.prepareLaunch(null)
81
+ assertEquals(if (completesLaunch) "hung-bundle" else stableBundleId, nextLaunch.launchedBundleId)
82
+ assertFalse(nextLaunch.shouldRollbackOnCrash)
83
+ assertEquals(!completesLaunch, secondProcess.getCrashHistory().contains("hung-bundle"))
84
+ if (!completesLaunch) {
85
+ assertEquals("RECOVERED", secondProcess.notifyAppReady()["status"])
86
+ }
87
+ val thirdProcess = createService(rootDir, preferences)
88
+ assertEquals(nextLaunch.launchedBundleId, thirdProcess.prepareLaunch(null).launchedBundleId)
89
+ }
90
+
26
91
  @Test
27
92
  fun `resolveBundleFile uses single manifest bundle at root`() {
28
93
  val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
@@ -349,6 +349,7 @@ class BundleFileStorageService: BundleStorageService {
349
349
 
350
350
  private var activeTasks: [URLSessionTask] = []
351
351
 
352
+ private var hasPreparedLaunch = false
352
353
  private var currentLaunchReport: LaunchReport?
353
354
  private let activeBundleMetadataLock = NSLock()
354
355
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
@@ -1609,7 +1610,22 @@ class BundleFileStorageService: BundleStorageService {
1609
1610
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
1610
1611
  saveLaunchReport(nil)
1611
1612
  applyPendingRecoveryIfNeeded(pendingRecovery)
1612
- return selectLaunch(bundle: bundle)
1613
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1614
+ // lookups or bridge reloads in this process must not reject its own launch.
1615
+ if !hasPreparedLaunch,
1616
+ let metadata = loadMetadataOrNull(),
1617
+ metadata.verificationPending,
1618
+ metadata.launchInProgress,
1619
+ let stagingBundleId = metadata.stagingBundleId {
1620
+ rollbackPendingBundle(stagingBundleId)
1621
+ }
1622
+ hasPreparedLaunch = true
1623
+ let selection = selectLaunch(bundle: bundle)
1624
+ if selection.shouldRollbackOnCrash, var metadata = loadMetadataOrNull() {
1625
+ metadata.launchInProgress = true
1626
+ _ = saveMetadata(metadata)
1627
+ }
1628
+ return selection
1613
1629
  }
1614
1630
 
1615
1631
  // MARK: - Bundle Update
@@ -2535,6 +2551,7 @@ class BundleFileStorageService: BundleStorageService {
2535
2551
  }
2536
2552
 
2537
2553
  metadata.verificationPending = false
2554
+ metadata.launchInProgress = false
2538
2555
  metadata.updatedAt = Date().timeIntervalSince1970 * 1000
2539
2556
  let _ = saveMetadata(metadata)
2540
2557
  }
@@ -12,6 +12,7 @@ public struct BundleMetadata: Codable {
12
12
  var stableBundleId: String?
13
13
  var stagingBundleId: String?
14
14
  var verificationPending: Bool
15
+ var launchInProgress: Bool
15
16
  var updatedAt: Double
16
17
 
17
18
  enum CodingKeys: String, CodingKey {
@@ -20,6 +21,7 @@ public struct BundleMetadata: Codable {
20
21
  case stableBundleId = "stable_bundle_id"
21
22
  case stagingBundleId = "staging_bundle_id"
22
23
  case verificationPending = "verification_pending"
24
+ case launchInProgress = "launch_in_progress"
23
25
  case updatedAt = "updated_at"
24
26
  }
25
27
 
@@ -29,6 +31,7 @@ public struct BundleMetadata: Codable {
29
31
  stableBundleId: String? = nil,
30
32
  stagingBundleId: String? = nil,
31
33
  verificationPending: Bool = false,
34
+ launchInProgress: Bool = false,
32
35
  updatedAt: Double = Date().timeIntervalSince1970 * 1000
33
36
  ) {
34
37
  self.schema = schema
@@ -36,9 +39,21 @@ public struct BundleMetadata: Codable {
36
39
  self.stableBundleId = stableBundleId
37
40
  self.stagingBundleId = stagingBundleId
38
41
  self.verificationPending = verificationPending
42
+ self.launchInProgress = launchInProgress
39
43
  self.updatedAt = updatedAt
40
44
  }
41
45
 
46
+ public init(from decoder: Decoder) throws {
47
+ let values = try decoder.container(keyedBy: CodingKeys.self)
48
+ schema = try values.decode(String.self, forKey: .schema)
49
+ isolationKey = try values.decodeIfPresent(String.self, forKey: .isolationKey)
50
+ stableBundleId = try values.decodeIfPresent(String.self, forKey: .stableBundleId)
51
+ stagingBundleId = try values.decodeIfPresent(String.self, forKey: .stagingBundleId)
52
+ verificationPending = try values.decode(Bool.self, forKey: .verificationPending)
53
+ launchInProgress = try values.decodeIfPresent(Bool.self, forKey: .launchInProgress) ?? false
54
+ updatedAt = try values.decode(Double.self, forKey: .updatedAt)
55
+ }
56
+
42
57
  static func load(from file: URL, expectedIsolationKey: String) -> BundleMetadata? {
43
58
  guard FileManager.default.fileExists(atPath: file.path) else {
44
59
  print("[BundleMetadata] Metadata file does not exist: \(file.path)")
@@ -4,5 +4,5 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.HOT_UPDATER_SDK_VERSION = void 0;
7
- const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.13";
7
+ const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.15";
8
8
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
 
3
- export const HOT_UPDATER_SDK_VERSION = "0.36.13";
3
+ export const HOT_UPDATER_SDK_VERSION = "0.36.15";
4
4
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.13";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.15";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.13";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.15";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.36.13",
3
+ "version": "0.36.15",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/commonjs/index.js",
@@ -131,14 +131,14 @@
131
131
  "react-native-builder-bob": "0.41.0",
132
132
  "@typescript/native": "npm:typescript@^7.0.2",
133
133
  "typescript": "npm:@typescript/typescript6@^6.0.2",
134
- "hot-updater": "0.36.13"
134
+ "hot-updater": "0.36.15"
135
135
  },
136
136
  "dependencies": {
137
137
  "use-sync-external-store": "1.5.0",
138
- "@hot-updater/js": "0.36.13",
139
- "@hot-updater/cli-tools": "0.36.13",
140
- "@hot-updater/plugin-core": "0.36.13",
141
- "@hot-updater/core": "0.36.13"
138
+ "@hot-updater/cli-tools": "0.36.15",
139
+ "@hot-updater/core": "0.36.15",
140
+ "@hot-updater/plugin-core": "0.36.15",
141
+ "@hot-updater/js": "0.36.15"
142
142
  },
143
143
  "scripts": {
144
144
  "build": "node ./scripts/sync-sdk-version.mjs run -- bob build && tsc -p plugin/tsconfig.build.json",
package/src/sdkVersion.ts CHANGED
@@ -1 +1 @@
1
- export const HOT_UPDATER_SDK_VERSION = "0.36.13";
1
+ export const HOT_UPDATER_SDK_VERSION = "0.36.15";