@hot-updater/react-native 0.36.17 → 0.36.19

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.
@@ -146,6 +146,9 @@ dependencies {
146
146
  }
147
147
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
148
148
  implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
149
+ // ProcessLifecycleOwner, for an activity that started before Hot Updater was created.
150
+ // React Native's AppCompat already brings it at runtime.
151
+ implementation "androidx.lifecycle:lifecycle-process:2.6.1"
149
152
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
150
153
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
151
154
  implementation "org.apache.commons:commons-compress:1.27.1"
@@ -0,0 +1,94 @@
1
+ package com.hotupdater
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import androidx.lifecycle.Lifecycle
7
+ import androidx.lifecycle.ProcessLifecycleOwner
8
+
9
+ /**
10
+ * Runs an action once this process has a started activity.
11
+ *
12
+ * A headless JS task (HeadlessJsTaskService, which background push libraries
13
+ * start) loads the bundle with no activity and never renders. Waiting for an
14
+ * activity keeps that launch from counting as one that ended before its first
15
+ * render.
16
+ *
17
+ * Android does not replay lifecycle callbacks to a late registration. An
18
+ * activity that started before this gate existed, as when a brownfield app adds
19
+ * React Native to an activity already on screen, is read from
20
+ * [processHasStartedActivity] instead.
21
+ */
22
+ internal class ActivityStartGate(
23
+ private val application: Application?,
24
+ private val processHasStartedActivity: () -> Boolean = ::hasStartedActivity,
25
+ ) : Application.ActivityLifecycleCallbacks {
26
+ private var startedActivities = 0
27
+ private var pendingAction: (() -> Unit)? = null
28
+
29
+ init {
30
+ application?.registerActivityLifecycleCallbacks(this)
31
+ }
32
+
33
+ /**
34
+ * Runs [action] now when an activity is started, otherwise at the next
35
+ * activity start. It replaces an action still waiting; null cancels it.
36
+ * Without an application to observe, it runs [action] now.
37
+ */
38
+ fun runWhenActivityStarted(action: (() -> Unit)?) {
39
+ val runNow =
40
+ synchronized(this) {
41
+ val waits =
42
+ application != null &&
43
+ startedActivities == 0 &&
44
+ !processHasStartedActivity()
45
+ pendingAction = action.takeIf { waits }
46
+ action.takeUnless { waits }
47
+ }
48
+ runNow?.invoke()
49
+ }
50
+
51
+ override fun onActivityStarted(activity: Activity) {
52
+ val action =
53
+ synchronized(this) {
54
+ startedActivities += 1
55
+ pendingAction.also { pendingAction = null }
56
+ }
57
+ action?.invoke()
58
+ }
59
+
60
+ override fun onActivityStopped(activity: Activity) {
61
+ synchronized(this) {
62
+ // An activity started before this gate existed was never counted.
63
+ if (startedActivities > 0) startedActivities -= 1
64
+ }
65
+ }
66
+
67
+ override fun onActivityCreated(
68
+ activity: Activity,
69
+ savedInstanceState: Bundle?,
70
+ ) {}
71
+
72
+ override fun onActivityResumed(activity: Activity) {}
73
+
74
+ override fun onActivityPaused(activity: Activity) {}
75
+
76
+ override fun onActivitySaveInstanceState(
77
+ activity: Activity,
78
+ outState: Bundle,
79
+ ) {}
80
+
81
+ override fun onActivityDestroyed(activity: Activity) {}
82
+ }
83
+
84
+ // ProcessLifecycleOwner observes activities from process start, through the
85
+ // androidx.startup initializer that lifecycle-process registers. It is STARTED
86
+ // while an activity of this process is started, and stays at INITIALIZED when
87
+ // an app removes that initializer, so the gate then waits for the next start.
88
+ private fun hasStartedActivity(): Boolean =
89
+ runCatching {
90
+ ProcessLifecycleOwner
91
+ .get()
92
+ .lifecycle.currentState
93
+ .isAtLeast(Lifecycle.State.STARTED)
94
+ }.getOrDefault(false)
@@ -98,6 +98,12 @@ interface BundleStorageService {
98
98
  progressCallback: (UpdateProgressPayload) -> Unit,
99
99
  )
100
100
 
101
+ /**
102
+ * Records that the current launch of a verification-pending bundle can show UI.
103
+ * The next process rolls the bundle back unless first content verified it.
104
+ */
105
+ fun markLaunchStarted(launchedBundleId: String?)
106
+
101
107
  /**
102
108
  * Marks the current launch as successful after the first content appeared.
103
109
  */
@@ -109,7 +115,8 @@ interface BundleStorageService {
109
115
  fun notifyAppReady(): Map<String, Any?>
110
116
 
111
117
  /**
112
- * Gets the crashed bundle history
118
+ * Gets the crashed bundle history, plus a bundle that waits for its retry after
119
+ * an unfinished launch while this process still refuses it (see [InterruptedLaunch])
113
120
  * @return CrashedHistory containing crashed bundles
114
121
  */
115
122
  fun getCrashHistory(): CrashedHistory
@@ -488,6 +495,16 @@ class BundleFileStorageService(
488
495
  private var hasPreparedLaunch = false
489
496
  private var currentLaunchReport: LaunchReport? = null
490
497
 
498
+ // Crash history is written at first content on the main thread while JS can read it.
499
+ private val crashedHistoryLock = Any()
500
+
501
+ // markLaunchStarted runs at an activity start, which can come while JS stages a
502
+ // newer bundle; both read and rewrite metadata.
503
+ private val stagingMetadataLock = Any()
504
+
505
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
506
+ private var retryHeldBundleId: String? = null
507
+
491
508
  @Volatile
492
509
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
493
510
  private val activeBundleMetadataLock = Any()
@@ -936,7 +953,14 @@ class BundleFileStorageService(
936
953
  )
937
954
  }
938
955
 
939
- private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
956
+ /**
957
+ * Rolls back the pending staging bundle. [unfinishedLaunch] means its launch
958
+ * ended before first content without a crash marker.
959
+ */
960
+ private fun rollbackPendingBundle(
961
+ stagingBundleId: String,
962
+ unfinishedLaunch: Boolean = false,
963
+ ): Boolean {
940
964
  val metadata = loadMetadataOrNull() ?: return false
941
965
  if (metadata.stagingBundleId != stagingBundleId) {
942
966
  return false
@@ -944,9 +968,7 @@ class BundleFileStorageService(
944
968
 
945
969
  Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
946
970
 
947
- val crashedHistory = loadCrashedHistory()
948
- crashedHistory.addEntry(stagingBundleId)
949
- saveCrashedHistory(crashedHistory)
971
+ recordFailedLaunch(stagingBundleId, unfinishedLaunch)
950
972
 
951
973
  val fallbackBundleId =
952
974
  metadata.stableBundleId?.takeIf { candidate ->
@@ -1034,18 +1056,103 @@ class BundleFileStorageService(
1034
1056
 
1035
1057
  private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
1036
1058
 
1037
- private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
1059
+ private fun isBundleInCrashedHistory(bundleId: String): Boolean = getCrashHistory().contains(bundleId)
1038
1060
 
1039
- override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
1061
+ /**
1062
+ * The bundles this device refuses to install: crash history, plus a bundle
1063
+ * waiting for its retry until a process that may retry it.
1064
+ */
1065
+ override fun getCrashHistory(): CrashedHistory =
1066
+ synchronized(crashedHistoryLock) {
1067
+ val history = loadCrashedHistory()
1068
+ val interrupted = history.interruptedLaunch
1069
+ if (interrupted != null &&
1070
+ (!interrupted.retryReady || interrupted.bundleId == retryHeldBundleId) &&
1071
+ !history.contains(interrupted.bundleId)
1072
+ ) {
1073
+ history.bundles.add(CrashedBundleEntry(interrupted.bundleId, crashedAt = System.currentTimeMillis()))
1074
+ }
1075
+ history
1076
+ }
1040
1077
 
1041
1078
  override fun clearCrashHistory(): Boolean {
1042
- val history = CrashedHistory()
1043
- saveCrashedHistory(history)
1079
+ synchronized(crashedHistoryLock) {
1080
+ saveCrashedHistory(CrashedHistory())
1081
+ retryHeldBundleId = null
1082
+ }
1044
1083
  Log.d(TAG, "Cleared crash history")
1045
1084
  return true
1046
1085
  }
1047
1086
 
1087
+ /**
1088
+ * A crash, or a second unfinished launch of the same bundle, adds the bundle to
1089
+ * crash history. A first unfinished launch cannot tell a hang from a user leaving
1090
+ * early, so it leaves the bundle one retry instead.
1091
+ */
1092
+ private fun recordFailedLaunch(
1093
+ bundleId: String,
1094
+ unfinishedLaunch: Boolean,
1095
+ ) {
1096
+ synchronized(crashedHistoryLock) {
1097
+ val history = loadCrashedHistory()
1098
+ val interrupted = history.interruptedLaunch
1099
+ if (unfinishedLaunch && interrupted?.bundleId != bundleId) {
1100
+ Log.w(TAG, "Launch of $bundleId ended before first content; it gets one retry")
1101
+ history.interruptedLaunch = InterruptedLaunch(bundleId)
1102
+ retryHeldBundleId = bundleId
1103
+ } else {
1104
+ history.addEntry(bundleId)
1105
+ if (interrupted?.bundleId == bundleId) {
1106
+ history.interruptedLaunch = null
1107
+ }
1108
+ }
1109
+ saveCrashedHistory(history)
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * First content appeared, so the app works again. A bundle waiting for its retry
1115
+ * may be installed from the next process on. This process keeps refusing it, so
1116
+ * the session that recovered never reloads into it. Content from that bundle
1117
+ * verifies it.
1118
+ */
1119
+ private fun readyInterruptedLaunchRetry(currentBundleId: String?) {
1120
+ synchronized(crashedHistoryLock) {
1121
+ val history = loadCrashedHistory()
1122
+ val interrupted = history.interruptedLaunch ?: return
1123
+ if (interrupted.bundleId == currentBundleId) {
1124
+ history.interruptedLaunch = null
1125
+ } else if (interrupted.retryReady) {
1126
+ return
1127
+ } else {
1128
+ history.interruptedLaunch = interrupted.copy(retryReady = true)
1129
+ retryHeldBundleId = interrupted.bundleId
1130
+ }
1131
+ saveCrashedHistory(history)
1132
+ }
1133
+ }
1134
+
1135
+ private fun saveNewStagingMetadata(bundleId: String): BundleMetadata =
1136
+ synchronized(stagingMetadataLock) {
1137
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1138
+ prepareMetadataForNewStagingBundle(currentMetadata, bundleId).also { saveMetadata(it) }
1139
+ }
1140
+
1141
+ override fun markLaunchStarted(launchedBundleId: String?) {
1142
+ synchronized(stagingMetadataLock) {
1143
+ val metadata = loadMetadataOrNull() ?: return
1144
+ if (!isVerificationPending(metadata) ||
1145
+ metadata.launchInProgress ||
1146
+ metadata.stagingBundleId != launchedBundleId
1147
+ ) {
1148
+ return
1149
+ }
1150
+ saveMetadata(metadata.copy(launchInProgress = true))
1151
+ }
1152
+ }
1153
+
1048
1154
  override fun markLaunchCompleted(currentBundleId: String?) {
1155
+ readyInterruptedLaunchRetry(currentBundleId)
1049
1156
  val metadata = loadMetadataOrNull() ?: return
1050
1157
  val stagingBundleId = metadata.stagingBundleId ?: return
1051
1158
  if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
@@ -1115,17 +1222,13 @@ class BundleFileStorageService(
1115
1222
  if (!hasPreparedLaunch) {
1116
1223
  val metadata = loadMetadataOrNull()
1117
1224
  if (metadata?.verificationPending == true && metadata.launchInProgress) {
1118
- metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
1225
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1119
1226
  }
1120
1227
  }
1121
1228
  hasPreparedLaunch = true
1122
1229
 
1230
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1123
1231
  val selection = selectLaunch()
1124
- if (selection.shouldRollbackOnCrash) {
1125
- loadMetadataOrNull()?.let { metadata ->
1126
- saveMetadata(metadata.copy(launchInProgress = true))
1127
- }
1128
- }
1129
1232
  Log.d(
1130
1233
  TAG,
1131
1234
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -1204,9 +1307,7 @@ class BundleFileStorageService(
1204
1307
  finalBundleDir.setLastModified(System.currentTimeMillis())
1205
1308
 
1206
1309
  // Update metadata: set as staging
1207
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1208
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1209
- saveMetadata(updatedMetadata)
1310
+ val updatedMetadata = saveNewStagingMetadata(bundleId)
1210
1311
 
1211
1312
  // Set bundle URL for backwards compatibility
1212
1313
  setBundleURL(existingBundleFile.absolutePath)
@@ -1437,9 +1538,7 @@ class BundleFileStorageService(
1437
1538
  Log.d(TAG, "Setting bundle as staging: $bundlePath")
1438
1539
 
1439
1540
  // Update metadata: set new bundle as staging
1440
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1441
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1442
- saveMetadata(updatedMetadata)
1541
+ val updatedMetadata = saveNewStagingMetadata(bundleId)
1443
1542
 
1444
1543
  // Also update HotUpdaterBundleURL for backwards compatibility
1445
1544
  // This will point to the staging bundle that will be loaded
@@ -1800,9 +1899,7 @@ class BundleFileStorageService(
1800
1899
 
1801
1900
  finalBundleDir.setLastModified(System.currentTimeMillis())
1802
1901
 
1803
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1804
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1805
- saveMetadata(updatedMetadata)
1902
+ val updatedMetadata = saveNewStagingMetadata(bundleId)
1806
1903
  setBundleURL(finalIndexFile.absolutePath)
1807
1904
 
1808
1905
  tempDir.deleteRecursively()
@@ -129,12 +129,48 @@ data class CrashedBundleEntry(
129
129
  }
130
130
  }
131
131
 
132
+ /**
133
+ * A bundle whose launch ended before first content without a crash, waiting
134
+ * for its one retry. Such a launch cannot tell a hang from a user leaving
135
+ * early, so the bundle is offered again instead of entering crash history.
136
+ * A second unfinished launch of it adds it there.
137
+ *
138
+ * [retryReady] turns true once a later launch shows content. The retry
139
+ * starts with the next process, so the session that recovered never reloads
140
+ * into the same bundle.
141
+ */
142
+ data class InterruptedLaunch(
143
+ val bundleId: String,
144
+ val retryReady: Boolean = false,
145
+ ) {
146
+ companion object {
147
+ fun fromJson(json: JSONObject): InterruptedLaunch? =
148
+ if (json.isNull("bundleId")) {
149
+ null
150
+ } else {
151
+ json.getString("bundleId").takeIf { it.isNotEmpty() }?.let { bundleId ->
152
+ InterruptedLaunch(
153
+ bundleId = bundleId,
154
+ retryReady = json.optBoolean("retryReady", false),
155
+ )
156
+ }
157
+ }
158
+ }
159
+
160
+ fun toJson(): JSONObject =
161
+ JSONObject().apply {
162
+ put("bundleId", bundleId)
163
+ put("retryReady", retryReady)
164
+ }
165
+ }
166
+
132
167
  /**
133
168
  * History of crashed bundles
134
169
  */
135
170
  data class CrashedHistory(
136
171
  val bundles: MutableList<CrashedBundleEntry> = mutableListOf(),
137
172
  val maxHistorySize: Int = DEFAULT_MAX_HISTORY_SIZE,
173
+ var interruptedLaunch: InterruptedLaunch? = null,
138
174
  ) {
139
175
  companion object {
140
176
  private const val TAG = "CrashedHistory"
@@ -150,6 +186,7 @@ data class CrashedHistory(
150
186
  return CrashedHistory(
151
187
  bundles = bundles,
152
188
  maxHistorySize = json.optInt("maxHistorySize", DEFAULT_MAX_HISTORY_SIZE),
189
+ interruptedLaunch = json.optJSONObject("interruptedLaunch")?.let(InterruptedLaunch::fromJson),
153
190
  )
154
191
  }
155
192
 
@@ -175,6 +212,7 @@ data class CrashedHistory(
175
212
  bundles.forEach { bundlesArray.put(it.toJson()) }
176
213
  put("bundles", bundlesArray)
177
214
  put("maxHistorySize", maxHistorySize)
215
+ interruptedLaunch?.let { put("interruptedLaunch", it.toJson()) }
178
216
  }
179
217
 
180
218
  fun saveToFile(file: File): Boolean =
@@ -222,6 +260,7 @@ data class CrashedHistory(
222
260
 
223
261
  fun clear() {
224
262
  bundles.clear()
263
+ interruptedLaunch = null
225
264
  }
226
265
  }
227
266
 
@@ -1,6 +1,7 @@
1
1
  package com.hotupdater
2
2
 
3
3
  import android.app.ActivityOptions
4
+ import android.app.Application
4
5
  import android.content.Context
5
6
  import android.content.Intent
6
7
  import android.os.Process
@@ -16,6 +17,7 @@ class HotUpdaterImpl {
16
17
  private val bundleStorage: BundleStorageService
17
18
  private val preferences: PreferencesService
18
19
  private val recoveryManager: HotUpdaterRecoveryManager
20
+ private val activityStartGate: ActivityStartGate
19
21
  private var currentLaunchSelection: LaunchSelection? = null
20
22
 
21
23
  /**
@@ -29,6 +31,7 @@ class HotUpdaterImpl {
29
31
  this.context = context.applicationContext
30
32
  this.bundleStorage = bundleStorage
31
33
  this.preferences = preferences
34
+ this.activityStartGate = ActivityStartGate(this.context as? Application)
32
35
  this.recoveryManager = HotUpdaterRecoveryManager(this.context)
33
36
  }
34
37
 
@@ -439,6 +442,16 @@ class HotUpdaterImpl {
439
442
 
440
443
  val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
441
444
  val selection = bundleStorage.prepareLaunch(pendingRecovery)
445
+ // A headless JS task, such as a background push, loads the bundle without
446
+ // an activity and never renders. Only a launch with an activity may leave
447
+ // an unfinished launch for the next process to roll back.
448
+ activityStartGate.runWhenActivityStarted(
449
+ if (selection.shouldRollbackOnCrash) {
450
+ { bundleStorage.markLaunchStarted(selection.launchedBundleId) }
451
+ } else {
452
+ null
453
+ },
454
+ )
442
455
  recoveryManager.startMonitoring(
443
456
  bundleId = selection.launchedBundleId,
444
457
  shouldRollback = selection.shouldRollbackOnCrash,
@@ -0,0 +1,118 @@
1
+ package com.hotupdater
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Test
7
+
8
+ class ActivityStartGateTest {
9
+ private val activity = Activity()
10
+
11
+ @Test
12
+ fun `headless launch waits for the first activity start`() {
13
+ val application = RecordingApplication()
14
+ val gate = createGate(application)
15
+ var runs = 0
16
+
17
+ gate.runWhenActivityStarted { runs += 1 }
18
+ assertEquals(0, runs)
19
+
20
+ application.startActivity()
21
+ application.startActivity()
22
+ assertEquals(1, runs)
23
+ }
24
+
25
+ @Test
26
+ fun `launch with a started activity runs at once`() {
27
+ val application = RecordingApplication()
28
+ val gate = createGate(application)
29
+ var runs = 0
30
+
31
+ application.startActivity()
32
+ gate.runWhenActivityStarted { runs += 1 }
33
+ assertEquals(1, runs)
34
+ }
35
+
36
+ @Test
37
+ fun `stopped activity waits for the next start`() {
38
+ val application = RecordingApplication()
39
+ val gate = createGate(application)
40
+ var runs = 0
41
+
42
+ application.startActivity()
43
+ application.stopActivity()
44
+ gate.runWhenActivityStarted { runs += 1 }
45
+ assertEquals(0, runs)
46
+
47
+ application.startActivity()
48
+ assertEquals(1, runs)
49
+ }
50
+
51
+ @Test
52
+ fun `stop of an activity started before the gate keeps later starts counted`() {
53
+ val application = RecordingApplication()
54
+ val gate = createGate(application)
55
+ var runs = 0
56
+
57
+ application.stopActivity()
58
+ application.startActivity()
59
+ gate.runWhenActivityStarted { runs += 1 }
60
+
61
+ assertEquals(1, runs)
62
+ }
63
+
64
+ @Test
65
+ fun `next launch replaces or cancels the waiting one`() {
66
+ val application = RecordingApplication()
67
+ val gate = createGate(application)
68
+ val runs = mutableListOf<String>()
69
+
70
+ gate.runWhenActivityStarted { runs += "first" }
71
+ gate.runWhenActivityStarted { runs += "reload" }
72
+ application.startActivity()
73
+ application.stopActivity()
74
+ gate.runWhenActivityStarted { runs += "verified" }
75
+ gate.runWhenActivityStarted(null)
76
+ application.startActivity()
77
+
78
+ assertEquals(listOf("reload"), runs)
79
+ }
80
+
81
+ @Test
82
+ fun `activity started before the gate runs at once`() {
83
+ // A brownfield app adds React Native to an activity already on screen,
84
+ // and Android does not replay that start to a late registration.
85
+ val application = RecordingApplication()
86
+ var runs = 0
87
+
88
+ createGate(application, startedBefore = true).runWhenActivityStarted { runs += 1 }
89
+
90
+ assertEquals(1, runs)
91
+ }
92
+
93
+ @Test
94
+ fun `runs at once without an application to observe`() {
95
+ var runs = 0
96
+
97
+ createGate(null).runWhenActivityStarted { runs += 1 }
98
+
99
+ assertEquals(1, runs)
100
+ }
101
+
102
+ private fun createGate(
103
+ application: Application?,
104
+ startedBefore: Boolean = false,
105
+ ) = ActivityStartGate(application) { startedBefore }
106
+
107
+ private inner class RecordingApplication : Application() {
108
+ private val callbacks = mutableListOf<ActivityLifecycleCallbacks>()
109
+
110
+ override fun registerActivityLifecycleCallbacks(callback: ActivityLifecycleCallbacks) {
111
+ callbacks += callback
112
+ }
113
+
114
+ fun startActivity() = callbacks.forEach { it.onActivityStarted(activity) }
115
+
116
+ fun stopActivity() = callbacks.forEach { it.onActivityStopped(activity) }
117
+ }
118
+ }
@@ -68,6 +68,7 @@ class BundleFileStorageServiceTest {
68
68
  val firstLaunch = firstProcess.prepareLaunch(null)
69
69
  assertEquals("hung-bundle", firstLaunch.launchedBundleId)
70
70
  assertTrue(firstLaunch.shouldRollbackOnCrash)
71
+ firstProcess.markLaunchStarted("hung-bundle")
71
72
 
72
73
  assertEquals("hung-bundle", firstProcess.prepareLaunch(null).launchedBundleId)
73
74
  assertFalse(firstProcess.getCrashHistory().contains("hung-bundle"))
@@ -88,6 +89,224 @@ class BundleFileStorageServiceTest {
88
89
  assertEquals(nextLaunch.launchedBundleId, thirdProcess.prepareLaunch(null).launchedBundleId)
89
90
  }
90
91
 
92
+ @Test
93
+ fun `headless staging launch stays pending on the next cold start`() {
94
+ val rootDir = temporaryFolder.newFolder()
95
+ val preferences = InMemoryPreferencesService()
96
+ listOf("stable-bundle", "staged-bundle").forEach { bundleId ->
97
+ val directory = createBundleDir(rootDir, bundleId)
98
+ writeFile(directory, "index.android.bundle")
99
+ writeManifest(directory, listOf("index.android.bundle"))
100
+ }
101
+ writeMetadata(
102
+ rootDir,
103
+ BundleMetadata(
104
+ isolationKey = TEST_ISOLATION_KEY,
105
+ stableBundleId = "stable-bundle",
106
+ stagingBundleId = "staged-bundle",
107
+ verificationPending = true,
108
+ ),
109
+ )
110
+ // Issue #1468: a headless JS task (a background FCM message) prepares the
111
+ // launch without an activity, so first content never appears.
112
+ val headlessLaunch = createService(rootDir, preferences).prepareLaunch(null)
113
+ assertEquals("staged-bundle", headlessLaunch.launchedBundleId)
114
+ assertTrue(headlessLaunch.shouldRollbackOnCrash)
115
+
116
+ // The OS reclaims the cached process, then the user opens the app.
117
+ val nextProcess = createService(rootDir, preferences)
118
+ val nextLaunch = nextProcess.prepareLaunch(null)
119
+ assertEquals("staged-bundle", nextLaunch.launchedBundleId)
120
+ assertTrue(nextLaunch.shouldRollbackOnCrash)
121
+ assertFalse(nextProcess.getCrashHistory().contains("staged-bundle"))
122
+ assertEquals("STABLE", nextProcess.notifyAppReady()["status"])
123
+ }
124
+
125
+ @Test
126
+ fun `launch start records only the pending staged bundle`() {
127
+ val rootDir = temporaryFolder.newFolder()
128
+ val preferences = InMemoryPreferencesService()
129
+ listOf("stable-bundle", "staged-bundle").forEach { bundleId ->
130
+ val directory = createBundleDir(rootDir, bundleId)
131
+ writeFile(directory, "index.android.bundle")
132
+ writeManifest(directory, listOf("index.android.bundle"))
133
+ }
134
+ writeMetadata(
135
+ rootDir,
136
+ BundleMetadata(
137
+ isolationKey = TEST_ISOLATION_KEY,
138
+ stableBundleId = "stable-bundle",
139
+ stagingBundleId = "staged-bundle",
140
+ verificationPending = true,
141
+ ),
142
+ )
143
+ val process = createService(rootDir, preferences)
144
+ process.prepareLaunch(null)
145
+
146
+ // The activity can start after JS staged another bundle, or after first
147
+ // content already verified this one.
148
+ process.markLaunchStarted("stable-bundle")
149
+ assertFalse(loadMetadata(rootDir)!!.launchInProgress)
150
+ process.markLaunchCompleted("staged-bundle")
151
+ process.markLaunchStarted("staged-bundle")
152
+ assertFalse(loadMetadata(rootDir)!!.launchInProgress)
153
+
154
+ val nextLaunch = createService(rootDir, preferences).prepareLaunch(null)
155
+ assertEquals("staged-bundle", nextLaunch.launchedBundleId)
156
+ assertFalse(nextLaunch.shouldRollbackOnCrash)
157
+ }
158
+
159
+ // Issue #1469: a launch that ends before first content without a crash marker
160
+ // may be a user leaving early rather than a hang.
161
+ @Test
162
+ fun `unfinished launch retries the bundle once before crash history`() =
163
+ runBlocking {
164
+ val rootDir = temporaryFolder.newFolder()
165
+ val preferences = InMemoryPreferencesService()
166
+ leaveUnfinishedLaunch(rootDir, preferences)
167
+
168
+ // The next process still rolls back at once and reports RECOVERED.
169
+ val recovered = createService(rootDir, preferences)
170
+ assertEquals("stable-bundle", recovered.prepareLaunch(null).launchedBundleId)
171
+ assertEquals("RECOVERED", recovered.notifyAppReady()["status"])
172
+ assertFalse(loadCrashedHistory(rootDir).contains("retried-bundle"))
173
+ assertEquals(InterruptedLaunch("retried-bundle"), loadCrashedHistory(rootDir).interruptedLaunch)
174
+ // The session that recovered refuses the bundle, even after first content.
175
+ recovered.markLaunchCompleted("stable-bundle")
176
+ assertTrue(recovered.getCrashHistory().contains("retried-bundle"))
177
+ assertEquals("BUNDLE_IN_CRASHED_HISTORY", updateError(recovered, "retried-bundle")?.code)
178
+
179
+ // A later process may install it again.
180
+ val retrying = createService(rootDir, preferences)
181
+ assertEquals("stable-bundle", retrying.prepareLaunch(null).launchedBundleId)
182
+ assertFalse(retrying.getCrashHistory().contains("retried-bundle"))
183
+ stageRetriedBundle(rootDir)
184
+
185
+ // The retry ends before first content too: now crash history keeps it.
186
+ val retryLaunch = createService(rootDir, preferences)
187
+ assertEquals("retried-bundle", retryLaunch.prepareLaunch(null).launchedBundleId)
188
+ retryLaunch.markLaunchStarted("retried-bundle")
189
+ val next = createService(rootDir, preferences)
190
+ assertEquals("stable-bundle", next.prepareLaunch(null).launchedBundleId)
191
+ assertEquals("RECOVERED", next.notifyAppReady()["status"])
192
+ assertTrue(loadCrashedHistory(rootDir).contains("retried-bundle"))
193
+ assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
194
+ next.markLaunchCompleted("stable-bundle")
195
+ val later = createService(rootDir, preferences)
196
+ later.prepareLaunch(null)
197
+ assertEquals("BUNDLE_IN_CRASHED_HISTORY", updateError(later, "retried-bundle")?.code)
198
+ }
199
+
200
+ @Test
201
+ fun `retried bundle that reaches first content is verified`() {
202
+ val rootDir = temporaryFolder.newFolder()
203
+ val preferences = InMemoryPreferencesService()
204
+ leaveUnfinishedLaunch(rootDir, preferences)
205
+ val recovered = createService(rootDir, preferences)
206
+ recovered.prepareLaunch(null)
207
+ recovered.markLaunchCompleted("stable-bundle")
208
+ createService(rootDir, preferences).prepareLaunch(null)
209
+ stageRetriedBundle(rootDir)
210
+
211
+ val retryLaunch = createService(rootDir, preferences)
212
+ assertEquals("retried-bundle", retryLaunch.prepareLaunch(null).launchedBundleId)
213
+ retryLaunch.markLaunchCompleted("retried-bundle")
214
+
215
+ assertTrue(loadCrashedHistory(rootDir).bundles.isEmpty())
216
+ assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
217
+ val next = createService(rootDir, preferences).prepareLaunch(null)
218
+ assertEquals("retried-bundle", next.launchedBundleId)
219
+ assertFalse(next.shouldRollbackOnCrash)
220
+ }
221
+
222
+ @Test
223
+ fun `retry waits for a session that showed content`() {
224
+ val rootDir = temporaryFolder.newFolder()
225
+ val preferences = InMemoryPreferencesService()
226
+ leaveUnfinishedLaunch(rootDir, preferences)
227
+
228
+ // A headless process consumes the unfinished launch and never renders.
229
+ val headless = createService(rootDir, preferences)
230
+ assertEquals("stable-bundle", headless.prepareLaunch(null).launchedBundleId)
231
+ assertTrue(headless.getCrashHistory().contains("retried-bundle"))
232
+
233
+ // The user's next open is the first session that recovered.
234
+ val opened = createService(rootDir, preferences)
235
+ opened.prepareLaunch(null)
236
+ assertTrue(opened.getCrashHistory().contains("retried-bundle"))
237
+ opened.markLaunchCompleted("stable-bundle")
238
+ assertTrue(opened.getCrashHistory().contains("retried-bundle"))
239
+
240
+ val later = createService(rootDir, preferences)
241
+ later.prepareLaunch(null)
242
+ assertFalse(later.getCrashHistory().contains("retried-bundle"))
243
+ }
244
+
245
+ @Test
246
+ fun `retry readies after content from the built-in bundle`() {
247
+ val rootDir = temporaryFolder.newFolder()
248
+ val preferences = InMemoryPreferencesService()
249
+ leaveUnfinishedLaunch(rootDir, preferences, stableBundleId = null)
250
+ val recovered = createService(rootDir, preferences)
251
+ recovered.prepareLaunch(null)
252
+
253
+ // First content of the built-in bundle reports no bundle ID.
254
+ recovered.markLaunchCompleted(null)
255
+
256
+ val later = createService(rootDir, preferences)
257
+ later.prepareLaunch(null)
258
+ assertFalse(later.getCrashHistory().contains("retried-bundle"))
259
+ }
260
+
261
+ @Test
262
+ fun `crash of a retried bundle adds it to crash history`() {
263
+ val rootDir = temporaryFolder.newFolder()
264
+ val preferences = InMemoryPreferencesService()
265
+ leaveUnfinishedLaunch(rootDir, preferences)
266
+ val recovered = createService(rootDir, preferences)
267
+ recovered.prepareLaunch(null)
268
+ recovered.markLaunchCompleted("stable-bundle")
269
+ createService(rootDir, preferences).prepareLaunch(null)
270
+ stageRetriedBundle(rootDir)
271
+ assertEquals("retried-bundle", createService(rootDir, preferences).prepareLaunch(null).launchedBundleId)
272
+
273
+ // That launch crashed before first content and left a crash marker.
274
+ val next =
275
+ createService(rootDir, preferences)
276
+ .prepareLaunch(PendingCrashRecovery(launchedBundleId = "retried-bundle", shouldRollback = true))
277
+
278
+ assertEquals("stable-bundle", next.launchedBundleId)
279
+ assertTrue(loadCrashedHistory(rootDir).contains("retried-bundle"))
280
+ assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
281
+ }
282
+
283
+ @Test
284
+ fun `clearing crash history drops a waiting retry`() {
285
+ val rootDir = temporaryFolder.newFolder()
286
+ val preferences = InMemoryPreferencesService()
287
+ leaveUnfinishedLaunch(rootDir, preferences)
288
+ val recovered = createService(rootDir, preferences)
289
+ recovered.prepareLaunch(null)
290
+
291
+ assertTrue(recovered.clearCrashHistory())
292
+
293
+ assertFalse(recovered.getCrashHistory().contains("retried-bundle"))
294
+ assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
295
+ }
296
+
297
+ @Test
298
+ fun `malformed retry record keeps crash history`() {
299
+ val rootDir = temporaryFolder.newFolder()
300
+ File(bundleStoreDir(rootDir), CrashedHistory.CRASHED_HISTORY_FILENAME).writeText(
301
+ """{"bundles":[{"bundleId":"crashed-bundle","crashedAt":1}],"maxHistorySize":10,"interruptedLaunch":{}}""",
302
+ )
303
+
304
+ val history = createService(rootDir).getCrashHistory()
305
+
306
+ assertEquals(listOf("crashed-bundle"), history.bundles.map { it.bundleId })
307
+ assertNull(history.interruptedLaunch)
308
+ }
309
+
91
310
  @Test
92
311
  fun `resolveBundleFile uses single manifest bundle at root`() {
93
312
  val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
@@ -458,6 +677,60 @@ class BundleFileStorageServiceTest {
458
677
  TEST_ISOLATION_KEY,
459
678
  )
460
679
 
680
+ private fun loadCrashedHistory(rootDir: File): CrashedHistory =
681
+ CrashedHistory.loadFromFile(File(bundleStoreDir(rootDir), CrashedHistory.CRASHED_HISTORY_FILENAME))
682
+
683
+ // Stages retried-bundle over stable-bundle, or over the built-in bundle, and
684
+ // leaves a launch of it that ended before first content.
685
+ private fun leaveUnfinishedLaunch(
686
+ rootDir: File,
687
+ preferences: InMemoryPreferencesService,
688
+ stableBundleId: String? = "stable-bundle",
689
+ ) {
690
+ listOfNotNull(stableBundleId, "retried-bundle").forEach { bundleId ->
691
+ val directory = createBundleDir(rootDir, bundleId)
692
+ writeFile(directory, "index.android.bundle")
693
+ writeManifest(directory, listOf("index.android.bundle"))
694
+ }
695
+ writeMetadata(
696
+ rootDir,
697
+ BundleMetadata(
698
+ isolationKey = TEST_ISOLATION_KEY,
699
+ stableBundleId = stableBundleId,
700
+ stagingBundleId = "retried-bundle",
701
+ verificationPending = true,
702
+ ),
703
+ )
704
+ val launch = createService(rootDir, preferences)
705
+ assertEquals("retried-bundle", launch.prepareLaunch(null).launchedBundleId)
706
+ launch.markLaunchStarted("retried-bundle")
707
+ }
708
+
709
+ // What an install of retried-bundle over the recovered stable bundle leaves.
710
+ private fun stageRetriedBundle(rootDir: File) {
711
+ val directory = createBundleDir(rootDir, "retried-bundle")
712
+ writeFile(directory, "index.android.bundle")
713
+ writeManifest(directory, listOf("index.android.bundle"))
714
+ writeMetadata(
715
+ rootDir,
716
+ BundleMetadata(
717
+ isolationKey = TEST_ISOLATION_KEY,
718
+ stableBundleId = "stable-bundle",
719
+ stagingBundleId = "retried-bundle",
720
+ verificationPending = true,
721
+ ),
722
+ )
723
+ }
724
+
725
+ // The crash history check runs before any download.
726
+ private suspend fun updateError(
727
+ service: BundleFileStorageService,
728
+ bundleId: String,
729
+ ): HotUpdaterException? =
730
+ runCatching {
731
+ service.updateBundle(bundleId, "https://example.com/$bundleId.zip", null, null, null, null) {}
732
+ }.exceptionOrNull() as? HotUpdaterException
733
+
461
734
  private fun writeFile(
462
735
  rootDir: File,
463
736
  relativePath: String,
@@ -185,6 +185,8 @@ class HotUpdaterImplTest {
185
185
  progressCallback: (UpdateProgressPayload) -> Unit,
186
186
  ) = Unit
187
187
 
188
+ override fun markLaunchStarted(launchedBundleId: String?) = Unit
189
+
188
190
  override fun markLaunchCompleted(currentBundleId: String?) = Unit
189
191
 
190
192
  override fun notifyAppReady(): Map<String, Any?> = mapOf("status" to "STABLE")
@@ -277,6 +277,7 @@ public protocol BundleStorageService {
277
277
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
278
278
 
279
279
  // Rollback support
280
+ func markLaunchStarted(bundleId: String?)
280
281
  func markLaunchCompleted(bundleId: String?)
281
282
  func notifyAppReady() -> [String: Any]
282
283
  func getCrashHistory() -> CrashedHistory
@@ -351,6 +352,13 @@ class BundleFileStorageService: BundleStorageService {
351
352
 
352
353
  private var hasPreparedLaunch = false
353
354
  private var currentLaunchReport: LaunchReport?
355
+ // Crash history is written at first content on the main thread while JavaScript can read it.
356
+ private let crashedHistoryLock = NSLock()
357
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
358
+ private var retryHeldBundleId: String?
359
+ // markLaunchStarted can run when the app enters the foreground while JavaScript
360
+ // stages a newer bundle; both read and rewrite metadata.
361
+ private let stagingMetadataLock = NSLock()
354
362
  private let activeBundleMetadataLock = NSLock()
355
363
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
356
364
 
@@ -1128,15 +1136,15 @@ class BundleFileStorageService: BundleStorageService {
1128
1136
  )
1129
1137
  }
1130
1138
 
1139
+ /// Rolls back the pending staging bundle. `unfinishedLaunch` means its launch
1140
+ /// ended before first content without a crash marker.
1131
1141
  @discardableResult
1132
- private func rollbackPendingBundle(_ stagingId: String) -> Bool {
1142
+ private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
1133
1143
  guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
1134
1144
  return false
1135
1145
  }
1136
1146
 
1137
- var crashedHistory = loadCrashedHistory()
1138
- crashedHistory.addEntry(stagingId)
1139
- let _ = saveCrashedHistory(crashedHistory)
1147
+ recordFailedLaunch(stagingId, unfinishedLaunch: unfinishedLaunch)
1140
1148
 
1141
1149
  let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
1142
1150
  if case .success(let storeDir) = bundleStoreDir() {
@@ -1617,15 +1625,11 @@ class BundleFileStorageService: BundleStorageService {
1617
1625
  metadata.verificationPending,
1618
1626
  metadata.launchInProgress,
1619
1627
  let stagingBundleId = metadata.stagingBundleId {
1620
- rollbackPendingBundle(stagingBundleId)
1628
+ rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
1621
1629
  }
1622
1630
  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
1631
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1632
+ return selectLaunch(bundle: bundle)
1629
1633
  }
1630
1634
 
1631
1635
  // MARK: - Bundle Update
@@ -1640,8 +1644,7 @@ class BundleFileStorageService: BundleStorageService {
1640
1644
  */
1641
1645
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
1642
1646
  // Check if bundle is in crashed history
1643
- let crashedHistory = loadCrashedHistory()
1644
- if crashedHistory.contains(bundleId) {
1647
+ if getCrashHistory().contains(bundleId) {
1645
1648
  NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
1646
1649
  completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
1647
1650
  return
@@ -1699,9 +1702,7 @@ class BundleFileStorageService: BundleStorageService {
1699
1702
  let setResult = self.setBundleURL(localPath: bundlePath)
1700
1703
  switch setResult {
1701
1704
  case .success:
1702
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
1703
- let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
1704
- let _ = self.saveMetadata(updatedMetadata)
1705
+ let updatedMetadata = self.saveNewStagingMetadata(bundleId)
1705
1706
  NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
1706
1707
 
1707
1708
  self.emitArchiveProgress(
@@ -2131,9 +2132,7 @@ class BundleFileStorageService: BundleStorageService {
2131
2132
  )
2132
2133
  switch self.setBundleURL(localPath: finalBundlePath) {
2133
2134
  case .success:
2134
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2135
- let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
2136
- let _ = self.saveMetadata(updatedMetadata)
2135
+ let updatedMetadata = self.saveNewStagingMetadata(bundleId)
2137
2136
  self.cleanupTemporaryFiles([tempDirectory])
2138
2137
  self.scheduleCleanupOldBundles(
2139
2138
  bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
@@ -2469,9 +2468,7 @@ class BundleFileStorageService: BundleStorageService {
2469
2468
  NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
2470
2469
 
2471
2470
  // 13) Set staging metadata for rollback support
2472
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2473
- let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
2474
- let _ = self.saveMetadata(updatedMetadata)
2471
+ let updatedMetadata = self.saveNewStagingMetadata(bundleId)
2475
2472
  NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
2476
2473
 
2477
2474
  // 14) Clean up the temporary directory
@@ -2542,7 +2539,32 @@ class BundleFileStorageService: BundleStorageService {
2542
2539
  /**
2543
2540
  * Marks the current launch as successful after the first content appeared.
2544
2541
  */
2542
+ private func saveNewStagingMetadata(_ bundleId: String) -> BundleMetadata {
2543
+ stagingMetadataLock.lock()
2544
+ defer { stagingMetadataLock.unlock() }
2545
+ let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
2546
+ let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
2547
+ _ = saveMetadata(updatedMetadata)
2548
+ return updatedMetadata
2549
+ }
2550
+
2551
+ func markLaunchStarted(bundleId: String?) {
2552
+ stagingMetadataLock.lock()
2553
+ defer { stagingMetadataLock.unlock() }
2554
+ guard let bundleId,
2555
+ var metadata = loadMetadataOrNull(),
2556
+ metadata.verificationPending,
2557
+ !metadata.launchInProgress,
2558
+ metadata.stagingBundleId == bundleId else {
2559
+ return
2560
+ }
2561
+ metadata.launchInProgress = true
2562
+ _ = saveMetadata(metadata)
2563
+ }
2564
+
2545
2565
  func markLaunchCompleted(bundleId: String?) {
2566
+ // Before the guard: content from the built-in bundle (nil) also counts.
2567
+ readyInterruptedLaunchRetry(bundleId)
2546
2568
  guard let bundleId,
2547
2569
  var metadata = loadMetadataOrNull(),
2548
2570
  metadata.verificationPending,
@@ -2569,11 +2591,20 @@ class BundleFileStorageService: BundleStorageService {
2569
2591
  }
2570
2592
 
2571
2593
  /**
2572
- * Returns the crashed bundle history.
2594
+ * Returns the bundles this device refuses to install: crash history, plus a
2595
+ * bundle waiting for its retry until a process that may retry it.
2573
2596
  * @return The crashed history object
2574
2597
  */
2575
2598
  func getCrashHistory() -> CrashedHistory {
2576
- return loadCrashedHistory()
2599
+ crashedHistoryLock.lock()
2600
+ defer { crashedHistoryLock.unlock() }
2601
+ var history = loadCrashedHistory()
2602
+ if let interrupted = history.interruptedLaunch,
2603
+ !interrupted.retryReady || interrupted.bundleId == retryHeldBundleId,
2604
+ !history.contains(interrupted.bundleId) {
2605
+ history.bundles.append(CrashedBundleEntry(bundleId: interrupted.bundleId))
2606
+ }
2607
+ return history
2577
2608
  }
2578
2609
 
2579
2610
  /**
@@ -2581,11 +2612,57 @@ class BundleFileStorageService: BundleStorageService {
2581
2612
  * @return true if clearing was successful
2582
2613
  */
2583
2614
  func clearCrashHistory() -> Bool {
2615
+ crashedHistoryLock.lock()
2616
+ defer { crashedHistoryLock.unlock() }
2584
2617
  var history = loadCrashedHistory()
2585
2618
  history.clear()
2619
+ retryHeldBundleId = nil
2586
2620
  return saveCrashedHistory(history)
2587
2621
  }
2588
2622
 
2623
+ /// A crash, or a second unfinished launch of the same bundle, adds the bundle
2624
+ /// to crash history. A first unfinished launch cannot tell a hang from a user
2625
+ /// leaving early, so it leaves the bundle one retry instead.
2626
+ private func recordFailedLaunch(_ bundleId: String, unfinishedLaunch: Bool) {
2627
+ crashedHistoryLock.lock()
2628
+ defer { crashedHistoryLock.unlock() }
2629
+ var history = loadCrashedHistory()
2630
+ let interrupted = history.interruptedLaunch
2631
+ if unfinishedLaunch, interrupted?.bundleId != bundleId {
2632
+ NSLog("[BundleStorage] Launch of \(bundleId) ended before first content; it gets one retry")
2633
+ history.interruptedLaunch = InterruptedLaunch(bundleId: bundleId)
2634
+ retryHeldBundleId = bundleId
2635
+ } else {
2636
+ history.addEntry(bundleId)
2637
+ if interrupted?.bundleId == bundleId {
2638
+ history.interruptedLaunch = nil
2639
+ }
2640
+ }
2641
+ _ = saveCrashedHistory(history)
2642
+ }
2643
+
2644
+ /// First content appeared, so the app works again. A bundle waiting for its
2645
+ /// retry may be installed from the next process on. This process keeps
2646
+ /// refusing it, so the session that recovered never reloads into it. Content
2647
+ /// from that bundle verifies it.
2648
+ private func readyInterruptedLaunchRetry(_ currentBundleId: String?) {
2649
+ crashedHistoryLock.lock()
2650
+ defer { crashedHistoryLock.unlock() }
2651
+ var history = loadCrashedHistory()
2652
+ guard let interrupted = history.interruptedLaunch else {
2653
+ return
2654
+ }
2655
+ if interrupted.bundleId == currentBundleId {
2656
+ history.interruptedLaunch = nil
2657
+ } else if interrupted.retryReady {
2658
+ return
2659
+ } else {
2660
+ history.interruptedLaunch?.retryReady = true
2661
+ retryHeldBundleId = interrupted.bundleId
2662
+ }
2663
+ _ = saveCrashedHistory(history)
2664
+ }
2665
+
2589
2666
  /**
2590
2667
  * Gets the base URL for the current active bundle directory
2591
2668
  * Returns the file:// URL to the bundle directory without trailing slash
@@ -120,6 +120,37 @@ public struct CrashedBundleEntry: Codable {
120
120
  }
121
121
  }
122
122
 
123
+ // MARK: - InterruptedLaunch
124
+
125
+ /// A bundle whose launch ended before first content without a crash, waiting
126
+ /// for its one retry. Such a launch cannot tell a hang from a user leaving
127
+ /// early, so the bundle is offered again instead of entering crash history.
128
+ /// A second unfinished launch of it adds it there.
129
+ ///
130
+ /// `retryReady` turns true once a later launch shows content. The retry starts
131
+ /// with the next process, so the session that recovered never reloads into the
132
+ /// same bundle.
133
+ public struct InterruptedLaunch: Codable {
134
+ let bundleId: String
135
+ var retryReady: Bool
136
+
137
+ enum CodingKeys: String, CodingKey {
138
+ case bundleId
139
+ case retryReady
140
+ }
141
+
142
+ init(bundleId: String, retryReady: Bool = false) {
143
+ self.bundleId = bundleId
144
+ self.retryReady = retryReady
145
+ }
146
+
147
+ public init(from decoder: Decoder) throws {
148
+ let values = try decoder.container(keyedBy: CodingKeys.self)
149
+ bundleId = try values.decode(String.self, forKey: .bundleId)
150
+ retryReady = try values.decodeIfPresent(Bool.self, forKey: .retryReady) ?? false
151
+ }
152
+ }
153
+
123
154
  // MARK: - CrashedHistory
124
155
 
125
156
  /// History of crashed bundles
@@ -129,10 +160,30 @@ public struct CrashedHistory: Codable {
129
160
 
130
161
  var bundles: [CrashedBundleEntry]
131
162
  var maxHistorySize: Int
163
+ var interruptedLaunch: InterruptedLaunch?
164
+
165
+ enum CodingKeys: String, CodingKey {
166
+ case bundles
167
+ case maxHistorySize
168
+ case interruptedLaunch
169
+ }
132
170
 
133
- init(bundles: [CrashedBundleEntry] = [], maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize) {
171
+ init(
172
+ bundles: [CrashedBundleEntry] = [],
173
+ maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize,
174
+ interruptedLaunch: InterruptedLaunch? = nil
175
+ ) {
134
176
  self.bundles = bundles
135
177
  self.maxHistorySize = maxHistorySize
178
+ self.interruptedLaunch = interruptedLaunch
179
+ }
180
+
181
+ public init(from decoder: Decoder) throws {
182
+ let values = try decoder.container(keyedBy: CodingKeys.self)
183
+ bundles = try values.decode([CrashedBundleEntry].self, forKey: .bundles)
184
+ maxHistorySize = try values.decode(Int.self, forKey: .maxHistorySize)
185
+ // A malformed retry record must not drop the crash history.
186
+ interruptedLaunch = try? values.decodeIfPresent(InterruptedLaunch.self, forKey: .interruptedLaunch)
136
187
  }
137
188
 
138
189
  static func load(from file: URL) -> CrashedHistory {
@@ -196,6 +247,7 @@ public struct CrashedHistory: Codable {
196
247
 
197
248
  mutating func clear() {
198
249
  bundles.removeAll()
250
+ interruptedLaunch = nil
199
251
  }
200
252
  }
201
253
 
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import React
3
+ import UIKit
3
4
 
4
5
  private func hotUpdaterUncaughtExceptionHandler(_ exception: NSException) {
5
6
  HotUpdaterRecoveryManager.shared.handleUncaughtException(exception)
@@ -35,6 +36,7 @@ private func hotUpdaterPerformRecoveryReload() -> Bool {
35
36
  private let cohortService: CohortService
36
37
  private let recoveryManager: HotUpdaterRecoveryManager
37
38
  private var currentLaunchSelection: LaunchSelection?
39
+ private var launchStartObservers: [NSObjectProtocol] = []
38
40
 
39
41
  private static let DEFAULT_CHANNEL = "production"
40
42
  private static let CHANNEL_STORAGE_KEY = "HotUpdaterChannel"
@@ -482,12 +484,49 @@ private func hotUpdaterPerformRecoveryReload() -> Bool {
482
484
 
483
485
  let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
484
486
  let selection = bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
487
+ markLaunchStartedInForeground(selection)
485
488
  recoveryManager.startMonitoring(bundleId: selection.launchedBundleId, shouldRollback: selection.shouldRollbackOnCrash) { [weak self] launchedBundleId in
486
489
  self?.bundleStorage.markLaunchCompleted(bundleId: launchedBundleId)
487
490
  }
488
491
  currentLaunchSelection = selection
489
492
  return selection
490
493
  }
494
+
495
+ // A background launch, such as a silent push, may never render. Only a
496
+ // launch in the foreground may leave an unfinished launch for the next
497
+ // process to roll back.
498
+ private func markLaunchStartedInForeground(_ selection: LaunchSelection) {
499
+ let bundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
500
+ let mark = { [weak self] in
501
+ guard let self else { return }
502
+ self.removeLaunchStartObservers()
503
+ guard let bundleId else { return }
504
+ guard UIApplication.shared.applicationState == .background else {
505
+ self.bundleStorage.markLaunchStarted(bundleId: bundleId)
506
+ return
507
+ }
508
+ self.launchStartObservers = [
509
+ UIApplication.willEnterForegroundNotification,
510
+ UIApplication.didBecomeActiveNotification,
511
+ ].map { name in
512
+ NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
513
+ self?.removeLaunchStartObservers()
514
+ self?.bundleStorage.markLaunchStarted(bundleId: bundleId)
515
+ }
516
+ }
517
+ }
518
+ // UIApplication is read on the main thread.
519
+ if Thread.isMainThread {
520
+ mark()
521
+ } else {
522
+ DispatchQueue.main.async(execute: mark)
523
+ }
524
+ }
525
+
526
+ private func removeLaunchStartObservers() {
527
+ launchStartObservers.forEach { NotificationCenter.default.removeObserver($0) }
528
+ launchStartObservers = []
529
+ }
491
530
  }
492
531
 
493
532
  @objcMembers
@@ -26,6 +26,32 @@ protocol DownloadService {
26
26
  enum DownloadError: Error {
27
27
  case incompleteDownload(expected: Int64, actual: Int64)
28
28
  case invalidContentLength
29
+
30
+ /// The size of the file a successful response leaves, or nil when the
31
+ /// response does not tell. A 206 answers a range: when the system resumes
32
+ /// an interrupted background download, `expectedContentLength` counts
33
+ /// only the resumed range while the file holds every byte, so the size is
34
+ /// the total in `Content-Range: bytes <first>-<last>/<total>`.
35
+ static func expectedFileSize(for response: URLResponse?) -> Int64? {
36
+ if let httpResponse = response as? HTTPURLResponse,
37
+ httpResponse.statusCode == 206 {
38
+ guard let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"),
39
+ let separator = contentRange.lastIndex(of: "/"),
40
+ let total = Int64(
41
+ contentRange[contentRange.index(after: separator)...]
42
+ .trimmingCharacters(in: .whitespaces)
43
+ ),
44
+ total > 0 else {
45
+ return nil
46
+ }
47
+ return total
48
+ }
49
+ guard let expectedContentLength = response?.expectedContentLength,
50
+ expectedContentLength > 0 else {
51
+ return nil
52
+ }
53
+ return expectedContentLength
54
+ }
29
55
  }
30
56
 
31
57
  // Task state for persistence and recovery
@@ -161,7 +187,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
161
187
  }
162
188
 
163
189
  // Verify file size
164
- let expectedSize = downloadTask.response?.expectedContentLength ?? -1
190
+ let expectedSize = DownloadError.expectedFileSize(for: downloadTask.response)
165
191
  let actualSize: Int64?
166
192
  do {
167
193
  let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
@@ -171,7 +197,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
171
197
  actualSize = nil
172
198
  }
173
199
 
174
- if expectedSize > 0, let actualSize = actualSize, actualSize != expectedSize {
200
+ if let expectedSize, let actualSize, actualSize != expectedSize {
175
201
  NSLog("[DownloadService] Download incomplete: \(actualSize) / \(expectedSize) bytes")
176
202
  // Delete incomplete file
177
203
  try? FileManager.default.removeItem(at: location)
@@ -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.17";
7
+ const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.19";
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.17";
3
+ export const HOT_UPDATER_SDK_VERSION = "0.36.19";
4
4
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.17";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.19";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "0.36.17";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "0.36.19";
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.17",
3
+ "version": "0.36.19",
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.17"
134
+ "hot-updater": "0.36.19"
135
135
  },
136
136
  "dependencies": {
137
137
  "use-sync-external-store": "1.5.0",
138
- "@hot-updater/cli-tools": "0.36.17",
139
- "@hot-updater/core": "0.36.17",
140
- "@hot-updater/plugin-core": "0.36.17",
141
- "@hot-updater/js": "0.36.17"
138
+ "@hot-updater/cli-tools": "0.36.19",
139
+ "@hot-updater/core": "0.36.19",
140
+ "@hot-updater/js": "0.36.19",
141
+ "@hot-updater/plugin-core": "0.36.19"
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.17";
1
+ export const HOT_UPDATER_SDK_VERSION = "0.36.19";