@hot-updater/react-native 1.0.0-rc.33 → 1.0.0-rc.35

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.
Files changed (54) hide show
  1. package/android/build.gradle +13 -2
  2. package/android/proguard-rules.pro +0 -9
  3. package/android/src/bridge/BridgeReactInstance.kt +38 -0
  4. package/android/src/bridgeless/BridgeReactInstance.kt +24 -0
  5. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +94 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +119 -16
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +35 -0
  8. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +14 -10
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +38 -138
  10. package/android/src/newarch/JavaScriptExceptionHooks.kt +48 -0
  11. package/android/src/newarch/ReactIntegrationManager.kt +6 -32
  12. package/android/src/oldarch/JavaScriptExceptionHooks.kt +71 -0
  13. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +93 -15
  14. package/ios/HotUpdater/Internal/BundleMetadata.swift +53 -1
  15. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +39 -0
  16. package/lib/commonjs/appReady.js +3 -12
  17. package/lib/commonjs/appReady.js.map +1 -1
  18. package/lib/commonjs/checkForUpdate.js +0 -4
  19. package/lib/commonjs/checkForUpdate.js.map +1 -1
  20. package/lib/commonjs/native.js +0 -4
  21. package/lib/commonjs/native.js.map +1 -1
  22. package/lib/commonjs/sdkVersion.js +1 -1
  23. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  24. package/lib/module/appReady.js +2 -10
  25. package/lib/module/appReady.js.map +1 -1
  26. package/lib/module/checkForUpdate.js +1 -5
  27. package/lib/module/checkForUpdate.js.map +1 -1
  28. package/lib/module/native.js +0 -4
  29. package/lib/module/native.js.map +1 -1
  30. package/lib/module/sdkVersion.js +1 -1
  31. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  32. package/lib/typescript/commonjs/appReady.d.ts +0 -2
  33. package/lib/typescript/commonjs/appReady.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/native.d.ts +0 -2
  36. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  38. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +0 -7
  39. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  40. package/lib/typescript/module/appReady.d.ts +0 -2
  41. package/lib/typescript/module/appReady.d.ts.map +1 -1
  42. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  43. package/lib/typescript/module/native.d.ts +0 -2
  44. package/lib/typescript/module/native.d.ts.map +1 -1
  45. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  46. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +0 -7
  47. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  48. package/package.json +3 -3
  49. package/src/appReady.ts +0 -8
  50. package/src/checkForUpdate.ts +1 -7
  51. package/src/native.ts +0 -8
  52. package/src/sdkVersion.ts +1 -1
  53. package/src/specs/NativeHotUpdater.ts +0 -7
  54. package/android/src/main/java/com/hotupdater/PreviousProcessExit.kt +0 -89
@@ -234,141 +234,11 @@ internal class HotUpdaterRecoveryManager(
234
234
  }
235
235
  }
236
236
 
237
+ // Each architecture's source set reaches its own React instance, so the old
238
+ // architecture's ReactNativeHost is compiled only into old architecture builds.
237
239
  private fun installJavaScriptExceptionHooks() {
238
240
  val application = appContext as? ReactApplication ?: return
239
-
240
- if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
241
- installReactHostExceptionHook(application)
242
- } else {
243
- installLegacyExceptionHook(application)
244
- }
245
- }
246
-
247
- private fun installReactHostExceptionHook(application: ReactApplication) {
248
- val reactHost = getReactHost(application) ?: return
249
- val reactHostDelegate =
250
- findField(reactHost.javaClass, "mReactHostDelegate")?.let { field ->
251
- field.isAccessible = true
252
- field.get(reactHost)
253
- }
254
- ?: findField(reactHost.javaClass, "reactHostDelegate")?.let { field ->
255
- field.isAccessible = true
256
- field.get(reactHost)
257
- }
258
- ?: return
259
-
260
- val delegateIdentity = System.identityHashCode(reactHostDelegate)
261
- if (patchedReactHostDelegateIds.add(delegateIdentity)) {
262
- val exceptionHandlerField = findField(reactHostDelegate.javaClass, "exceptionHandler")
263
- if (exceptionHandlerField == null) {
264
- patchedReactHostDelegateIds.remove(delegateIdentity)
265
- return
266
- }
267
-
268
- exceptionHandlerField.isAccessible = true
269
- @Suppress("UNCHECKED_CAST")
270
- val previousHandler =
271
- exceptionHandlerField.get(reactHostDelegate) as? (Exception) -> Unit
272
-
273
- exceptionHandlerField.set(reactHostDelegate) { exception: Exception ->
274
- if (activeManager?.handleJavaScriptException(exception) != true) {
275
- previousHandler?.invoke(exception) ?: throw exception
276
- }
277
- }
278
- }
279
-
280
- val reactContext =
281
- findMethod(reactHost.javaClass, "getCurrentReactContext")?.invoke(reactHost) as? ReactContext
282
- reactContext?.let { patchReactContextExceptionHandler(it) }
283
- }
284
-
285
- private fun installLegacyExceptionHook(application: ReactApplication) {
286
- val instanceManager = application.reactNativeHost.reactInstanceManager
287
- val managerIdentity = System.identityHashCode(instanceManager)
288
- if (patchedInstanceManagerIds.add(managerIdentity)) {
289
- val exceptionHandlerField = findField(instanceManager.javaClass, "mJSExceptionHandler")
290
- if (exceptionHandlerField == null) {
291
- patchedInstanceManagerIds.remove(managerIdentity)
292
- return
293
- }
294
-
295
- exceptionHandlerField.isAccessible = true
296
- val previousHandler = exceptionHandlerField.get(instanceManager) as? JSExceptionHandler
297
- exceptionHandlerField.set(instanceManager, RecoveryJSExceptionHandler(previousHandler))
298
- }
299
-
300
- instanceManager.currentReactContext?.let { reactContext ->
301
- patchReactContextExceptionHandler(reactContext)
302
- patchCatalystInstanceExceptionHandler(reactContext)
303
- }
304
- }
305
-
306
- private fun patchReactContextExceptionHandler(reactContext: ReactContext) {
307
- val contextIdentity = System.identityHashCode(reactContext)
308
- if (!patchedReactContextIds.add(contextIdentity)) {
309
- return
310
- }
311
-
312
- val previousHandler = reactContext.jsExceptionHandler
313
- if (previousHandler is RecoveryJSExceptionHandler) {
314
- return
315
- }
316
-
317
- reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
318
- }
319
-
320
- private fun patchCatalystInstanceExceptionHandler(reactContext: ReactContext) {
321
- val catalystInstance =
322
- try {
323
- reactContext.catalystInstance
324
- } catch (_: Exception) {
325
- null
326
- } ?: return
327
-
328
- val catalystIdentity = System.identityHashCode(catalystInstance)
329
- if (!patchedCatalystInstanceIds.add(catalystIdentity)) {
330
- return
331
- }
332
-
333
- val exceptionHandlerField = findField(catalystInstance.javaClass, "mJSExceptionHandler")
334
- if (exceptionHandlerField == null) {
335
- patchedCatalystInstanceIds.remove(catalystIdentity)
336
- return
337
- }
338
-
339
- exceptionHandlerField.isAccessible = true
340
- val previousHandler = exceptionHandlerField.get(catalystInstance) as? JSExceptionHandler
341
- if (previousHandler is RecoveryJSExceptionHandler) {
342
- return
343
- }
344
- exceptionHandlerField.set(catalystInstance, RecoveryJSExceptionHandler(previousHandler))
345
- }
346
-
347
- private fun getReactHost(application: ReactApplication): Any? =
348
- try {
349
- findMethod(application.javaClass, "getReactHost")?.invoke(application)
350
- } catch (_: Exception) {
351
- null
352
- }
353
-
354
- private fun findMethod(
355
- clazz: Class<*>,
356
- name: String,
357
- ) = runCatching { clazz.getMethod(name) }.getOrNull()
358
-
359
- private fun findField(
360
- clazz: Class<*>,
361
- name: String,
362
- ): Field? {
363
- var current: Class<*>? = clazz
364
- while (true) {
365
- val currentClass = current ?: break
366
- runCatching { currentClass.getDeclaredField(name) }
367
- .getOrNull()
368
- ?.let { return it }
369
- current = currentClass.superclass
370
- }
371
- return null
241
+ JavaScriptExceptionHooks.install(application)
372
242
  }
373
243
 
374
244
  private fun loadNativeLibrary(): Boolean {
@@ -395,11 +265,11 @@ internal class HotUpdaterRecoveryManager(
395
265
  shouldRollback: Boolean,
396
266
  )
397
267
 
398
- private class RecoveryJSExceptionHandler(
268
+ internal class RecoveryJSExceptionHandler(
399
269
  private val previousHandler: JSExceptionHandler?,
400
270
  ) : JSExceptionHandler {
401
271
  override fun handleException(e: Exception) {
402
- if (activeManager?.handleJavaScriptException(e) != true) {
272
+ if (!handleJavaScriptExceptionForRecovery(e)) {
403
273
  previousHandler?.handleException(e) ?: throw e
404
274
  }
405
275
  }
@@ -432,10 +302,40 @@ internal class HotUpdaterRecoveryManager(
432
302
  @Volatile
433
303
  private var activeManager: HotUpdaterRecoveryManager? = null
434
304
 
435
- private val patchedInstanceManagerIds = mutableSetOf<Int>()
436
- private val patchedCatalystInstanceIds = mutableSetOf<Int>()
437
305
  private val patchedReactContextIds = mutableSetOf<Int>()
438
- private val patchedReactHostDelegateIds = mutableSetOf<Int>()
306
+
307
+ /** Hands a JavaScript error to the monitored launch; false when it does not recover it. */
308
+ internal fun handleJavaScriptExceptionForRecovery(exception: Exception): Boolean =
309
+ activeManager?.handleJavaScriptException(exception) == true
310
+
311
+ internal fun patchReactContextExceptionHandler(reactContext: ReactContext) {
312
+ val contextIdentity = System.identityHashCode(reactContext)
313
+ if (!patchedReactContextIds.add(contextIdentity)) {
314
+ return
315
+ }
316
+
317
+ val previousHandler = reactContext.jsExceptionHandler
318
+ if (previousHandler is RecoveryJSExceptionHandler) {
319
+ return
320
+ }
321
+
322
+ reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
323
+ }
324
+
325
+ internal fun findField(
326
+ clazz: Class<*>,
327
+ name: String,
328
+ ): Field? {
329
+ var current: Class<*>? = clazz
330
+ while (true) {
331
+ val currentClass = current ?: break
332
+ runCatching { currentClass.getDeclaredField(name) }
333
+ .getOrNull()
334
+ ?.let { return it }
335
+ current = currentClass.superclass
336
+ }
337
+ return null
338
+ }
439
339
 
440
340
  @JvmStatic
441
341
  fun handleRecoveryWatchdog(context: Context) {
@@ -0,0 +1,48 @@
1
+ package com.hotupdater
2
+
3
+ import com.facebook.react.ReactApplication
4
+
5
+ /**
6
+ * Routes JavaScript errors of the running React instance to crash recovery.
7
+ * The New Architecture reaches the instance through ReactHost.
8
+ */
9
+ internal object JavaScriptExceptionHooks {
10
+ private val patchedReactHostDelegateIds = mutableSetOf<Int>()
11
+
12
+ fun install(application: ReactApplication) {
13
+ val reactHost = application.reactHost ?: return
14
+ val reactHostDelegate =
15
+ HotUpdaterRecoveryManager.findField(reactHost.javaClass, "mReactHostDelegate")?.let { field ->
16
+ field.isAccessible = true
17
+ field.get(reactHost)
18
+ }
19
+ ?: HotUpdaterRecoveryManager.findField(reactHost.javaClass, "reactHostDelegate")?.let { field ->
20
+ field.isAccessible = true
21
+ field.get(reactHost)
22
+ }
23
+ ?: return
24
+
25
+ val delegateIdentity = System.identityHashCode(reactHostDelegate)
26
+ if (patchedReactHostDelegateIds.add(delegateIdentity)) {
27
+ val exceptionHandlerField =
28
+ HotUpdaterRecoveryManager.findField(reactHostDelegate.javaClass, "exceptionHandler")
29
+ if (exceptionHandlerField == null) {
30
+ patchedReactHostDelegateIds.remove(delegateIdentity)
31
+ return
32
+ }
33
+
34
+ exceptionHandlerField.isAccessible = true
35
+ @Suppress("UNCHECKED_CAST")
36
+ val previousHandler =
37
+ exceptionHandlerField.get(reactHostDelegate) as? (Exception) -> Unit
38
+
39
+ exceptionHandlerField.set(reactHostDelegate) { exception: Exception ->
40
+ if (!HotUpdaterRecoveryManager.handleJavaScriptExceptionForRecovery(exception)) {
41
+ previousHandler?.invoke(exception) ?: throw exception
42
+ }
43
+ }
44
+ }
45
+
46
+ reactHost.currentReactContext?.let(HotUpdaterRecoveryManager::patchReactContextExceptionHandler)
47
+ }
48
+ }
@@ -5,12 +5,10 @@ import android.util.Log
5
5
  import com.facebook.react.ReactApplication
6
6
  import com.facebook.react.ReactHost
7
7
  import com.facebook.react.ReactInstanceEventListener
8
- import com.facebook.react.bridge.JSBundleLoader
9
8
  import com.facebook.react.bridge.ReactApplicationContext
10
9
  import com.facebook.react.bridge.ReactContext
11
10
  import com.facebook.react.common.LifecycleState
12
11
  import kotlinx.coroutines.suspendCancellableCoroutine
13
- import java.lang.reflect.Field
14
12
  import kotlin.coroutines.resume
15
13
 
16
14
  class ReactIntegrationManager(
@@ -89,7 +87,7 @@ class ReactIntegrationManager(
89
87
 
90
88
  /**
91
89
  * Sets the JS bundle.
92
- * Priority: ReactHostHolder (if set) > application.reactHost > reactNativeHost
90
+ * Priority: ReactHostHolder (if set) > application.reactHost > the bridge (React Native 0.81 and older)
93
91
  * @param bundleURL The bundle URL to set
94
92
  */
95
93
  public fun setJSBundle(bundleURL: String) {
@@ -115,19 +113,8 @@ class ReactIntegrationManager(
115
113
  return
116
114
  }
117
115
 
118
- // 4. Fallback to old architecture (reactNativeHost)
119
- @Suppress("DEPRECATION")
120
- val instanceManager = application.reactNativeHost.reactInstanceManager
121
- val bundleLoader: JSBundleLoader? = this.getJSBundlerLoader(bundleURL)
122
- val bundleLoaderField: Field =
123
- instanceManager::class.java.getDeclaredField("mBundleLoader")
124
- bundleLoaderField.isAccessible = true
125
-
126
- if (bundleLoader != null) {
127
- bundleLoaderField.set(instanceManager, bundleLoader)
128
- } else {
129
- bundleLoaderField.set(instanceManager, null)
130
- }
116
+ // 4. Fall back to the bridge, which React Native 0.81 and older can run
117
+ BridgeReactInstance.setJSBundle(application, getJSBundlerLoader(bundleURL))
131
118
  } catch (e: Exception) {
132
119
  Log.d("HotUpdater", "Failed to setJSBundle: ${e.message}")
133
120
  }
@@ -135,7 +122,7 @@ class ReactIntegrationManager(
135
122
 
136
123
  /**
137
124
  * Reload the React Native application.
138
- * Priority: ReactHostHolder (if set) > application.reactHost > reactNativeHost
125
+ * Priority: ReactHostHolder (if set) > application.reactHost > the bridge (React Native 0.81 and older)
139
126
  */
140
127
  public suspend fun reload() {
141
128
  try {
@@ -160,21 +147,8 @@ class ReactIntegrationManager(
160
147
  return
161
148
  }
162
149
 
163
- // 4. Fallback to old architecture (reactNativeHost)
164
- @Suppress("DEPRECATION")
165
- val reactNativeHost = application.reactNativeHost
166
- try {
167
- reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
168
- } catch (e: Exception) {
169
- val currentActivity = reactNativeHost.reactInstanceManager.currentReactContext?.currentActivity
170
- if (currentActivity == null) {
171
- return
172
- }
173
-
174
- currentActivity.runOnUiThread {
175
- currentActivity.recreate()
176
- }
177
- }
150
+ // 4. Fall back to the bridge, which React Native 0.81 and older can run
151
+ BridgeReactInstance.reload(application)
178
152
  } catch (e: Exception) {
179
153
  Log.d("HotUpdater", "Failed to reload: ${e.message}")
180
154
  }
@@ -0,0 +1,71 @@
1
+ package com.hotupdater
2
+
3
+ import com.facebook.react.ReactApplication
4
+ import com.facebook.react.bridge.JSExceptionHandler
5
+ import com.facebook.react.bridge.ReactContext
6
+
7
+ /**
8
+ * Routes JavaScript errors of the running React instance to crash recovery.
9
+ * The old architecture reaches the instance through ReactNativeHost, which only
10
+ * an old architecture build compiles.
11
+ */
12
+ internal object JavaScriptExceptionHooks {
13
+ private val patchedInstanceManagerIds = mutableSetOf<Int>()
14
+ private val patchedCatalystInstanceIds = mutableSetOf<Int>()
15
+
16
+ fun install(application: ReactApplication) {
17
+ val instanceManager = application.reactNativeHost.reactInstanceManager
18
+ val managerIdentity = System.identityHashCode(instanceManager)
19
+ if (patchedInstanceManagerIds.add(managerIdentity)) {
20
+ val exceptionHandlerField =
21
+ HotUpdaterRecoveryManager.findField(instanceManager.javaClass, "mJSExceptionHandler")
22
+ if (exceptionHandlerField == null) {
23
+ patchedInstanceManagerIds.remove(managerIdentity)
24
+ return
25
+ }
26
+
27
+ exceptionHandlerField.isAccessible = true
28
+ val previousHandler = exceptionHandlerField.get(instanceManager) as? JSExceptionHandler
29
+ exceptionHandlerField.set(
30
+ instanceManager,
31
+ HotUpdaterRecoveryManager.RecoveryJSExceptionHandler(previousHandler),
32
+ )
33
+ }
34
+
35
+ instanceManager.currentReactContext?.let { reactContext ->
36
+ HotUpdaterRecoveryManager.patchReactContextExceptionHandler(reactContext)
37
+ patchCatalystInstanceExceptionHandler(reactContext)
38
+ }
39
+ }
40
+
41
+ private fun patchCatalystInstanceExceptionHandler(reactContext: ReactContext) {
42
+ val catalystInstance =
43
+ try {
44
+ reactContext.catalystInstance
45
+ } catch (_: Exception) {
46
+ null
47
+ } ?: return
48
+
49
+ val catalystIdentity = System.identityHashCode(catalystInstance)
50
+ if (!patchedCatalystInstanceIds.add(catalystIdentity)) {
51
+ return
52
+ }
53
+
54
+ val exceptionHandlerField =
55
+ HotUpdaterRecoveryManager.findField(catalystInstance.javaClass, "mJSExceptionHandler")
56
+ if (exceptionHandlerField == null) {
57
+ patchedCatalystInstanceIds.remove(catalystIdentity)
58
+ return
59
+ }
60
+
61
+ exceptionHandlerField.isAccessible = true
62
+ val previousHandler = exceptionHandlerField.get(catalystInstance) as? JSExceptionHandler
63
+ if (previousHandler is HotUpdaterRecoveryManager.RecoveryJSExceptionHandler) {
64
+ return
65
+ }
66
+ exceptionHandlerField.set(
67
+ catalystInstance,
68
+ HotUpdaterRecoveryManager.RecoveryJSExceptionHandler(previousHandler),
69
+ )
70
+ }
71
+ }
@@ -293,6 +293,7 @@ public protocol BundleStorageService {
293
293
  func commitReleaseSelection(_ selection: PersistedSelection) -> Bool
294
294
 
295
295
  // Rollback support
296
+ func markLaunchStarted(bundleId: String?)
296
297
  func markLaunchCompleted(bundleId: String?)
297
298
  func notifyAppReady() -> [String: Any]
298
299
  func getCrashHistory() -> CrashedHistory
@@ -373,6 +374,13 @@ class BundleFileStorageService: BundleStorageService {
373
374
 
374
375
  private var hasPreparedLaunch = false
375
376
  private var currentLaunchReport: LaunchReport?
377
+ // The staged bundle this process launched before its first content. Only that
378
+ // launch is pending; a bundle staged later waits for the next launch.
379
+ private var pendingLaunchBundleId: String?
380
+ // Crash history is written at first content on the main thread while JavaScript can read it.
381
+ private let crashedHistoryLock = NSLock()
382
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
383
+ private var retryHeldBundleId: String?
376
384
  private let activeBundleMetadataLock = NSLock()
377
385
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
378
386
  private let builtInBundleIdProvider: () -> String
@@ -1308,8 +1316,10 @@ class BundleFileStorageService: BundleStorageService {
1308
1316
  )
1309
1317
  }
1310
1318
 
1319
+ /// Rolls back the pending staging bundle. `unfinishedLaunch` means its launch
1320
+ /// ended before first content without a crash marker.
1311
1321
  @discardableResult
1312
- private func rollbackPendingBundle(_ stagingId: String) -> Bool {
1322
+ private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
1313
1323
  guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
1314
1324
  return false
1315
1325
  }
@@ -1318,9 +1328,7 @@ class BundleFileStorageService: BundleStorageService {
1318
1328
  let pendingSelectionTransition = metadata.pendingSelectionTransition
1319
1329
  let stableSelection = metadata.stableSelection
1320
1330
 
1321
- var crashedHistory = loadCrashedHistory()
1322
- crashedHistory.addEntry(stagingId)
1323
- let _ = saveCrashedHistory(crashedHistory)
1331
+ recordFailedLaunch(stagingId, unfinishedLaunch: unfinishedLaunch)
1324
1332
 
1325
1333
  let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
1326
1334
  if case .success(let storeDir) = bundleStoreDir() {
@@ -1988,14 +1996,12 @@ class BundleFileStorageService: BundleStorageService {
1988
1996
  metadata.verificationPending,
1989
1997
  metadata.launchInProgress,
1990
1998
  let stagingBundleId = metadata.stagingBundleId {
1991
- rollbackPendingBundle(stagingBundleId)
1999
+ rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
1992
2000
  }
1993
2001
  hasPreparedLaunch = true
2002
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1994
2003
  let selection = selectLaunch(bundle: bundle)
1995
- if selection.shouldRollbackOnCrash, var metadata = loadMetadataOrNull() {
1996
- metadata.launchInProgress = true
1997
- _ = saveMetadata(metadata)
1998
- }
2004
+ pendingLaunchBundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
1999
2005
  return selection
2000
2006
  }
2001
2007
 
@@ -2011,8 +2017,7 @@ class BundleFileStorageService: BundleStorageService {
2011
2017
  */
2012
2018
  func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
2013
2019
  // Check if bundle is in crashed history
2014
- let crashedHistory = loadCrashedHistory()
2015
- if crashedHistory.contains(bundleId) {
2020
+ if getCrashHistory().contains(bundleId) {
2016
2021
  NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
2017
2022
  completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
2018
2023
  return
@@ -2786,7 +2791,24 @@ class BundleFileStorageService: BundleStorageService {
2786
2791
  }
2787
2792
  }
2788
2793
 
2794
+ func markLaunchStarted(bundleId: String?) {
2795
+ // Unlike prepareLaunch, this can run while JavaScript stages a newer bundle.
2796
+ releaseStateLock.lock()
2797
+ defer { releaseStateLock.unlock() }
2798
+ guard let bundleId,
2799
+ var metadata = loadMetadataOrNull(),
2800
+ metadata.verificationPending,
2801
+ !metadata.launchInProgress,
2802
+ metadata.stagingBundleId == bundleId else {
2803
+ return
2804
+ }
2805
+ metadata.launchInProgress = true
2806
+ _ = saveMetadata(metadata)
2807
+ }
2808
+
2789
2809
  func markLaunchCompleted(bundleId: String?) {
2810
+ // Before the guard: content from the built-in bundle (nil) also counts.
2811
+ readyInterruptedLaunchRetry(bundleId)
2790
2812
  guard let bundleId,
2791
2813
  var metadata = loadMetadataOrNull(),
2792
2814
  metadata.verificationPending,
@@ -2818,9 +2840,10 @@ class BundleFileStorageService: BundleStorageService {
2818
2840
 
2819
2841
  func notifyAppReady() -> [String: Any] {
2820
2842
  guard let report = loadLaunchReport() else {
2821
- if let metadata = loadMetadataOrNull(),
2843
+ if let launchedBundleId = pendingLaunchBundleId,
2844
+ let metadata = loadMetadataOrNull(),
2822
2845
  metadata.verificationPending,
2823
- metadata.stagingBundleId != nil {
2846
+ metadata.stagingBundleId == launchedBundleId {
2824
2847
  return ["status": "PENDING"]
2825
2848
  }
2826
2849
  return ["status": LaunchReportStatus.unchanged.rawValue]
@@ -2846,11 +2869,20 @@ class BundleFileStorageService: BundleStorageService {
2846
2869
  }
2847
2870
 
2848
2871
  /**
2849
- * Returns the crashed bundle history.
2872
+ * Returns the bundles this device refuses to install: crash history, plus a
2873
+ * bundle waiting for its retry until a process that may retry it.
2850
2874
  * @return The crashed history object
2851
2875
  */
2852
2876
  func getCrashHistory() -> CrashedHistory {
2853
- return loadCrashedHistory()
2877
+ crashedHistoryLock.lock()
2878
+ defer { crashedHistoryLock.unlock() }
2879
+ var history = loadCrashedHistory()
2880
+ if let interrupted = history.interruptedLaunch,
2881
+ !interrupted.retryReady || interrupted.bundleId == retryHeldBundleId,
2882
+ !history.contains(interrupted.bundleId) {
2883
+ history.bundles.append(CrashedBundleEntry(bundleId: interrupted.bundleId))
2884
+ }
2885
+ return history
2854
2886
  }
2855
2887
 
2856
2888
  /**
@@ -2858,11 +2890,57 @@ class BundleFileStorageService: BundleStorageService {
2858
2890
  * @return true if clearing was successful
2859
2891
  */
2860
2892
  func clearCrashHistory() -> Bool {
2893
+ crashedHistoryLock.lock()
2894
+ defer { crashedHistoryLock.unlock() }
2861
2895
  var history = loadCrashedHistory()
2862
2896
  history.clear()
2897
+ retryHeldBundleId = nil
2863
2898
  return saveCrashedHistory(history)
2864
2899
  }
2865
2900
 
2901
+ /// A crash, or a second unfinished launch of the same bundle, adds the bundle
2902
+ /// to crash history. A first unfinished launch cannot tell a hang from a user
2903
+ /// leaving early, so it leaves the bundle one retry instead.
2904
+ private func recordFailedLaunch(_ bundleId: String, unfinishedLaunch: Bool) {
2905
+ crashedHistoryLock.lock()
2906
+ defer { crashedHistoryLock.unlock() }
2907
+ var history = loadCrashedHistory()
2908
+ let interrupted = history.interruptedLaunch
2909
+ if unfinishedLaunch, interrupted?.bundleId != bundleId {
2910
+ NSLog("[BundleStorage] Launch of \(bundleId) ended before first content; it gets one retry")
2911
+ history.interruptedLaunch = InterruptedLaunch(bundleId: bundleId)
2912
+ retryHeldBundleId = bundleId
2913
+ } else {
2914
+ history.addEntry(bundleId)
2915
+ if interrupted?.bundleId == bundleId {
2916
+ history.interruptedLaunch = nil
2917
+ }
2918
+ }
2919
+ _ = saveCrashedHistory(history)
2920
+ }
2921
+
2922
+ /// First content appeared, so the app works again. A bundle waiting for its
2923
+ /// retry may be installed from the next process on. This process keeps
2924
+ /// refusing it, so the session that recovered never reloads into it. Content
2925
+ /// from that bundle verifies it.
2926
+ private func readyInterruptedLaunchRetry(_ currentBundleId: String?) {
2927
+ crashedHistoryLock.lock()
2928
+ defer { crashedHistoryLock.unlock() }
2929
+ var history = loadCrashedHistory()
2930
+ guard let interrupted = history.interruptedLaunch else {
2931
+ return
2932
+ }
2933
+ if interrupted.bundleId == currentBundleId {
2934
+ history.interruptedLaunch = nil
2935
+ } else if interrupted.retryReady {
2936
+ return
2937
+ } else {
2938
+ history.interruptedLaunch?.retryReady = true
2939
+ retryHeldBundleId = interrupted.bundleId
2940
+ }
2941
+ _ = saveCrashedHistory(history)
2942
+ }
2943
+
2866
2944
  func getInstallId() -> String {
2867
2945
  loadOrCreateInstallIdentity().installId
2868
2946
  }
@@ -222,6 +222,37 @@ public struct CrashedBundleEntry: Codable {
222
222
  }
223
223
  }
224
224
 
225
+ // MARK: - InterruptedLaunch
226
+
227
+ /// A bundle whose launch ended before first content without a crash, waiting
228
+ /// for its one retry. Such a launch cannot tell a hang from a user leaving
229
+ /// early, so the bundle is offered again instead of entering crash history.
230
+ /// A second unfinished launch of it adds it there.
231
+ ///
232
+ /// `retryReady` turns true once a later launch shows content. The retry starts
233
+ /// with the next process, so the session that recovered never reloads into the
234
+ /// same bundle.
235
+ public struct InterruptedLaunch: Codable {
236
+ let bundleId: String
237
+ var retryReady: Bool
238
+
239
+ enum CodingKeys: String, CodingKey {
240
+ case bundleId
241
+ case retryReady
242
+ }
243
+
244
+ init(bundleId: String, retryReady: Bool = false) {
245
+ self.bundleId = bundleId
246
+ self.retryReady = retryReady
247
+ }
248
+
249
+ public init(from decoder: Decoder) throws {
250
+ let values = try decoder.container(keyedBy: CodingKeys.self)
251
+ bundleId = try values.decode(String.self, forKey: .bundleId)
252
+ retryReady = try values.decodeIfPresent(Bool.self, forKey: .retryReady) ?? false
253
+ }
254
+ }
255
+
225
256
  // MARK: - CrashedHistory
226
257
 
227
258
  /// History of crashed bundles
@@ -231,10 +262,30 @@ public struct CrashedHistory: Codable {
231
262
 
232
263
  var bundles: [CrashedBundleEntry]
233
264
  var maxHistorySize: Int
265
+ var interruptedLaunch: InterruptedLaunch?
266
+
267
+ enum CodingKeys: String, CodingKey {
268
+ case bundles
269
+ case maxHistorySize
270
+ case interruptedLaunch
271
+ }
234
272
 
235
- init(bundles: [CrashedBundleEntry] = [], maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize) {
273
+ init(
274
+ bundles: [CrashedBundleEntry] = [],
275
+ maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize,
276
+ interruptedLaunch: InterruptedLaunch? = nil
277
+ ) {
236
278
  self.bundles = bundles
237
279
  self.maxHistorySize = maxHistorySize
280
+ self.interruptedLaunch = interruptedLaunch
281
+ }
282
+
283
+ public init(from decoder: Decoder) throws {
284
+ let values = try decoder.container(keyedBy: CodingKeys.self)
285
+ bundles = try values.decode([CrashedBundleEntry].self, forKey: .bundles)
286
+ maxHistorySize = try values.decode(Int.self, forKey: .maxHistorySize)
287
+ // A malformed retry record must not drop the crash history.
288
+ interruptedLaunch = try? values.decodeIfPresent(InterruptedLaunch.self, forKey: .interruptedLaunch)
238
289
  }
239
290
 
240
291
  static func load(from file: URL) -> CrashedHistory {
@@ -298,6 +349,7 @@ public struct CrashedHistory: Codable {
298
349
 
299
350
  mutating func clear() {
300
351
  bundles.removeAll()
352
+ interruptedLaunch = nil
301
353
  }
302
354
  }
303
355