@hot-updater/react-native 1.0.7 → 1.1.0

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 (144) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  143. package/android/src/newarch/ReactIntegrationManager.kt +0 -185
  144. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -3,18 +3,79 @@ import React
3
3
  import UIKit
4
4
 
5
5
  private func hotUpdaterUncaughtExceptionHandler(_ exception: NSException) {
6
- HotUpdaterRecoveryManager.shared.handleUncaughtException(exception)
6
+ HotUpdaterRecoveryHub.shared.handleUncaughtException(exception)
7
7
  }
8
8
 
9
- @_silgen_name("HotUpdaterInstallSignalHandlers")
10
- private func hotUpdaterInstallSignalHandlersSymbol(_ crashMarkerPath: NSString)
9
+ @_silgen_name("HotUpdaterInstallCrashSignalHandlers")
10
+ private func hotUpdaterInstallCrashSignalHandlers()
11
11
 
12
- @_silgen_name("HotUpdaterUpdateSignalLaunchState")
13
- private func hotUpdaterUpdateSignalLaunchStateSymbol(
12
+ @_silgen_name("HotUpdaterUpdateCrashSlot")
13
+ private func hotUpdaterUpdateCrashSlotSymbol(
14
+ _ slot: Int,
15
+ _ crashMarkerPath: NSString,
14
16
  _ bundleId: NSString?,
15
17
  _ shouldRollback: ObjCBool
16
18
  )
17
19
 
20
+ @_silgen_name("HotUpdaterAttributeCrashToSlot")
21
+ private func hotUpdaterAttributeCrashToSlot(_ slot: Int)
22
+
23
+ @_silgen_name("HotUpdaterCopyImplForRootObject")
24
+ private func hotUpdaterCopyImplForRootObjectSymbol(_ object: AnyObject) -> UnsafeMutableRawPointer?
25
+
26
+ @_silgen_name("HotUpdaterCopyRuntimeImpl")
27
+ private func hotUpdaterCopyRuntimeImplSymbol() -> UnsafeMutableRawPointer?
28
+
29
+ @_silgen_name("HotUpdaterCopyBundleURLForRootObject")
30
+ private func hotUpdaterCopyBundleURLForRootObjectSymbol(_ object: AnyObject) -> UnsafeMutableRawPointer?
31
+
32
+ @_silgen_name("HotUpdaterCopyRuntimeIdentityForRootObject")
33
+ private func hotUpdaterCopyRuntimeIdentityForRootObjectSymbol(_ object: AnyObject) -> UnsafeMutableRawPointer?
34
+
35
+ @_silgen_name("HotUpdaterCopyRuntimeIdentity")
36
+ private func hotUpdaterCopyRuntimeIdentitySymbol() -> UnsafeMutableRawPointer?
37
+
38
+ /// The object standing for the runtime `object` belongs to (its module
39
+ /// bridgeless, its bridge in the old architecture), when one told it apart.
40
+ private func hotUpdaterRuntimeIdentity(forRootObject object: AnyObject) -> AnyObject? {
41
+ hotUpdaterCopyRuntimeIdentityForRootObjectSymbol(object).map {
42
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue()
43
+ }
44
+ }
45
+
46
+ /// The object standing for the runtime whose JavaScript thread this is.
47
+ private func hotUpdaterRuntimeIdentity() -> AnyObject? {
48
+ hotUpdaterCopyRuntimeIdentitySymbol().map {
49
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue()
50
+ }
51
+ }
52
+
53
+ @_silgen_name("HotUpdaterCopyRuntimeBundleURL")
54
+ private func hotUpdaterCopyRuntimeBundleURLSymbol() -> UnsafeMutableRawPointer?
55
+
56
+ /// The bundle the runtime `object` belongs to runs.
57
+ private func hotUpdaterBundleURL(forRootObject object: AnyObject) -> URL? {
58
+ hotUpdaterCopyBundleURLForRootObjectSymbol(object).flatMap {
59
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue() as? URL
60
+ }
61
+ }
62
+
63
+ /// The bundle the runtime whose JavaScript thread this is runs.
64
+ private func hotUpdaterRuntimeBundleURL() -> URL? {
65
+ hotUpdaterCopyRuntimeBundleURLSymbol().flatMap {
66
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue() as? URL
67
+ }
68
+ }
69
+
70
+ @_silgen_name("HotUpdaterIsViewShown")
71
+ private func hotUpdaterIsViewShownSymbol(_ object: AnyObject) -> ObjCBool
72
+
73
+ /// Whether a started Fabric surface, or an old-architecture root view, is in
74
+ /// a window. Main thread only.
75
+ private func hotUpdaterIsViewShown(_ object: AnyObject) -> Bool {
76
+ hotUpdaterIsViewShownSymbol(object).boolValue
77
+ }
78
+
18
79
  @_silgen_name("HotUpdaterPerformRecoveryReload")
19
80
  private func hotUpdaterPerformRecoveryReloadSymbol() -> ObjCBool
20
81
 
@@ -33,12 +94,38 @@ private func hotUpdaterAfterSurfaceStarts(_ action: @convention(block) @escaping
33
94
  @_silgen_name("HotUpdaterCopyMinBundleId")
34
95
  private func hotUpdaterCopyMinBundleIdSymbol() -> UnsafeMutableRawPointer
35
96
 
36
- private func hotUpdaterInstallSignalHandlers(_ crashMarkerPath: String) {
37
- hotUpdaterInstallSignalHandlersSymbol(crashMarkerPath as NSString)
97
+ @_silgen_name("HotUpdaterCopyBuildFingerprintHash")
98
+ private func hotUpdaterCopyBuildFingerprintHashSymbol() -> UnsafeMutableRawPointer?
99
+
100
+ /// The fingerprint this build embedded: the hash of the native interface it
101
+ /// was built from, or nil for an app without one. Named apart from the
102
+ /// generated `hotUpdaterBuildFingerprintHash()`, which SwiftPM compiles into
103
+ /// this module.
104
+ private let embeddedFingerprintHash: String? = hotUpdaterCopyBuildFingerprintHashSymbol()
105
+ .map { Unmanaged<NSString>.fromOpaque($0).takeRetainedValue() as String }
106
+
107
+ extension HotUpdaterInstanceEnvironment {
108
+ /// Hands the shared config the fingerprint this build embedded, once,
109
+ /// before anything resolves an instance's values.
110
+ static let registerBuildFingerprintHash: Void = {
111
+ HotUpdaterConfig.shared.buildFingerprintHash = embeddedFingerprintHash
112
+ }()
113
+ }
114
+
115
+ /// The impl whose runtime `object` belongs to: a Fabric surface or root
116
+ /// view, an RCTInstance or RCTHost, an old-architecture root view or bridge.
117
+ /// Nil when no runtime claims it yet.
118
+ private func hotUpdaterImpl(forRootObject object: AnyObject) -> HotUpdaterImpl? {
119
+ hotUpdaterCopyImplForRootObjectSymbol(object).flatMap {
120
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue() as? HotUpdaterImpl
121
+ }
38
122
  }
39
123
 
40
- private func hotUpdaterUpdateSignalLaunchState(_ bundleId: String?, shouldRollback: Bool) {
41
- hotUpdaterUpdateSignalLaunchStateSymbol(bundleId as NSString?, ObjCBool(shouldRollback))
124
+ /// The impl of the React Native runtime whose JavaScript thread this is.
125
+ private func hotUpdaterRuntimeImpl() -> HotUpdaterImpl? {
126
+ hotUpdaterCopyRuntimeImplSymbol().flatMap {
127
+ Unmanaged<AnyObject>.fromOpaque($0).takeRetainedValue() as? HotUpdaterImpl
128
+ }
42
129
  }
43
130
 
44
131
  private func hotUpdaterPerformRecoveryReload() -> Bool {
@@ -55,13 +142,59 @@ private func hotUpdaterGetMinBundleId() -> String {
55
142
  private let bundleStorage: BundleStorageService
56
143
  private let preferences: PreferencesService
57
144
  private let cohortService: CohortService
145
+ /// Whose state this impl keeps; see `HotUpdaterInstanceEnvironment`.
146
+ let environment: HotUpdaterInstanceEnvironment
147
+ /// This instance's crash recovery: its marker, its launch's watch, and
148
+ /// its rollback, apart from every other instance's.
58
149
  private let recoveryManager: HotUpdaterRecoveryManager
59
150
  private let releaseCatalogCache: ReleaseCatalogCacheService
60
151
  private let keyValueStorage: KeyValueStorageService
61
- private var currentLaunchSelection: LaunchSelection?
152
+ // Read by native update checks on other threads while React Native sets it.
153
+ private let launchSelectionLock = NSLock()
154
+ private var launchSelectionValue: LaunchSelection?
155
+ private var currentLaunchSelection: LaunchSelection? {
156
+ get {
157
+ launchSelectionLock.lock()
158
+ defer { launchSelectionLock.unlock() }
159
+ return launchSelectionValue
160
+ }
161
+ set {
162
+ launchSelectionLock.lock()
163
+ launchSelectionValue = newValue
164
+ launchSelectionLock.unlock()
165
+ }
166
+ }
167
+ // The selection React Native runs in this process. A channel reset clears
168
+ // currentLaunchSelection, so React Native's next read picks the default
169
+ // channel's bundle, but the running bundle stays until a reload.
170
+ private var runningLaunchSelectionValue: LaunchSelection?
171
+ private var runningLaunchSelection: LaunchSelection? {
172
+ get {
173
+ launchSelectionLock.lock()
174
+ defer { launchSelectionLock.unlock() }
175
+ return runningLaunchSelectionValue
176
+ }
177
+ set {
178
+ launchSelectionLock.lock()
179
+ runningLaunchSelectionValue = newValue
180
+ launchSelectionLock.unlock()
181
+ }
182
+ }
183
+ // Serializes the launch's bundle selection with the update check's read
184
+ // of it, and with a channel reset or built-in commit, which delete
185
+ // downloaded bundles.
186
+ private let launchPreparationLock = LaunchPreparationLock()
187
+ /// Advanced when this instance's own fatal marks its launch for
188
+ /// rollback; a selection prepared at an older epoch is prepared again.
189
+ private let launchEpoch = HotUpdaterLaunchEpoch()
190
+ // Guarded by launchPreparationLock.
191
+ private var currentLaunchSelectionEpoch = 0
62
192
  private var launchStartObservers: [NSObjectProtocol] = []
193
+ // Main thread only: the launch the observers mark started, and the
194
+ // newest launch whose mark ran.
195
+ private var launchStartObserversGeneration: Int?
196
+ private var launchStartGenerations = HotUpdaterLatestGeneration()
63
197
 
64
- private static let DEFAULT_CHANNEL = "production"
65
198
  private static let CHANNEL_STORAGE_KEY = "HotUpdaterChannel"
66
199
 
67
200
  private static func parseSafeByteSize(_ value: Any?) -> Int64? {
@@ -85,26 +218,43 @@ private func hotUpdaterGetMinBundleId() -> String {
85
218
  * Convenience initializer that creates and configures all dependencies.
86
219
  */
87
220
  public convenience override init() {
221
+ self.init(environment: .main)
222
+ }
223
+
224
+ /**
225
+ * Creates the impl of one instance name, with its own storage, keys, and
226
+ * config. Main's is `HotUpdaterNativeUpdateBridge.sharedImpl`.
227
+ */
228
+ convenience init(environment: HotUpdaterInstanceEnvironment) {
229
+ _ = HotUpdaterInstanceEnvironment.registerBuildFingerprintHash
230
+ // Main is built from one snapshot of its values, which a later main
231
+ // instance must match; nothing below reads HotUpdaterConfig again.
232
+ let environment = environment.isMain ? HotUpdaterMainBundle.shared.implEnvironment() : environment
88
233
  let fileSystem = FileManagerService()
89
- let isolationKey = HotUpdaterImpl.getIsolationKey()
234
+ let isolationKey = environment.isolationKey(appVersion: Self.appVersion, minBundleId: Self.minBundleId())
90
235
  let preferences = VersionedPreferencesService()
91
- let downloadService = URLSessionDownloadService()
236
+ let downloadService = URLSessionDownloadService(
237
+ backgroundSessionIdentifier: environment.backgroundSessionIdentifier
238
+ )
92
239
  let bundleStorage = BundleFileStorageService(
93
240
  fileSystem: fileSystem,
94
241
  downloadService: downloadService,
95
242
  preferences: preferences,
96
243
  isolationKey: isolationKey,
244
+ instance: environment,
97
245
  builtInBundleIdProvider: { HotUpdaterImpl.minBundleId() }
98
246
  )
99
- let recoveryManager = HotUpdaterRecoveryManager.shared
100
247
 
101
248
  self.init(
102
249
  bundleStorage: bundleStorage,
103
250
  preferences: preferences,
104
- recoveryManager: recoveryManager,
105
- releaseCatalogCache: ReleaseCatalogCacheService(),
251
+ environment: environment,
252
+ recoveryManager: HotUpdaterRecoveryManager(environment: environment, bundleStorage: bundleStorage),
253
+ releaseCatalogCache: ReleaseCatalogCacheService(
254
+ rootDirectory: environment.releaseCatalogCacheDirectory(in: fileSystem)
255
+ ),
106
256
  keyValueStorage: KeyValueStorageService(
107
- directory: NoBackupStorage.directory(in: fileSystem)
257
+ directory: environment.keyValueStorageDirectory(in: fileSystem)
108
258
  )
109
259
  )
110
260
  }
@@ -118,6 +268,7 @@ private func hotUpdaterGetMinBundleId() -> String {
118
268
  internal init(
119
269
  bundleStorage: BundleStorageService,
120
270
  preferences: PreferencesService,
271
+ environment: HotUpdaterInstanceEnvironment = .main,
121
272
  recoveryManager: HotUpdaterRecoveryManager,
122
273
  releaseCatalogCache: ReleaseCatalogCacheService = ReleaseCatalogCacheService(),
123
274
  keyValueStorage: KeyValueStorageService = KeyValueStorageService(
@@ -127,13 +278,17 @@ private func hotUpdaterGetMinBundleId() -> String {
127
278
  self.bundleStorage = bundleStorage
128
279
  self.preferences = preferences
129
280
  self.cohortService = CohortService()
281
+ self.environment = environment
130
282
  self.recoveryManager = recoveryManager
131
283
  self.releaseCatalogCache = releaseCatalogCache
132
284
  self.keyValueStorage = keyValueStorage
133
285
  super.init()
286
+ recoveryManager.onRollbackCharged = { [weak self] in
287
+ self?.invalidateLaunchPreparation()
288
+ }
134
289
 
135
290
  // Configure preferences with isolation key
136
- let isolationKey = HotUpdaterImpl.getIsolationKey()
291
+ let isolationKey = environment.isolationKey(appVersion: Self.appVersion, minBundleId: Self.minBundleId())
137
292
  (preferences as? VersionedPreferencesService)?.configure(isolationKey: isolationKey)
138
293
  }
139
294
 
@@ -143,17 +298,14 @@ private func hotUpdaterGetMinBundleId() -> String {
143
298
  * Returns the app version from main bundle info.
144
299
  */
145
300
  public static var appVersion: String? {
146
- return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
301
+ return HotUpdaterInstanceEnvironment.appVersion
147
302
  }
148
303
 
149
304
  /**
150
- * Returns the app version from main bundle info.
305
+ * Returns main's default channel.
151
306
  */
152
307
  public static var appChannel: String {
153
- if let override = HotUpdaterConfig.shared.channel, !override.isEmpty {
154
- return override
155
- }
156
- return Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_CHANNEL") as? String ?? Self.DEFAULT_CHANNEL
308
+ return HotUpdaterInstanceEnvironment.main.defaultChannel()
157
309
  }
158
310
 
159
311
  public static func minBundleId() -> String {
@@ -162,32 +314,13 @@ private func hotUpdaterGetMinBundleId() -> String {
162
314
 
163
315
  /**
164
316
  * Gets the complete isolation key for preferences storage.
165
- * @return The isolation key in format: hotupdater_{fingerprintOrVersion}_{channel}_
317
+ * @return The isolation key in format: hotupdater_{fingerprint}_{minBundleId}_{channel}_
318
+ * (fingerprint strategy) or hotupdater_{appVersion}_{channel}_
166
319
  */
167
320
  public static func getIsolationKey() -> String {
168
- // Programmatic override (brownfield/XCFramework). When set, it is used verbatim
169
- // so the OTA cache can stay stable across host app version bumps.
170
- if let override = HotUpdaterConfig.shared.isolationKey, !override.isEmpty {
171
- return override
172
- }
173
-
174
- // Get fingerprint hash from programmatic override or Info.plist
175
- let fingerprintHash = HotUpdaterConfig.shared.fingerprintHash
176
- ?? Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
177
-
178
- // Get app version and channel
179
- let appVersion = self.appVersion ?? "unknown"
180
- let appChannel = self.appChannel
181
-
182
- // Include both fingerprint hash and app version for complete isolation
183
- let baseKey: String
184
- if let hash = fingerprintHash, !hash.isEmpty {
185
- baseKey = "\(hash)_\(appVersion)"
186
- } else {
187
- baseKey = appVersion
188
- }
189
-
190
- return "hotupdater_\(baseKey)_\(appChannel)_"
321
+ // The deprecated programmatic override is used verbatim.
322
+ _ = HotUpdaterInstanceEnvironment.registerBuildFingerprintHash
323
+ return HotUpdaterInstanceEnvironment.main.isolationKey(appVersion: appVersion, minBundleId: minBundleId())
191
324
  }
192
325
 
193
326
  // MARK: - Channel Management
@@ -202,19 +335,19 @@ private func hotUpdaterGetMinBundleId() -> String {
202
335
  !channel.isEmpty {
203
336
  return channel
204
337
  }
205
- if let override = HotUpdaterConfig.shared.channel, !override.isEmpty {
206
- return override
207
- }
338
+ // A runtime channel switch outranks the default channel, which
339
+ // `HotUpdaterOptions.channel` (or the deprecated
340
+ // `configure(channel:)`) only replaces, as on Android.
208
341
  if let savedChannel = try? preferences.getItem(forKey: Self.CHANNEL_STORAGE_KEY),
209
342
  !savedChannel.isEmpty {
210
343
  return savedChannel
211
344
  }
212
345
 
213
- return Self.appChannel
346
+ return environment.defaultChannel()
214
347
  }
215
348
 
216
349
  public func getDefaultChannel() -> String {
217
- return Self.appChannel
350
+ return environment.defaultChannel()
218
351
  }
219
352
 
220
353
  /**
@@ -222,10 +355,7 @@ private func hotUpdaterGetMinBundleId() -> String {
222
355
  * @return The fingerprint hash or nil if not set
223
356
  */
224
357
  public func getFingerprintHash() -> String? {
225
- if let override = HotUpdaterConfig.shared.fingerprintHash, !override.isEmpty {
226
- return override
227
- }
228
- return Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
358
+ return environment.fingerprintHash()
229
359
  }
230
360
 
231
361
  // MARK: - Cohort Management
@@ -243,10 +373,20 @@ private func hotUpdaterGetMinBundleId() -> String {
243
373
  /**
244
374
  * Gets the URL to the bundle file.
245
375
  * @param bundle instance to lookup the JavaScript bundle resource. Defaults to Bundle.main.
376
+ * Main records it in `HotUpdaterConfig.shared.bundle` for reloads and Info.plist keys;
377
+ * a named impl reads its own bundle and leaves main's alone.
246
378
  * @return URL to the bundle or nil
247
379
  */
248
380
  public func bundleURL(bundle: Bundle = Bundle.main) -> URL? {
249
- return prepareLaunchIfNeeded(bundle: bundle).bundleURL
381
+ if environment.isMain {
382
+ HotUpdaterMainBundle.shared.note(bundle)
383
+ }
384
+ let bundleURL = prepareLaunchIfNeeded(bundle: bundle).bundleURL
385
+ if !environment.isMain, let bundleURL {
386
+ // The runtime started with this URL finds this impl by it.
387
+ HotUpdaterNativeUpdateBridge.router.record(launchURL: bundleURL, name: environment.name)
388
+ }
389
+ return bundleURL
250
390
  }
251
391
 
252
392
  // MARK: - Bundle Update
@@ -263,12 +403,24 @@ private func hotUpdaterGetMinBundleId() -> String {
263
403
  public func updateBundle(_ params: [AnyHashable: Any]?,
264
404
  resolver resolve: @escaping RCTPromiseResolveBlock,
265
405
  rejecter reject: @escaping RCTPromiseRejectBlock) {
406
+ updateBundle(params, onProgress: nil, onFilesResponse: nil, resolver: resolve, rejecter: reject)
407
+ }
408
+
409
+ /**
410
+ * `updateBundle`, also reporting progress (0 to 1) to `onProgress` on the
411
+ * main thread, and each artifact files response to `onFilesResponse`.
412
+ */
413
+ func updateBundle(_ params: [AnyHashable: Any]?,
414
+ onProgress: ((Double) -> Void)?,
415
+ onFilesResponse: ((Int, String?) -> Void)?,
416
+ resolver resolve: @escaping RCTPromiseResolveBlock,
417
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
266
418
  // Legacy: artifact protocol v1, where every file comes with its URL.
267
419
  // JavaScript from the first protocol v2 SDK also sends `filesUrl`
268
420
  // here, with only the Hermes patch in `assets`; its OTA Bundles must
269
421
  // still install.
270
- stage(params, resolver: resolve, rejecter: reject) { data, reject in
271
- switch Self.parseFileResolver(data, required: false) {
422
+ stage(params, onProgress: onProgress, resolver: resolve, rejecter: reject) { data, reject in
423
+ switch Self.parseFileResolver(data, required: false, onResponse: onFilesResponse) {
272
424
  case .failure(let error):
273
425
  reject("INVALID_FILE_URL", error.localizedDescription, error)
274
426
  return nil
@@ -292,8 +444,20 @@ private func hotUpdaterGetMinBundleId() -> String {
292
444
  public func installArtifact(_ params: [AnyHashable: Any]?,
293
445
  resolver resolve: @escaping RCTPromiseResolveBlock,
294
446
  rejecter reject: @escaping RCTPromiseRejectBlock) {
295
- stage(params, resolver: resolve, rejecter: reject) { data, reject in
296
- Self.parseArtifactInstall(data, rejecter: reject)
447
+ installArtifact(params, onProgress: nil, onFilesResponse: nil, resolver: resolve, rejecter: reject)
448
+ }
449
+
450
+ /**
451
+ * `installArtifact`, also reporting progress (0 to 1) to `onProgress` on
452
+ * the main thread, and each artifact files response to `onFilesResponse`.
453
+ */
454
+ func installArtifact(_ params: [AnyHashable: Any]?,
455
+ onProgress: ((Double) -> Void)?,
456
+ onFilesResponse: ((Int, String?) -> Void)?,
457
+ resolver resolve: @escaping RCTPromiseResolveBlock,
458
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
459
+ stage(params, onProgress: onProgress, resolver: resolve, rejecter: reject) { data, reject in
460
+ Self.parseArtifactInstall(data, onFilesResponse: onFilesResponse, rejecter: reject)
297
461
  }
298
462
  }
299
463
 
@@ -303,6 +467,7 @@ private func hotUpdaterGetMinBundleId() -> String {
303
467
  /// assets and file resolver `plan` returns; `plan` rejects and returns nil
304
468
  /// when its own parameters are invalid.
305
469
  private func stage(_ params: [AnyHashable: Any]?,
470
+ onProgress: ((Double) -> Void)?,
306
471
  resolver resolve: @escaping RCTPromiseResolveBlock,
307
472
  rejecter reject: @escaping RCTPromiseRejectBlock,
308
473
  plan: ([AnyHashable: Any], RCTPromiseRejectBlock) -> StagePlan?) {
@@ -355,7 +520,7 @@ private func hotUpdaterGetMinBundleId() -> String {
355
520
  return
356
521
  }
357
522
  let selection = (data["selection"] as? [String: Any]).flatMap(Self.parseSelection)
358
- if let selection, !bundleStorage.stageReleaseSelection(selection) {
523
+ if let selection, !bundleStorage.isSelectionCurrent(selection) {
359
524
  // Not an update failure, so the rejection carries no stage or reason.
360
525
  let error = StaleReleaseSelectionError() as NSError
361
526
  reject("UNKNOWN_ERROR", error.localizedDescription, error)
@@ -368,17 +533,20 @@ private func hotUpdaterGetMinBundleId() -> String {
368
533
  NSLog("[HotUpdaterImpl] updateBundle called with bundleId: \(bundleId), manifestUrl: \(manifestUrl.absoluteString)")
369
534
 
370
535
  // Heavy work is delegated to bundle storage service with safe error handling
371
- bundleStorage.updateBundle(bundleId: bundleId, manifestUrl: manifestUrl, manifestFileHash: manifestFileHash, archiveUrl: archiveUrl, assets: planned.assets, fileResolver: planned.fileResolver, progressHandler: { payload in
536
+ bundleStorage.updateBundle(bundleId: bundleId, manifestUrl: manifestUrl, manifestFileHash: manifestFileHash, archiveUrl: archiveUrl, assets: planned.assets, fileResolver: planned.fileResolver, selection: selection, progressHandler: { payload in
372
537
  DispatchQueue.main.async {
538
+ // The impl as the object: HotUpdater.mm forwards only
539
+ // main's progress to JavaScript.
373
540
  NotificationCenter.default.post(
374
541
  name: .updateProgressDidChange,
375
- object: nil,
542
+ object: self,
376
543
  userInfo: payload.userInfo
377
544
  )
378
545
 
379
546
  if let callback = progressCallback {
380
547
  callback([payload.progress])
381
548
  }
549
+ onProgress?(payload.progress)
382
550
  }
383
551
  }) { [weak self] result in
384
552
  guard let self = self else {
@@ -465,7 +633,8 @@ private func hotUpdaterGetMinBundleId() -> String {
465
633
  /// by one, with the update check's headers and timeout; nil when
466
634
  /// `filesUrl` is missing and not `required`.
467
635
  private static func parseFileResolver(_ data: [AnyHashable: Any],
468
- required: Bool) -> Result<ArtifactFileResolver?, NSError> {
636
+ required: Bool,
637
+ onResponse: ((Int, String?) -> Void)?) -> Result<ArtifactFileResolver?, NSError> {
469
638
  let filesUrlString = data["filesUrl"] as? String ?? ""
470
639
  if filesUrlString.isEmpty && !required {
471
640
  return .success(nil)
@@ -480,16 +649,22 @@ private func hotUpdaterGetMinBundleId() -> String {
480
649
  .compactMapValues { $0 as? String }
481
650
  let timeout = (data["requestTimeout"] as? NSNumber).map { $0.doubleValue / 1000 }
482
651
  .flatMap { $0 > 0 ? $0 : nil } ?? HTTPArtifactFileResolver.defaultTimeout
483
- return .success(HTTPArtifactFileResolver(filesUrl: filesUrl, headers: headers, timeout: timeout))
652
+ return .success(HTTPArtifactFileResolver(
653
+ filesUrl: filesUrl,
654
+ headers: headers,
655
+ timeout: timeout,
656
+ onResponse: onResponse
657
+ ))
484
658
  }
485
659
 
486
660
  /// Artifact protocol v2: the Hermes patch as the only planned asset, and
487
661
  /// the resolver that asks `filesUrl` for the files native downloads one
488
662
  /// by one.
489
663
  private static func parseArtifactInstall(_ data: [AnyHashable: Any],
664
+ onFilesResponse: ((Int, String?) -> Void)?,
490
665
  rejecter reject: RCTPromiseRejectBlock) -> StagePlan? {
491
666
  let fileResolver: ArtifactFileResolver?
492
- switch parseFileResolver(data, required: true) {
667
+ switch parseFileResolver(data, required: true, onResponse: onFilesResponse) {
493
668
  case .failure(let error):
494
669
  reject("INVALID_FILE_URL", error.localizedDescription, error)
495
670
  return nil
@@ -644,7 +819,11 @@ private func hotUpdaterGetMinBundleId() -> String {
644
819
  reject("INVALID_SELECTION", error.localizedDescription, error)
645
820
  return
646
821
  }
647
- resolve(bundleStorage.commitReleaseSelection(selection))
822
+ // A built-in commit deletes every downloaded bundle, so it runs under
823
+ // the launch lock as a channel reset does.
824
+ resolve(launchPreparationLock.commitReleaseSelection(selection, in: bundleStorage) {
825
+ self.currentLaunchSelection = nil
826
+ })
648
827
  }
649
828
 
650
829
  /**
@@ -764,6 +943,13 @@ private func hotUpdaterGetMinBundleId() -> String {
764
943
  return nil
765
944
  }
766
945
 
946
+ /// The bundle this process runs. A channel reset clears
947
+ /// currentLaunchSelection for the next launch, but the running bundle
948
+ /// stays until React Native reloads.
949
+ func runningBundleId() -> String? {
950
+ (currentLaunchSelection ?? runningLaunchSelection)?.launchedBundleId
951
+ }
952
+
767
953
  /**
768
954
  * Gets the current manifest from bundle storage.
769
955
  */
@@ -777,21 +963,71 @@ private func hotUpdaterGetMinBundleId() -> String {
777
963
 
778
964
  public func resetLaunchPreparation() {
779
965
  currentLaunchSelection = nil
966
+ runningLaunchSelection = nil
967
+ }
968
+
969
+ /// This named instance's own fatal marked its launch for rollback, and
970
+ /// the process may live on: the host's next `bundleURL()` prepares the
971
+ /// launch again, consuming the marker, instead of returning the bundle
972
+ /// that failed. The running bundle stays what React Native runs, as
973
+ /// after a channel reset.
974
+ ///
975
+ /// Takes no lock: a crash can reach here on a thread that holds the
976
+ /// launch lock. A preparation already running keeps the epoch it started
977
+ /// with, so its selection is prepared again at the next `bundleURL()`.
978
+ func invalidateLaunchPreparation() {
979
+ launchEpoch.invalidate()
980
+ }
981
+
982
+ // MARK: - Native Update Check
983
+
984
+ /**
985
+ * The bundle the native update check counts as running: the launched one,
986
+ * or before React Native starts, the one its launch would pick. Preparing
987
+ * the launch here would keep a forced update staged before React Native
988
+ * starts from applying in this session.
989
+ */
990
+ func bundleIdForUpdateCheck() -> String? {
991
+ launchPreparationLock.withLock {
992
+ if let launchSelection = currentLaunchSelection ?? runningLaunchSelection {
993
+ return launchSelection.launchedBundleId
994
+ }
995
+ // Settles what the previous process left first, so the check reads the
996
+ // selection, launch state, and crash history this launch will use.
997
+ // Each instance consumes only its own crash marker.
998
+ bundleStorage.settlePreviousLaunch(pendingRecovery: { self.recoveryManager.consumePendingCrashRecovery() })
999
+ return bundleStorage.peekLaunchBundleId()
1000
+ }
1001
+ }
1002
+
1003
+ func releaseCatalogCacheValue(_ partition: String) -> String? {
1004
+ releaseCatalogCache.get(partition: partition)
1005
+ }
1006
+
1007
+ func setReleaseCatalogCacheValue(_ partition: String, value: String) -> Bool {
1008
+ releaseCatalogCache.set(partition: partition, value: value)
1009
+ }
1010
+
1011
+ func removeReleaseCatalogCacheValue(_ partition: String) {
1012
+ releaseCatalogCache.remove(partition: partition)
780
1013
  }
781
1014
 
782
1015
  @objc
783
1016
  public func resetChannel(_ resolver: @escaping RCTPromiseResolveBlock,
784
1017
  rejecter reject: @escaping RCTPromiseRejectBlock) {
785
- let result = bundleStorage.resetChannel()
786
-
787
- switch result {
788
- case .success(let success):
1018
+ // Under the launch lock, so React Native never selects a bundle the
1019
+ // reset is about to delete.
1020
+ let result = launchPreparationLock.resetChannel(in: bundleStorage) {
789
1021
  do {
790
1022
  try preferences.setItem(nil, forKey: Self.CHANNEL_STORAGE_KEY)
791
1023
  } catch {
792
1024
  NSLog("[HotUpdaterImpl] Failed to clear channel override: \(error)")
793
1025
  }
794
1026
  self.currentLaunchSelection = nil
1027
+ }
1028
+
1029
+ switch result {
1030
+ case .success(let success):
795
1031
  resolver(success)
796
1032
  case .failure(let error):
797
1033
  let normalizedCode = HotUpdaterImpl.normalizeErrorCode(from: error)
@@ -801,40 +1037,80 @@ private func hotUpdaterGetMinBundleId() -> String {
801
1037
  }
802
1038
 
803
1039
  private func prepareLaunchIfNeeded(bundle: Bundle) -> LaunchSelection {
804
- if let currentLaunchSelection {
1040
+ launchPreparationLock.withLock { prepareLaunchLocked(bundle: bundle) }
1041
+ }
1042
+
1043
+ private func prepareLaunchLocked(bundle: Bundle) -> LaunchSelection {
1044
+ if let currentLaunchSelection, launchEpoch.isCurrent(currentLaunchSelectionEpoch) {
805
1045
  return currentLaunchSelection
806
1046
  }
807
1047
 
808
- let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
809
- let selection = bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
810
- markLaunchStartedInForeground(selection)
811
- recoveryManager.startMonitoring(bundleId: selection.launchedBundleId, shouldRollback: selection.shouldRollbackOnCrash) { [weak self] launchedBundleId in
812
- self?.bundleStorage.markLaunchCompleted(bundleId: launchedBundleId)
1048
+ // The epoch is read before the marker is consumed, and checked again
1049
+ // once this launch's monitoring replaced the running one's, after
1050
+ // which that launch can no longer mark a rollback: a rollback marked
1051
+ // meanwhile prepares again, consuming it. Monitoring is registered
1052
+ // before the foreground mark, so another instance's fatal that stops
1053
+ // every started launch also sees this one; starting it again on a
1054
+ // retry replaces it.
1055
+ let (selection, generation, epoch) = launchEpoch.prepareCurrent(
1056
+ prepare: {
1057
+ let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
1058
+ return bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
1059
+ },
1060
+ startMonitoring: { selection in
1061
+ // The same bundle again (a named instance): its launch goes on.
1062
+ if !environment.isMain, let generation = recoveryManager.continuingLaunch(
1063
+ bundleId: selection.launchedBundleId,
1064
+ launchURL: selection.bundleURL
1065
+ ) {
1066
+ return generation
1067
+ }
1068
+ return recoveryManager.startMonitoring(
1069
+ bundleId: selection.launchedBundleId,
1070
+ shouldRollback: selection.shouldRollbackOnCrash,
1071
+ launchURL: selection.bundleURL
1072
+ )
1073
+ }
1074
+ )
1075
+ // A named runtime may be warmed up without UI, so its launch starts
1076
+ // only once one of its views is shown (HotUpdaterRecoveryHub), and
1077
+ // only its own first content verifies it.
1078
+ if environment.isMain {
1079
+ markLaunchStartedInForeground(selection, generation: generation)
813
1080
  }
1081
+ runningLaunchSelection = selection
814
1082
  currentLaunchSelection = selection
1083
+ currentLaunchSelectionEpoch = epoch
815
1084
  return selection
816
1085
  }
817
1086
 
818
1087
  // A background launch, such as a silent push, may never render. Only a
819
1088
  // launch in the foreground may leave an unfinished launch for the next
820
1089
  // process to roll back.
821
- private func markLaunchStartedInForeground(_ selection: LaunchSelection) {
1090
+ // Through crash recovery, which refuses a later launch's, one whose
1091
+ // content appeared, and holds it while another instance's fatal is on
1092
+ // its way.
1093
+ private func markLaunchStartedInForeground(_ selection: LaunchSelection, generation: Int) {
822
1094
  let bundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
823
1095
  let mark = { [weak self] in
824
1096
  guard let self else { return }
825
- self.removeLaunchStartObservers()
826
- guard let bundleId else { return }
1097
+ // Queued for an older launch, behind a newer launch's mark: the
1098
+ // newer launch's observers stay.
1099
+ guard self.launchStartGenerations.admit(generation) else { return }
1100
+ self.removeLaunchStartObservers(before: generation + 1)
1101
+ guard bundleId != nil else { return }
827
1102
  guard UIApplication.shared.applicationState == .background else {
828
- self.bundleStorage.markLaunchStarted(bundleId: bundleId)
1103
+ self.recoveryManager.markMainLaunchStarted(generation: generation)
829
1104
  return
830
1105
  }
1106
+ self.launchStartObserversGeneration = generation
831
1107
  self.launchStartObservers = [
832
1108
  UIApplication.willEnterForegroundNotification,
833
1109
  UIApplication.didBecomeActiveNotification,
834
1110
  ].map { name in
835
1111
  NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
836
- self?.removeLaunchStartObservers()
837
- self?.bundleStorage.markLaunchStarted(bundleId: bundleId)
1112
+ self?.removeLaunchStartObservers(before: generation + 1)
1113
+ self?.recoveryManager.markMainLaunchStarted(generation: generation)
838
1114
  }
839
1115
  }
840
1116
  }
@@ -846,121 +1122,435 @@ private func hotUpdaterGetMinBundleId() -> String {
846
1122
  }
847
1123
  }
848
1124
 
849
- private func removeLaunchStartObservers() {
1125
+ /// Removes the observers if a launch before `generation` installed
1126
+ /// them. Main thread only.
1127
+ private func removeLaunchStartObservers(before generation: Int) {
1128
+ guard let installed = launchStartObserversGeneration, installed < generation else { return }
850
1129
  launchStartObservers.forEach { NotificationCenter.default.removeObserver($0) }
851
1130
  launchStartObservers = []
1131
+ launchStartObserversGeneration = nil
852
1132
  }
853
1133
  }
854
1134
 
1135
+ /// One instance's crash recovery: consumes its marker at launch, watches its
1136
+ /// launch until 10 seconds after its first content, and for main, reloads
1137
+ /// in-process when its JavaScript fails before first content. The state lives
1138
+ /// in `HotUpdaterRecoveryHub.shared.coordinator`; the process-wide handlers in
1139
+ /// the hub dispatch to the instance a crash or content belongs to.
855
1140
  @objcMembers
856
1141
  final class HotUpdaterRecoveryManager: NSObject {
857
- static let shared = HotUpdaterRecoveryManager()
858
-
1142
+ let name: String
859
1143
  private let crashMarkerURL: URL
860
- private var previousFatalHandler: RCTFatalHandler?
861
- private var previousFatalExceptionHandler: RCTFatalExceptionHandler?
862
- private var previousUncaughtExceptionHandler: (@convention(c) (NSException) -> Void)?
863
-
864
- private var signalHandlersInstalled = false
865
- private var handlersInstalled = false
866
- private var isMonitoring = false
867
- private var recoveryRequested = false
868
- private var monitoringGeneration = 0
869
- private var currentBundleId: String?
870
- private var shouldRollbackOnCrash = false
871
- private var contentAppearedCallback: ((String?) -> Void)?
1144
+ private let hub: HotUpdaterRecoveryHub
1145
+ /// Set by the impl: invalidates its launch preparation.
1146
+ var onRollbackCharged: (() -> Void)?
872
1147
  private var stopMonitoringWorkItem: DispatchWorkItem?
873
-
874
- private override init() {
875
- let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
876
- self.crashMarkerURL = documentsPath
877
- .appendingPathComponent("bundle-store", isDirectory: true)
878
- .appendingPathComponent("recovery-crash-marker.json")
1148
+ // Main thread only: a named launch whose view started in the background
1149
+ // starts once the app comes to the foreground.
1150
+ private var armObservers: [NSObjectProtocol] = []
1151
+ // The launch the observers start.
1152
+ private var armGeneration: Int?
1153
+ private let backgroundViewStarts = HotUpdaterBackgroundViewStarts()
1154
+
1155
+ init(environment: HotUpdaterInstanceEnvironment, bundleStorage: BundleStorageService) {
1156
+ name = environment.name
1157
+ if environment.isMain {
1158
+ // Main's path as it always was: Documents/bundle-store.
1159
+ let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
1160
+ crashMarkerURL = HotUpdaterCrashMarker.url(for: environment, documentsPath: documentsPath.path)
1161
+ } else {
1162
+ crashMarkerURL = HotUpdaterCrashMarker.url(
1163
+ for: environment,
1164
+ documentsPath: FileManagerService().documentsPath()
1165
+ )
1166
+ }
1167
+ hub = .shared
879
1168
  super.init()
1169
+ hub.coordinator.register(name: name, markerURL: crashMarkerURL, storage: bundleStorage)
1170
+ hub.register(self)
880
1171
  }
881
1172
 
1173
+ private var isMain: Bool { name == HotUpdaterInstanceEnvironment.mainName }
1174
+
882
1175
  func consumePendingCrashRecovery() -> PendingCrashRecovery? {
883
- guard FileManager.default.fileExists(atPath: crashMarkerURL.path) else {
884
- return nil
885
- }
1176
+ HotUpdaterCrashMarker.consume(at: crashMarkerURL)
1177
+ }
886
1178
 
887
- defer {
888
- try? FileManager.default.removeItem(at: crashMarkerURL)
889
- }
1179
+ @discardableResult
1180
+ func startMonitoring(bundleId: String?, shouldRollback: Bool, launchURL: URL?) -> Int {
1181
+ objc_sync_enter(self)
1182
+ defer { objc_sync_exit(self) }
890
1183
 
891
- do {
892
- let data = try Data(contentsOf: crashMarkerURL)
893
- let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
894
- return PendingCrashRecovery.from(json: json)
895
- } catch {
896
- NSLog("[HotUpdaterRecovery] Failed to read crash marker: \(error)")
897
- return nil
1184
+ // Under this manager's lock, as are the content and grace callbacks
1185
+ // that check the generation, so none acts on a launch it wasn't for.
1186
+ let generation = hub.coordinator.startMonitoring(
1187
+ name: name,
1188
+ bundleId: bundleId,
1189
+ shouldRollback: shouldRollback,
1190
+ launchURL: launchURL
1191
+ )
1192
+ stopMonitoringWorkItem?.cancel()
1193
+ stopMonitoringWorkItem = nil
1194
+ if !isMain {
1195
+ DispatchQueue.main.async { [weak self] in self?.removeArmObservers(before: generation) }
898
1196
  }
1197
+
1198
+ hub.installIfNeeded()
1199
+ return generation
899
1200
  }
900
1201
 
901
- func startMonitoring(
902
- bundleId: String?,
903
- shouldRollback: Bool,
904
- onContentAppeared: @escaping (String?) -> Void
905
- ) {
1202
+ /// The generation of the launch a preparation of the same bundle
1203
+ /// continues, if any.
1204
+ func continuingLaunch(bundleId: String?, launchURL: URL?) -> Int? {
1205
+ hub.coordinator.continuingLaunch(name: name, bundleId: bundleId, launchURL: launchURL)
1206
+ }
1207
+
1208
+ /// Main only: its launch of `generation` is in the foreground.
1209
+ func markMainLaunchStarted(generation: Int) {
1210
+ hub.coordinator.markMainLaunchStarted(generation: generation)
1211
+ }
1212
+
1213
+ /// This instance's first content: its monitoring ends after a grace period.
1214
+ func contentAppeared(generation: Int) {
906
1215
  objc_sync_enter(self)
907
1216
  defer { objc_sync_exit(self) }
908
-
909
- monitoringGeneration += 1
910
- currentBundleId = bundleId
911
- shouldRollbackOnCrash = shouldRollback
912
- recoveryRequested = false
913
- contentAppearedCallback = onContentAppeared
914
- isMonitoring = true
1217
+ // A launch started since then has its own grace period.
1218
+ guard hub.coordinator.isCurrent(name: name, generation: generation) else { return }
915
1219
 
916
1220
  stopMonitoringWorkItem?.cancel()
1221
+ let workItem = DispatchWorkItem { [weak self] in
1222
+ self?.finishMonitoring(generation: generation)
1223
+ }
1224
+ stopMonitoringWorkItem = workItem
1225
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10), execute: workItem)
1226
+ if !isMain {
1227
+ DispatchQueue.main.async { [weak self] in self?.removeArmObservers(before: generation + 1) }
1228
+ }
1229
+ }
1230
+
1231
+ private func finishMonitoring(generation: Int) {
1232
+ objc_sync_enter(self)
1233
+ defer { objc_sync_exit(self) }
1234
+ guard hub.coordinator.isCurrent(name: name, generation: generation) else { return }
1235
+
917
1236
  stopMonitoringWorkItem = nil
1237
+ hub.coordinator.finishMonitoring(name: name, generation: generation)
1238
+ }
1239
+
1240
+ /// A view of this named instance's runtime started. Only a launch in the
1241
+ /// foreground may leave an unfinished launch for the next process to roll
1242
+ /// back, as for main. `generation` is the launch the view started in.
1243
+ /// Main thread only.
1244
+ func viewStarted(_ view: AnyObject, inForeground: Bool, generation: Int, runtimeBundleURL: URL?) {
1245
+ guard !isMain else { return }
1246
+ // A view of a runtime of an earlier launch, such as one torn down
1247
+ // while its host is recreated, starts nothing.
1248
+ guard hub.coordinator.isCurrentRuntime(name: name, runtimeBundleURL: runtimeBundleURL) else { return }
1249
+ switch HotUpdaterBackgroundViewStarts.viewStart(inForeground: inForeground, isShown: { hotUpdaterIsViewShown(view) }) {
1250
+ case .arm:
1251
+ removeArmObservers(before: generation + 1)
1252
+ hub.coordinator.arm(name: name, generation: generation, runtimeBundleURL: runtimeBundleURL)
1253
+ return
1254
+ case .waitForAttachment:
1255
+ // Started for pre-rendering: its attachment to a window is
1256
+ // reported as another start.
1257
+ return
1258
+ case .waitForForeground:
1259
+ break
1260
+ }
1261
+ // Views of an earlier launch never start this one, nor remove what
1262
+ // a later launch installed.
1263
+ if let armGeneration {
1264
+ if generation < armGeneration {
1265
+ return
1266
+ }
1267
+ removeArmObservers(before: generation)
1268
+ }
1269
+ // Only if the view is still shown when the app comes back.
1270
+ backgroundViewStarts.add(view)
1271
+ guard armObservers.isEmpty else { return }
1272
+ armGeneration = generation
1273
+ armObservers = [
1274
+ UIApplication.willEnterForegroundNotification,
1275
+ UIApplication.didBecomeActiveNotification,
1276
+ ].map { notificationName in
1277
+ NotificationCenter.default.addObserver(forName: notificationName, object: nil, queue: .main) { [weak self] _ in
1278
+ guard let self else { return }
1279
+ // willEnterForeground may come before the view is in its
1280
+ // window; didBecomeActive decides.
1281
+ let final = notificationName == UIApplication.didBecomeActiveNotification
1282
+ let isShown = self.backgroundViewStarts.takeAnyShown({ hotUpdaterIsViewShown($0) }, final: final)
1283
+ guard isShown || final else { return }
1284
+ self.removeArmObservers(before: generation + 1)
1285
+ if isShown {
1286
+ self.hub.coordinator.arm(name: self.name, generation: generation, runtimeBundleURL: runtimeBundleURL)
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ /// Drops the foreground observers if they were for a launch before
1293
+ /// `generation`. Main thread only.
1294
+ private func removeArmObservers(before generation: Int) {
1295
+ guard let armGeneration, armGeneration < generation else { return }
1296
+ armObservers.forEach { NotificationCenter.default.removeObserver($0) }
1297
+ armObservers = []
1298
+ self.armGeneration = nil
1299
+ backgroundViewStarts.removeAll()
1300
+ }
1301
+
1302
+ /// Main only: its fatal before first content reloads it in-process with
1303
+ /// the bundle its marker rolls back to.
1304
+ func requestRecoveryReloadIfNeeded(onFailure: @escaping () -> Void) -> Bool {
1305
+ guard isMain else { return false }
1306
+ let request = hub.coordinator.requestRecovery(name: name)
1307
+ if request == .notNeeded {
1308
+ return false
1309
+ }
1310
+
1311
+ hotUpdaterMarkFailedRuntime()
1312
+ guard case .requested(let generation, let bundleId) = request else {
1313
+ return true
1314
+ }
1315
+
1316
+ hotUpdaterAfterSurfaceStarts { [weak self] in
1317
+ guard let self else {
1318
+ return
1319
+ }
1320
+
1321
+ guard self.hub.coordinator.isCurrent(name: self.name, generation: generation) else { return }
1322
+
1323
+ let started = hotUpdaterPerformRecoveryReload()
1324
+ if !started {
1325
+ self.hub.coordinator.recoveryReloadFailed(name: self.name, generation: generation)
1326
+ NSLog("[HotUpdaterRecovery] Failed to trigger recovery reload")
1327
+ onFailure()
1328
+ } else {
1329
+ NSLog("[HotUpdaterRecovery] Triggered recovery reload for bundleId=\(bundleId ?? "nil")")
1330
+ }
1331
+ }
1332
+
1333
+ return true
1334
+ }
1335
+ }
1336
+
1337
+ /// HotUpdater.mm's signal-safe crash slots.
1338
+ private final class HotUpdaterSignalCrashSlots: HotUpdaterCrashSlotSink {
1339
+ func updateSlot(_ slot: Int, markerPath: String, bundleId: String?, shouldRollback: Bool) {
1340
+ hotUpdaterUpdateCrashSlotSymbol(slot, markerPath as NSString, bundleId as NSString?, ObjCBool(shouldRollback))
1341
+ }
1342
+
1343
+ func attributeCrash(toSlot slot: Int) {
1344
+ hotUpdaterAttributeCrashToSlot(slot)
1345
+ }
1346
+ }
1347
+
1348
+ /// The process-wide side of crash recovery. React Native's fatal handlers,
1349
+ /// the uncaught exception handler, the signal handler, and the content and
1350
+ /// load-failure observers exist once per process; each event goes to the
1351
+ /// instance whose runtime it comes from, found through the runtime's
1352
+ /// TurboModule (see HotUpdater.mm). An event no runtime claims is main's, as
1353
+ /// it always was, except that a crash then also counts against every named
1354
+ /// launch that is unverified.
1355
+ @objcMembers
1356
+ final class HotUpdaterRecoveryHub: NSObject {
1357
+ static let shared = HotUpdaterRecoveryHub()
1358
+
1359
+ let coordinator = HotUpdaterRecoveryCoordinator(sink: HotUpdaterSignalCrashSlots())
1360
+ /// The launch each runtime runs, by its module.
1361
+ private let runtimeLaunches = HotUpdaterRuntimeLaunches()
1362
+
1363
+ override init() {
1364
+ super.init()
1365
+ coordinator.onRollbackCharged = { [weak self] name in
1366
+ self?.manager(named: name)?.onRollbackCharged?()
1367
+ }
1368
+ // Off the main queue, which a blocked fatal handler may hold.
1369
+ coordinator.onClaimStarted = { [weak self] in
1370
+ DispatchQueue.global(qos: .utility).asyncAfter(
1371
+ deadline: .now() + HotUpdaterRecoveryCoordinator.claimFailSafe + 0.1
1372
+ ) {
1373
+ self?.coordinator.releaseStaleClaims()
1374
+ }
1375
+ }
1376
+ }
1377
+ private let pendingSurfaceStarts = HotUpdaterPendingSurfaceStarts()
1378
+ private let managersLock = NSLock()
1379
+ private var managers: [String: HotUpdaterRecoveryManager] = [:]
1380
+
1381
+ private var previousFatalHandler: RCTFatalHandler?
1382
+ private var previousFatalExceptionHandler: RCTFatalExceptionHandler?
1383
+ private var previousUncaughtExceptionHandler: (@convention(c) (NSException) -> Void)?
1384
+ private var isInstalled = false
1385
+
1386
+ private static let surfaceWillStartNotification = Notification.Name("HotUpdaterSurfaceWillStart")
1387
+ private static let runtimeDidResolveNotification = Notification.Name("HotUpdaterRuntimeDidResolve")
1388
+ /// The bridgeless runtime's JavaScript thread, where RCTFatal reports its
1389
+ /// JavaScript fatals.
1390
+ private static let runtimeThreadName = "com.facebook.react.runtime.JavaScript"
1391
+
1392
+ func register(_ manager: HotUpdaterRecoveryManager) {
1393
+ managersLock.lock()
1394
+ managers[manager.name] = manager
1395
+ managersLock.unlock()
1396
+ }
1397
+
1398
+ private func manager(named name: String) -> HotUpdaterRecoveryManager? {
1399
+ managersLock.lock()
1400
+ defer { managersLock.unlock() }
1401
+ return managers[name]
1402
+ }
1403
+
1404
+ private var mainName: String { HotUpdaterInstanceEnvironment.mainName }
918
1405
 
919
- installHandlersIfNeeded()
1406
+ /// Once per process, at the first launch of any instance: a process that
1407
+ /// runs only named instances never launches main.
1408
+ func installIfNeeded() {
1409
+ objc_sync_enter(self)
1410
+ defer { objc_sync_exit(self) }
1411
+ guard !isInstalled else {
1412
+ return
1413
+ }
1414
+ isInstalled = true
1415
+
1416
+ installFatalHandlers()
920
1417
  hotUpdaterInstallRecoveryHooks()
921
1418
  registerObservers()
922
- installSignalHandlersIfNeeded()
923
- hotUpdaterUpdateSignalLaunchState(bundleId, shouldRollback: shouldRollback)
1419
+ hotUpdaterInstallCrashSignalHandlers()
924
1420
  }
925
1421
 
926
1422
  func handleUncaughtException(_ exception: NSException) {
927
- writeCrashMarker()
1423
+ coordinator.chargeUncaughtException()
928
1424
  // An uncaught native exception cannot safely continue in this process.
929
1425
  // Preserve the marker so the next launch can select the safe bundle.
930
1426
  previousUncaughtExceptionHandler?(exception)
931
1427
  }
932
1428
 
933
- private func installHandlersIfNeeded() {
934
- guard !handlersInstalled else {
935
- return
936
- }
1429
+ // MARK: - Attribution
1430
+
1431
+ private func owner(of object: AnyObject?) -> String? {
1432
+ guard let object else { return nil }
1433
+ return hotUpdaterImpl(forRootObject: object)?.environment.name
1434
+ }
1435
+
1436
+ /// The instance whose JavaScript thread is failing; a fatal right after a
1437
+ /// load failure already attributed belongs to the same instance. Else
1438
+ /// the instance whose bundle every frame of its JavaScript stack is in.
1439
+ /// Else, on a JavaScript thread whose runtime has no module yet, the one
1440
+ /// launch that can have thrown it, if there is exactly one.
1441
+ /// With the owner, the bundle its failing runtime runs when known, which
1442
+ /// tells a runtime of an earlier launch from the current one.
1443
+ private func fatalOwner(stack: Any?) -> (
1444
+ name: String,
1445
+ runtimeBundleURL: URL?,
1446
+ attribution: HotUpdaterRecoveryCoordinator.FatalAttribution
1447
+ )? {
1448
+ let module = hotUpdaterRuntimeImpl().map { ($0.environment.name, hotUpdaterRuntimeBundleURL()) }
1449
+ let stackOwner = HotUpdaterJSStackOwner.owner(of: stack, resolve: {
1450
+ HotUpdaterNativeUpdateBridge.impl(forBundleURL: $0).environment.name
1451
+ }).map { ($0, HotUpdaterJSStackOwner.bundleURLs(in: stack).first) }
1452
+ return HotUpdaterRecoveryCoordinator.fatalOwner(
1453
+ module: module,
1454
+ stack: stackOwner,
1455
+ claim: coordinator.attributedOwner,
1456
+ inference: {
1457
+ guard Thread.current.name == Self.runtimeThreadName else { return nil }
1458
+ return self.coordinator.inferredFatalOwner()
1459
+ }
1460
+ )
1461
+ }
937
1462
 
1463
+ // MARK: - Fatal errors
1464
+
1465
+ private func installFatalHandlers() {
938
1466
  previousFatalHandler = RCTGetFatalHandler()
939
1467
  previousFatalExceptionHandler = RCTGetFatalExceptionHandler()
940
1468
  previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler()
941
1469
 
942
1470
  RCTSetFatalHandler { [weak self] error in
943
1471
  guard !hotUpdaterIsFailedRuntime().boolValue else { return }
944
- if self?.requestRecoveryReloadIfNeeded(onFailure: {
945
- self?.forwardFatalError(error)
946
- }) != true {
947
- self?.writeCrashMarker()
948
- self?.forwardFatalError(error)
949
- }
1472
+ let stack = (error as NSError?)?.userInfo[RCTJSStackTraceKey]
1473
+ self?.handleFatal(stack: stack) { self?.forwardFatalError(error) }
950
1474
  }
951
1475
 
952
1476
  RCTSetFatalExceptionHandler { [weak self] exception in
953
1477
  guard !hotUpdaterIsFailedRuntime().boolValue else { return }
954
- if self?.requestRecoveryReloadIfNeeded(onFailure: {
955
- self?.forwardFatalException(exception)
956
- }) != true {
957
- self?.writeCrashMarker()
958
- self?.forwardFatalException(exception)
959
- }
1478
+ let stack = exception?.userInfo?[RCTJSStackTraceKey]
1479
+ self?.handleFatal(stack: stack) { self?.forwardFatalException(exception) }
960
1480
  }
961
1481
 
962
1482
  NSSetUncaughtExceptionHandler(hotUpdaterUncaughtExceptionHandler)
963
- handlersInstalled = true
1483
+ }
1484
+
1485
+ private func handleFatal(stack: Any?, forward: @escaping () -> Void) {
1486
+ var attributed = fatalOwner(stack: stack)
1487
+ // A fatal of a module-less runtime (by stack, inference, or none)
1488
+ // takes a deferred load failure; one of a runtime with a module, or
1489
+ // of a claimed crash, leaves it to the runtime that failed.
1490
+ let loadFailure = coordinator.takeDeferredLoadFailure(
1491
+ for: attributed?.attribution ?? .none,
1492
+ owner: attributed?.name
1493
+ )
1494
+ var afterLoadFailure = false
1495
+ if attributed == nil, loadFailure != nil {
1496
+ afterLoadFailure = true
1497
+ // RCTInstance reports a load failure's fatal on the main thread.
1498
+ attributed = coordinator.inferredFatalOwner().map { ($0, nil, .inference) }
1499
+ }
1500
+ let owner = attributed?.name
1501
+ if owner == nil, let claim = coordinator.earlierRuntimeClaim {
1502
+ // The fatal of a runtime of an earlier launch: no launch is charged.
1503
+ forward()
1504
+ coordinator.clearAttribution(claim: claim)
1505
+ return
1506
+ }
1507
+ // A load failure no runtime claimed may be a named runtime's: then
1508
+ // main doesn't recover in-process, and the crash counts against
1509
+ // every unverified launch.
1510
+ if owner == mainName || (owner == nil && !afterLoadFailure), let main = manager(named: mainName) {
1511
+ // Main's fatal, or one no runtime claims: with only main, main
1512
+ // recovers in-process as it always has (never beside a named
1513
+ // instance, see HotUpdaterRecoveryCoordinator.requestRecovery).
1514
+ if main.requestRecoveryReloadIfNeeded(onFailure: { [weak self] in
1515
+ let claim = owner.flatMap { self?.coordinator.attribute(to: $0) }
1516
+ forward()
1517
+ if let claim {
1518
+ self?.coordinator.clearAttribution(claim: claim)
1519
+ }
1520
+ }) {
1521
+ if owner == nil {
1522
+ // Main lives on, but the fatal may have been a named
1523
+ // runtime's: a named launch it leaves unverified rolls back.
1524
+ coordinator.chargeUnverifiedNamedLaunches()
1525
+ }
1526
+ return
1527
+ }
1528
+ }
1529
+ if owner == nil, afterLoadFailure {
1530
+ let claim = coordinator.chargeUnclaimedLoadFailure(loadFailure)
1531
+ forward()
1532
+ coordinator.clearAttribution(claim: claim)
1533
+ return
1534
+ }
1535
+ // A named instance never reloads in-process: it applies updates and
1536
+ // rollbacks at its next start, and main's host must not reload for
1537
+ // another instance's fatal. React Native's default crashes the
1538
+ // process, and the crash is this instance's alone. The claim is
1539
+ // released only once the forwarded handler returns.
1540
+ // By its runtime, the launch it was recorded for, when the module told
1541
+ // it apart.
1542
+ let runtimeGeneration = attributed?.attribution == .runtimeModule
1543
+ ? runtimeLaunches.generation(of: hotUpdaterRuntimeIdentity())
1544
+ : nil
1545
+ let claim = coordinator.chargeFatal(
1546
+ owner: owner,
1547
+ runtimeBundleURL: attributed?.runtimeBundleURL,
1548
+ runtimeGeneration: runtimeGeneration
1549
+ )
1550
+ forward()
1551
+ if let claim {
1552
+ coordinator.clearAttribution(claim: claim)
1553
+ }
964
1554
  }
965
1555
 
966
1556
  private func forwardFatalError(_ error: Error?) {
@@ -990,22 +1580,9 @@ final class HotUpdaterRecoveryManager: NSObject {
990
1580
  }
991
1581
  }
992
1582
 
993
- private func installSignalHandlersIfNeeded() {
994
- guard !signalHandlersInstalled else {
995
- return
996
- }
997
-
998
- try? FileManager.default.createDirectory(
999
- at: crashMarkerURL.deletingLastPathComponent(),
1000
- withIntermediateDirectories: true,
1001
- attributes: nil
1002
- )
1003
- hotUpdaterInstallSignalHandlers(crashMarkerURL.path)
1004
- signalHandlersInstalled = true
1005
- }
1583
+ // MARK: - Observers
1006
1584
 
1007
1585
  private func registerObservers() {
1008
- unregisterObservers()
1009
1586
  NotificationCenter.default.addObserver(
1010
1587
  self,
1011
1588
  selector: #selector(handleJavaScriptDidFailToLoad(_:)),
@@ -1014,21 +1591,20 @@ final class HotUpdaterRecoveryManager: NSObject {
1014
1591
  )
1015
1592
  NotificationCenter.default.addObserver(
1016
1593
  self,
1017
- selector: #selector(handleContentDidAppear),
1594
+ selector: #selector(handleContentDidAppear(_:)),
1018
1595
  name: NSNotification.Name.RCTContentDidAppear,
1019
1596
  object: nil
1020
1597
  )
1021
- }
1022
-
1023
- private func unregisterObservers() {
1024
- NotificationCenter.default.removeObserver(
1598
+ NotificationCenter.default.addObserver(
1025
1599
  self,
1026
- name: NSNotification.Name.RCTJavaScriptDidFailToLoad,
1600
+ selector: #selector(handleSurfaceWillStart(_:)),
1601
+ name: Self.surfaceWillStartNotification,
1027
1602
  object: nil
1028
1603
  )
1029
- NotificationCenter.default.removeObserver(
1604
+ NotificationCenter.default.addObserver(
1030
1605
  self,
1031
- name: NSNotification.Name.RCTContentDidAppear,
1606
+ selector: #selector(handleRuntimeDidResolve(_:)),
1607
+ name: Self.runtimeDidResolveNotification,
1032
1608
  object: nil
1033
1609
  )
1034
1610
  }
@@ -1039,115 +1615,218 @@ final class HotUpdaterRecoveryManager: NSObject {
1039
1615
  code: 1,
1040
1616
  userInfo: [NSLocalizedDescriptionKey: "JavaScript bundle failed to load"]
1041
1617
  )
1042
- if requestRecoveryReloadIfNeeded(onFailure: { [weak self] in
1043
- self?.forwardFatalError(error)
1044
- }) {
1618
+ // Old architecture: the bridge, whose bundle URL names the instance.
1619
+ // Bridgeless: the RCTInstance, whose runtime has no module when its
1620
+ // bundle didn't load, so it is unattributed and main's, as before.
1621
+ let owner = self.owner(of: notification.object as AnyObject?)
1622
+ ?? self.owner(of: notification.userInfo?["bridge"] as AnyObject?)
1623
+
1624
+ // A named runtime that has no module yet may have failed: leave it to
1625
+ // the fatal RCTInstance reports next, which also reads its stack.
1626
+ if owner == nil, coordinator.deferUnclaimedLoadFailure() {
1045
1627
  return
1046
1628
  }
1047
- unregisterObservers()
1048
- }
1049
1629
 
1050
- @objc private func handleContentDidAppear() {
1051
- objc_sync_enter(self)
1052
- defer { objc_sync_exit(self) }
1053
-
1054
- guard isMonitoring, !recoveryRequested else {
1630
+ if let owner, owner != mainName {
1631
+ let runtimeBundleURL = (notification.object as AnyObject?).flatMap(hotUpdaterBundleURL(forRootObject:))
1632
+ ?? (notification.userInfo?["bridge"] as AnyObject?).flatMap(hotUpdaterBundleURL(forRootObject:))
1633
+ let runtime = (notification.object as AnyObject?).flatMap(hotUpdaterRuntimeIdentity(forRootObject:))
1634
+ ?? (notification.userInfo?["bridge"] as AnyObject?).flatMap(hotUpdaterRuntimeIdentity(forRootObject:))
1635
+ let runtimeGeneration = runtimeLaunches.generation(of: runtime)
1636
+ // A runtime of an earlier launch fails, not the current one: the
1637
+ // fatal that follows charges no launch.
1638
+ guard coordinator.isCurrentRuntime(
1639
+ name: owner,
1640
+ runtimeBundleURL: runtimeBundleURL,
1641
+ runtimeGeneration: runtimeGeneration
1642
+ ) else {
1643
+ chargeLoadFailure(owner: owner, runtimeBundleURL: runtimeBundleURL, runtimeGeneration: runtimeGeneration)
1644
+ return
1645
+ }
1646
+ guard let generation = coordinator.awaitingContentGeneration(name: owner) else { return }
1647
+ // React Native's fatal follows and crashes the process: charge
1648
+ // this instance alone, so that fatal is attributed to it too.
1649
+ chargeLoadFailure(owner: owner, runtimeBundleURL: runtimeBundleURL, runtimeGeneration: runtimeGeneration)
1650
+ coordinator.stopAwaitingContent(name: owner, generation: generation)
1651
+ releaseHeldContent()
1055
1652
  return
1056
1653
  }
1057
1654
 
1058
- unregisterObservers()
1059
- contentAppearedCallback?(currentBundleId)
1060
- shouldRollbackOnCrash = false
1061
- hotUpdaterUpdateSignalLaunchState(currentBundleId, shouldRollback: false)
1062
-
1063
- stopMonitoringWorkItem?.cancel()
1064
- let generation = monitoringGeneration
1065
- let workItem = DispatchWorkItem { [weak self] in
1066
- self?.finishMonitoring(generation: generation)
1655
+ guard let main = manager(named: mainName),
1656
+ let generation = coordinator.awaitingContentGeneration(name: mainName) else {
1657
+ return
1067
1658
  }
1068
- stopMonitoringWorkItem = workItem
1069
- DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10), execute: workItem)
1659
+ if main.requestRecoveryReloadIfNeeded(onFailure: { [weak self] in
1660
+ self?.forwardFatalError(error)
1661
+ }) {
1662
+ return
1663
+ }
1664
+ if coordinator.hasNamedInstance {
1665
+ // No in-process reload beside named instances: the fatal React
1666
+ // Native reports next is main's, and main rolls back at its next
1667
+ // start.
1668
+ chargeLoadFailure(owner: mainName, runtimeBundleURL: nil, runtimeGeneration: nil)
1669
+ }
1670
+ coordinator.stopAwaitingContent(name: mainName, generation: generation)
1070
1671
  }
1071
1672
 
1072
- private func finishMonitoring(generation: Int) {
1073
- objc_sync_enter(self)
1074
- defer { objc_sync_exit(self) }
1075
- guard monitoringGeneration == generation else { return }
1076
-
1077
- isMonitoring = false
1078
- recoveryRequested = false
1079
- stopMonitoringWorkItem = nil
1080
- currentBundleId = nil
1081
- shouldRollbackOnCrash = false
1082
- contentAppearedCallback = nil
1083
- hotUpdaterUpdateSignalLaunchState(nil, shouldRollback: false)
1673
+ /// Charges a load failure; its claim is the one the fatal React Native
1674
+ /// reports next takes over and releases. Without that fatal, the claim
1675
+ /// ends when the instance launches again or once the bound passes.
1676
+ private func chargeLoadFailure(owner: String, runtimeBundleURL: URL?, runtimeGeneration: Int?) {
1677
+ coordinator.chargeLoadFailure(
1678
+ owner: owner,
1679
+ runtimeBundleURL: runtimeBundleURL,
1680
+ runtimeGeneration: runtimeGeneration
1681
+ )
1682
+ DispatchQueue.main.asyncAfter(
1683
+ deadline: .now() + HotUpdaterRecoveryCoordinator.loadFailureClaimBound + 0.1
1684
+ ) { [weak self] in
1685
+ self?.coordinator.releaseExpiredLoadFailureClaims()
1686
+ }
1084
1687
  }
1085
1688
 
1086
- private func requestRecoveryReloadIfNeeded(onFailure: @escaping () -> Void) -> Bool {
1087
- objc_sync_enter(self)
1088
- defer { objc_sync_exit(self) }
1089
-
1090
- guard isMonitoring, shouldRollbackOnCrash else {
1091
- return false
1689
+ @objc private func handleContentDidAppear(_ notification: Notification) {
1690
+ let view = notification.object as AnyObject?
1691
+ let owner = owner(of: view)
1692
+ // A view no runtime claims is main's, as it always was, unless a
1693
+ // named runtime that has no module yet may have shown it.
1694
+ if owner == nil, let view, coordinator.holdUnclaimedContent(view) {
1695
+ // Main's at the latest once the bound passes.
1696
+ DispatchQueue.main.asyncAfter(
1697
+ deadline: .now() + HotUpdaterRecoveryCoordinator.heldContentBound + 0.1
1698
+ ) { [weak self] in
1699
+ self?.releaseHeldContent()
1700
+ }
1701
+ return
1092
1702
  }
1093
-
1094
- hotUpdaterMarkFailedRuntime()
1095
- if recoveryRequested {
1096
- return true
1703
+ // Content of a runtime of an earlier launch verifies nothing, and
1704
+ // is not main's either.
1705
+ let runtimeBundleURL = owner == nil ? nil : view.flatMap(hotUpdaterBundleURL(forRootObject:))
1706
+ // The launch the view's runtime runs: content of a runtime torn down
1707
+ // after its host started a replacement with the same bundle verifies
1708
+ // nothing. Unknown (its runtime not told apart yet): by name and
1709
+ // bundle alone, as before.
1710
+ let runtimeGeneration = owner == nil
1711
+ ? nil
1712
+ : runtimeLaunches.generation(of: view.flatMap(hotUpdaterRuntimeIdentity(forRootObject:)))
1713
+ guard let verified = coordinator.contentAppeared(
1714
+ owner: owner,
1715
+ runtimeBundleURL: runtimeBundleURL,
1716
+ runtimeGeneration: runtimeGeneration
1717
+ ) else {
1718
+ return
1097
1719
  }
1098
- recoveryRequested = true
1099
- writeCrashMarker()
1100
-
1101
- let bundleId = currentBundleId
1102
- let generation = monitoringGeneration
1103
- hotUpdaterAfterSurfaceStarts { [weak self] in
1104
- guard let self else {
1105
- return
1106
- }
1720
+ manager(named: verified.launch.name)?.contentAppeared(generation: verified.generation)
1721
+ if verified.launch.name != mainName {
1722
+ releaseHeldContent()
1723
+ }
1724
+ }
1107
1725
 
1108
- objc_sync_enter(self)
1109
- let isCurrentLaunch = self.monitoringGeneration == generation
1110
- objc_sync_exit(self)
1111
- guard isCurrentLaunch else { return }
1726
+ /// Credits the content held for runtimes that had no module yet. Main
1727
+ /// thread only.
1728
+ private func releaseHeldContent() {
1729
+ for verified in coordinator.releaseHeldContent(
1730
+ owner: { self.owner(of: $0) },
1731
+ runtimeBundleURL: { hotUpdaterBundleURL(forRootObject: $0) },
1732
+ runtimeGeneration: { self.runtimeLaunches.generation(of: hotUpdaterRuntimeIdentity(forRootObject: $0)) }
1733
+ ) {
1734
+ manager(named: verified.launch.name)?.contentAppeared(generation: verified.generation)
1735
+ }
1736
+ }
1112
1737
 
1113
- let started = hotUpdaterPerformRecoveryReload()
1114
- if !started {
1115
- objc_sync_enter(self)
1116
- self.recoveryRequested = false
1117
- objc_sync_exit(self)
1118
- NSLog("[HotUpdaterRecovery] Failed to trigger recovery reload")
1119
- onFailure()
1738
+ /// Posted by HotUpdaterRecoveryHooks.mm on the thread that shows a view:
1739
+ /// once a Fabric surface started and whenever its view moves into a
1740
+ /// window, or, in the old architecture, when an RCTRootView moves into a
1741
+ /// window. The launch starts only once a view is shown.
1742
+ @objc private func handleSurfaceWillStart(_ notification: Notification) {
1743
+ guard let surface = notification.object as AnyObject? else { return }
1744
+ // The launches the view started in, read now on the thread that
1745
+ // starts it: by the time the main queue runs `handle`, its host may
1746
+ // have started a replacement launch with the same bundle.
1747
+ let generations = coordinator.generations()
1748
+ let handle = { [weak self] in
1749
+ guard let self else { return }
1750
+ let inForeground = UIApplication.shared.applicationState != .background
1751
+ // Kept from its first start for its later ones.
1752
+ let firstGenerations = self.runtimeLaunches.generationsOfFirstStart(surface, current: generations)
1753
+ if let owner = self.owner(of: surface) {
1754
+ self.viewStarted(
1755
+ owner: owner,
1756
+ view: surface,
1757
+ inForeground: inForeground,
1758
+ generation: self.launchOfView(surface, owner: owner, firstGenerations: firstGenerations)
1759
+ )
1120
1760
  } else {
1121
- NSLog("[HotUpdaterRecovery] Triggered recovery reload for bundleId=\(bundleId ?? "nil")")
1761
+ // The surface starts before its bundle runs, so before the
1762
+ // runtime's module that tells whose it is.
1763
+ self.pendingSurfaceStarts.add(surface, inForeground: inForeground, generations: generations)
1122
1764
  }
1123
1765
  }
1124
-
1125
- return true
1766
+ // UIApplication is read on the main thread.
1767
+ if Thread.isMainThread {
1768
+ handle()
1769
+ } else {
1770
+ DispatchQueue.main.async(execute: handle)
1771
+ }
1126
1772
  }
1127
1773
 
1128
- private func writeCrashMarker() {
1129
- objc_sync_enter(self)
1130
- defer { objc_sync_exit(self) }
1131
-
1132
- guard isMonitoring else {
1133
- return
1774
+ /// Posted by HotUpdater.mm when a runtime's module knows its instance.
1775
+ @objc private func handleRuntimeDidResolve(_ notification: Notification) {
1776
+ if let impl = notification.userInfo?["impl"] as? HotUpdaterImpl {
1777
+ let name = impl.environment.name
1778
+ let runtimeBundleURL = notification.userInfo?["bundleURL"] as? URL
1779
+ coordinator.runtimeResolved(name: name, runtimeBundleURL: runtimeBundleURL)
1780
+ // The launch this runtime runs: the current one, when its bundle
1781
+ // is that launch's. A runtime of an earlier launch gets none.
1782
+ // Keyed by the runtime's identity: its module, or in the old
1783
+ // architecture its bridge, which root views also reach.
1784
+ if let runtime = (notification.userInfo?["runtime"] ?? notification.object) as AnyObject?,
1785
+ coordinator.isCurrentRuntime(name: name, runtimeBundleURL: runtimeBundleURL),
1786
+ let generation = coordinator.generations()[name] {
1787
+ runtimeLaunches.record(runtime: runtime, generation: generation)
1788
+ }
1134
1789
  }
1790
+ DispatchQueue.main.async { [weak self] in
1791
+ guard let self else { return }
1792
+ // Content first: a launch whose content already appeared is
1793
+ // verified, and a surface start then no longer starts it.
1794
+ self.releaseHeldContent()
1795
+ // Decided by the app's state now, and whether the surface is
1796
+ // shown now: the app may have come to the foreground, and the
1797
+ // surface may have been attached, since it started.
1798
+ let inForegroundNow = UIApplication.shared.applicationState != .background
1799
+ for started in self.pendingSurfaceStarts.resolve({ self.owner(of: $0) }) {
1800
+ self.viewStarted(
1801
+ owner: started.name,
1802
+ view: started.surface,
1803
+ inForeground: inForegroundNow,
1804
+ generation: self.launchOfView(
1805
+ started.surface,
1806
+ owner: started.name,
1807
+ firstGenerations: self.runtimeLaunches.generationsOfFirstStart(started.surface, current: [:])
1808
+ ) ?? started.generation
1809
+ )
1810
+ }
1811
+ }
1812
+ }
1135
1813
 
1136
- do {
1137
- try FileManager.default.createDirectory(
1138
- at: crashMarkerURL.deletingLastPathComponent(),
1139
- withIntermediateDirectories: true,
1140
- attributes: nil
1141
- )
1814
+ /// The launch a view started for: the one its runtime was recorded for,
1815
+ /// else the one current at its first start, so its later attachment
1816
+ /// after a replacement launch began starts nothing. Main thread only.
1817
+ private func launchOfView(_ view: AnyObject, owner: String, firstGenerations: [String: Int]) -> Int? {
1818
+ runtimeLaunches.generation(of: hotUpdaterRuntimeIdentity(forRootObject: view)) ?? firstGenerations[owner]
1819
+ }
1142
1820
 
1143
- let payload: [String: Any] = [
1144
- "bundleId": currentBundleId ?? NSNull(),
1145
- "shouldRollback": shouldRollbackOnCrash,
1146
- ]
1147
- let data = try JSONSerialization.data(withJSONObject: payload)
1148
- try data.write(to: crashMarkerURL, options: .atomic)
1149
- } catch {
1150
- NSLog("[HotUpdaterRecovery] Failed to write crash marker: \(error)")
1151
- }
1821
+ private func viewStarted(owner: String, view: AnyObject, inForeground: Bool, generation: Int?) {
1822
+ // Main's launch starts when it is selected, as it always has; a view
1823
+ // that started before its instance launched starts nothing.
1824
+ guard owner != mainName, let generation else { return }
1825
+ manager(named: owner)?.viewStarted(
1826
+ view,
1827
+ inForeground: inForeground,
1828
+ generation: generation,
1829
+ runtimeBundleURL: hotUpdaterBundleURL(forRootObject: view)
1830
+ )
1152
1831
  }
1153
1832
  }