@hot-updater/react-native 1.0.8 → 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 (145) 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/ContentAppearedLog.kt +0 -31
  143. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  144. package/android/src/newarch/ReactIntegrationManager.kt +0 -212
  145. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -1,68 +1,96 @@
1
1
  package com.hotupdater
2
2
 
3
+ import android.app.Activity
3
4
  import android.app.AlarmManager
4
5
  import android.app.PendingIntent
5
6
  import android.content.Context
6
7
  import android.content.Intent
7
- import android.os.Handler
8
- import android.os.Looper
9
8
  import android.os.Process
10
9
  import android.os.SystemClock
11
10
  import android.util.Log
12
11
  import androidx.core.util.AtomicFile
13
- import com.facebook.react.ReactApplication
14
12
  import com.facebook.react.bridge.JSExceptionHandler
15
13
  import com.facebook.react.bridge.ReactContext
16
14
  import com.facebook.react.bridge.ReactMarker
17
15
  import com.facebook.react.bridge.ReactMarkerConstants
16
+ import com.facebook.react.common.JavascriptException
18
17
  import org.json.JSONObject
19
18
  import java.io.File
20
19
  import java.io.FileOutputStream
20
+ import java.io.IOException
21
+ import java.lang.ref.WeakReference
21
22
  import java.lang.reflect.Field
23
+ import java.util.WeakHashMap
24
+ import java.util.concurrent.ConcurrentHashMap
25
+ import java.util.concurrent.Executors
26
+ import java.util.concurrent.TimeUnit
27
+ import java.util.concurrent.atomic.AtomicLong
22
28
  import kotlin.system.exitProcess
23
29
 
30
+ /**
31
+ * Watches one instance's launch until its first content appears: its crash
32
+ * marker lives in its own bundle store, and a crash charges it only when the
33
+ * crash is its own (see [HotUpdaterRecoverySlots]). Main restarts the app to
34
+ * recover, as it always has; a named instance leaves its marker for its next
35
+ * launch to roll back.
36
+ */
24
37
  internal class HotUpdaterRecoveryManager(
25
38
  context: Context,
39
+ environment: HotUpdaterInstanceEnvironment = HotUpdaterInstanceEnvironment.main,
40
+ private val mainThread: HotUpdaterMainThread = HotUpdaterMainThread.android,
26
41
  ) {
27
42
  private val appContext = context.applicationContext
28
- private val mainHandler = Handler(Looper.getMainLooper())
29
- private val bundleStoreDir = getBundleStoreDir(appContext)
30
- private val crashMarkerFile = File(bundleStoreDir, CRASH_MARKER_FILENAME)
31
- private val watchdogStateFile = File(bundleStoreDir, WATCHDOG_STATE_FILENAME)
43
+ private val name = environment.name
44
+ private val isMain = environment.isMain
45
+ private val crashMarkerFile = getCrashMarkerFile(appContext, environment)
32
46
 
33
47
  private val launchState = HotUpdaterRecoveryState()
34
- private var contentAppearedCallback: ((String?) -> Unit)? = null
35
- private var recoveryRestartCallback: (() -> Boolean)? = null
36
48
 
37
- private val stopMonitoringRunnable =
38
- Runnable {
39
- cancelRecoveryWatchdog()
40
- Log.d(TAG, "Stopping crash monitoring for current launch")
41
- launchState.stop()
42
- contentAppearedCallback = null
43
- recoveryRestartCallback = null
44
- activeManager = null
45
- updateNativeLaunchState(null, false)
46
- }
49
+ // Launches start on whichever thread prepares them, while first content,
50
+ // views, delayed callbacks, and JavaScript errors arrive on others. Every
51
+ // change to this launch's state happens under this lock, and work posted
52
+ // for a launch carries its generation, so it never acts on a newer one.
53
+ private val lock = recoveryLock
54
+ private var generation = 0L
47
55
 
48
- private val installJsExceptionHooksRunnable =
49
- object : Runnable {
50
- override fun run() {
51
- if (launchState.snapshot() == null) {
52
- return
53
- }
56
+ // Main's launch counts from the start, as it always has. A named launch
57
+ // counts once one of its views is shown ([armLaunch]): a runtime pre-warmed
58
+ // without UI must not be charged for a crash elsewhere in the process.
59
+ private var armed = isMain
54
60
 
55
- installJavaScriptExceptionHooks()
56
- mainHandler.postDelayed(this, JS_EXCEPTION_HOOK_RETRY_DELAY_MS)
57
- }
58
- }
61
+ // Another instance's fatal error is ending the process, so this launch
62
+ // must not be armed in the moments left.
63
+ private var endedByAnotherInstance = false
59
64
 
60
- private val contentAppearedListener =
61
- ReactMarker.MarkerListener { name, _, _ ->
62
- if (name == ReactMarkerConstants.CONTENT_APPEARED) {
63
- handleContentAppeared()
64
- }
65
- }
65
+ // What arms this launch for the next process, kept so a launch another
66
+ // instance's claimed error disarmed is armed again when that error turns
67
+ // out not to end the process.
68
+ private var launchStartedAction: (() -> Unit)? = null
69
+
70
+ // A rollback marker was written for this launch: its native slot asks
71
+ // for rollback too, armed or not, so a signal before the process ends
72
+ // writes it again rather than leaving it out.
73
+ private var charged = false
74
+ private var contentAppearedCallback: ((String?) -> Unit)? = null
75
+ private var crashOfAnotherInstanceCallback: ((String?) -> Unit)? = null
76
+ private var recoveryRestartCallback: (() -> Boolean)? = null
77
+ private var runtimeActivityCallback: ((Activity) -> Unit)? = null
78
+ private var chargedCallback: (() -> Unit)? = null
79
+ private var stopTask: Runnable? = null
80
+ private var hookTask: Runnable? = null
81
+
82
+ // The bundle this launch handed to React Native, and the runtimes running
83
+ // it whose HotUpdater module hooks their errors. A runtime of an earlier
84
+ // launch whose module is created late runs another bundle, so it never
85
+ // counts for this launch.
86
+ private var launchURL: String? = null
87
+ private val registeredRuntimes = WeakIdentitySet<Any>()
88
+
89
+ init {
90
+ attribution.track(appContext)
91
+ watchdogContext = appContext
92
+ if (!isMain) namedInstanceCreated = true
93
+ }
66
94
 
67
95
  fun consumePendingCrashRecovery(): PendingCrashRecovery? {
68
96
  if (!crashMarkerFile.exists()) {
@@ -73,7 +101,7 @@ internal class HotUpdaterRecoveryManager(
73
101
  val recovery = PendingCrashRecovery.loadFromFile(crashMarkerFile) ?: return null
74
102
  Log.d(
75
103
  TAG,
76
- "Consumed pending crash marker bundleId=${recovery.launchedBundleId} shouldRollback=${recovery.shouldRollback}",
104
+ "Consumed pending crash marker of $name bundleId=${recovery.launchedBundleId} shouldRollback=${recovery.shouldRollback}",
77
105
  )
78
106
  recovery
79
107
  } catch (e: Exception) {
@@ -84,71 +112,238 @@ internal class HotUpdaterRecoveryManager(
84
112
  }
85
113
  }
86
114
 
115
+ /**
116
+ * @param onCrashOfAnotherInstance called when another instance's
117
+ * JavaScript fatal error is about to end the process: this launch did not
118
+ * fail, so it must not count as unfinished at the next start
119
+ * @param onRecoveryRestartRequested restarts the app to recover; null
120
+ * leaves recovery to the next launch
121
+ * @param onRuntimeActivity called with the activity of this instance's
122
+ * runtime once its HotUpdater module is created, which may show its view
123
+ * in an activity that started before anything followed activities
124
+ * @param launchURL the bundle this launch hands to React Native, which
125
+ * the runtimes of this launch load; null accepts any runtime
126
+ * @param onCharged called when a named launch is charged with a crash
127
+ * marker, so the instance's next start prepares a launch that reads it
128
+ * @return the launch's generation, which [armLaunch] takes
129
+ */
87
130
  fun startMonitoring(
88
131
  bundleId: String?,
89
132
  shouldRollback: Boolean,
90
133
  onContentAppeared: (String?) -> Unit,
91
- onRecoveryRestartRequested: () -> Boolean,
92
- ) {
134
+ onCrashOfAnotherInstance: (String?) -> Unit,
135
+ onRecoveryRestartRequested: (() -> Boolean)?,
136
+ onRuntimeActivity: ((Activity) -> Unit)? = null,
137
+ onCharged: (() -> Unit)? = null,
138
+ launchURL: String? = null,
139
+ ): Long {
93
140
  crashMarkerFile.parentFile?.mkdirs()
94
- contentAppearedCallback = onContentAppeared
95
- recoveryRestartCallback = onRecoveryRestartRequested
96
- launchState.start(bundleId, shouldRollback)
97
- activeManager = this
98
-
99
141
  ensureExceptionHandlerInstalled()
142
+ ensureContentAppearedListenerInstalled()
143
+
144
+ val generation =
145
+ synchronized(lock) {
146
+ // Unique in the process, so a claim names one launch of one manager.
147
+ generation = launchGenerations.incrementAndGet()
148
+ cancelScheduledLocked()
149
+ contentAppearedCallback = onContentAppeared
150
+ crashOfAnotherInstanceCallback = onCrashOfAnotherInstance
151
+ recoveryRestartCallback = onRecoveryRestartRequested
152
+ runtimeActivityCallback = onRuntimeActivity
153
+ chargedCallback = onCharged
154
+ // A new launch runs in a new runtime, which registers when its
155
+ // module is created; until then its errors are not hooked.
156
+ this.launchURL = launchURL
157
+ registeredRuntimes.clear()
158
+ launchState.start(bundleId, shouldRollback)
159
+ armed = isMain
160
+ launchStartedAction = null
161
+ charged = false
162
+ // A launch that starts while another instance's claimed error
163
+ // may still end the process joins that claim: it is armed only
164
+ // once the claim is released and the process lived on.
165
+ endedByAnotherInstance = joinActiveClaim(this, generation)
166
+ liveManagers[name] = this
167
+ updateNativeLaunchStateLocked()
168
+ if (shouldRollback && armed) {
169
+ startRecoveryWatchdog()
170
+ } else {
171
+ releaseRecoveryWatchdog()
172
+ }
173
+ // A named instance's runtime hooks its errors when its module
174
+ // is created (registerRuntime); main's host is reachable from the app.
175
+ if (isMain) installJavaScriptExceptionHooksLocked(generation)
176
+ generation
177
+ }
178
+ // The signal handler only once this launch's slot is published: a
179
+ // signal from the moment it is installed finds the slot it needs.
180
+ // The slots are static in the native library, written before any
181
+ // handler reads them.
100
182
  ensureNativeSignalHandlerInstalled()
101
183
 
102
- mainHandler.removeCallbacks(installJsExceptionHooksRunnable)
103
- mainHandler.removeCallbacks(stopMonitoringRunnable)
104
- ReactMarker.removeListener(contentAppearedListener)
105
- ReactMarker.addListener(contentAppearedListener)
106
- updateNativeLaunchState(bundleId, shouldRollback)
107
- if (shouldRollback) {
108
- startRecoveryWatchdog()
109
- } else {
110
- cancelRecoveryWatchdog()
111
- }
112
- mainHandler.post(installJsExceptionHooksRunnable)
184
+ Log.d(TAG, "Started crash monitoring of $name bundleId=$bundleId shouldRollback=$shouldRollback")
185
+ return generation
186
+ }
113
187
 
114
- Log.d(TAG, "Started crash monitoring bundleId=$bundleId shouldRollback=$shouldRollback")
188
+ /**
189
+ * One of this instance's views is shown, so a crash from now on may be
190
+ * its launch's, when [generation] is still the current launch: a gate's
191
+ * action may run after a newer launch started. [markLaunchStarted]
192
+ * records the launch for the next process unless another instance's
193
+ * crash is already ending this one.
194
+ */
195
+ fun armLaunch(
196
+ generation: Long,
197
+ markLaunchStarted: () -> Unit,
198
+ ) {
199
+ synchronized(lock) {
200
+ if (generation != this.generation) return
201
+ // Content already verified the launch, as when its runtime
202
+ // rendered before its view could be found.
203
+ if (launchState.hasContentAppeared()) return
204
+ launchStartedAction = markLaunchStarted
205
+ if (endedByAnotherInstance) return
206
+ // The native slot asks for rollback before any file is written,
207
+ // then the watchdog, then the launch is recorded in storage: a
208
+ // signal during either write still rolls the launch back. An
209
+ // instance that found every slot taken gets no marker from a
210
+ // signal, so its launch is recorded first: from the moment it
211
+ // counts as armed, a signal leaves it unfinished in storage.
212
+ val recordsFirst = !armed && slots.slotOf(name) == null
213
+ if (recordsFirst) markLaunchStarted()
214
+ if (!armed) {
215
+ armed = true
216
+ updateNativeLaunchStateLocked()
217
+ if (launchState.snapshot()?.shouldRollback == true) {
218
+ startRecoveryWatchdog()
219
+ }
220
+ }
221
+ if (!recordsFirst) markLaunchStarted()
222
+ }
115
223
  }
116
224
 
117
- private fun handleContentAppeared() {
118
- val launch = launchState.completeLaunch() ?: return
119
- Log.d(TAG, "First content appeared for bundleId=${launch.bundleId}")
120
- ReactMarker.removeListener(contentAppearedListener)
121
- mainHandler.removeCallbacks(installJsExceptionHooksRunnable)
122
- mainHandler.removeCallbacks(stopMonitoringRunnable)
123
- contentAppearedCallback?.invoke(launch.bundleId)
124
- updateNativeLaunchState(launch.bundleId, false)
125
- cancelRecoveryWatchdog()
126
- mainHandler.postDelayed(stopMonitoringRunnable, MONITORING_GRACE_PERIOD_MS)
225
+ private fun currentLaunch(): HotUpdaterRecoverySlots.Launch? =
226
+ synchronized(lock) {
227
+ launchState.snapshot()?.let {
228
+ // A launch another instance's claim left alone is not armed
229
+ // for the crash that claim may end in.
230
+ HotUpdaterRecoverySlots.Launch(
231
+ name,
232
+ it.bundleId,
233
+ it.shouldRollback,
234
+ armed && !endedByAnotherInstance,
235
+ launchState.hasContentAppeared(),
236
+ )
237
+ }
238
+ }
239
+
240
+ /** First content of this instance's runtime; [expectedGeneration] limits it to that launch. */
241
+ private fun handleContentAppeared(expectedGeneration: Long? = null) {
242
+ synchronized(lock) {
243
+ if (expectedGeneration != null && expectedGeneration != generation) return
244
+ val launch = launchState.completeLaunch() ?: return
245
+ Log.d(TAG, "First content appeared for $name bundleId=${launch.bundleId}")
246
+ cancelScheduledLocked()
247
+ // The slot stops asking before storage records the verification.
248
+ updateNativeLaunchStateLocked()
249
+ contentAppearedCallback?.invoke(launch.bundleId)
250
+ releaseRecoveryWatchdog()
251
+ val generation = generation
252
+ stopTask = Runnable { stopMonitoring(generation) }.also { mainThread.postDelayed(it, MONITORING_GRACE_PERIOD_MS) }
253
+ }
127
254
  }
128
255
 
129
- private fun ensureExceptionHandlerInstalled() {
130
- if (exceptionHandlerInstalled) {
131
- return
256
+ /** Ends the grace period of the launch of [generation], unless a newer launch started. */
257
+ private fun stopMonitoring(generation: Long) {
258
+ synchronized(lock) {
259
+ if (generation != this.generation) return
260
+ stopTask = null
261
+ releaseRecoveryWatchdog()
262
+ Log.d(TAG, "Stopping crash monitoring for current launch of $name")
263
+ launchState.stop()
264
+ contentAppearedCallback = null
265
+ crashOfAnotherInstanceCallback = null
266
+ recoveryRestartCallback = null
267
+ runtimeActivityCallback = null
268
+ chargedCallback = null
269
+ liveManagers.remove(name, this)
270
+ updateNativeLaunchStateLocked()
132
271
  }
272
+ }
133
273
 
134
- previousExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
135
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
136
- val manager = activeManager
137
- if (manager?.requestAutomaticRecovery() == true) {
138
- // The separate restart activity kills this process after it starts.
139
- // A second fatal error must not kill it while that request is in flight.
140
- return@setDefaultUncaughtExceptionHandler
141
- }
274
+ private fun cancelScheduledLocked() {
275
+ stopTask?.let(mainThread::remove)
276
+ hookTask?.let(mainThread::remove)
277
+ stopTask = null
278
+ hookTask = null
279
+ }
142
280
 
143
- previousExceptionHandler?.uncaughtException(thread, throwable)
144
- ?: run {
145
- Process.killProcess(Process.myPid())
146
- exitProcess(10)
281
+ // Keeps hooking main's host until the launch of [generation] ends: the
282
+ // host creates its React instance later.
283
+ private fun installJavaScriptExceptionHooksLocked(generation: Long) {
284
+ hookTask =
285
+ object : Runnable {
286
+ override fun run() {
287
+ synchronized(lock) {
288
+ if (generation != this@HotUpdaterRecoveryManager.generation || launchState.snapshot() == null) return
289
+ }
290
+ installJavaScriptExceptionHooks()
291
+ synchronized(lock) {
292
+ if (generation == this@HotUpdaterRecoveryManager.generation && hookTask === this) {
293
+ mainThread.postDelayed(this, JS_EXCEPTION_HOOK_RETRY_DELAY_MS)
294
+ }
295
+ }
147
296
  }
148
- }
149
- exceptionHandlerInstalled = true
297
+ }.also(mainThread::post)
150
298
  }
151
299
 
300
+ /**
301
+ * Records [runtime], which loaded [sourceURL], as one of this
302
+ * launch's, and shows the launch gate its [activity]; false for a runtime
303
+ * of an earlier launch, which runs another bundle.
304
+ */
305
+ private fun registerRuntime(
306
+ runtime: Any,
307
+ sourceURL: String?,
308
+ activity: Activity?,
309
+ ): Boolean {
310
+ val callback =
311
+ synchronized(lock) {
312
+ val launchURL = launchURL
313
+ if (launchState.snapshot() == null) return false
314
+ if (launchURL != null && !isSameBundle(sourceURL, launchURL)) return false
315
+ // A runtime registered for an earlier launch, whose module is
316
+ // created again, stays that launch's.
317
+ runtimeLaunches[runtime]?.let { (instanceName, launch) -> if (instanceName == name && launch != generation) return false }
318
+ registeredRuntimes.add(runtime)
319
+ runtimeLaunches[runtime] = name to generation
320
+ runtimeActivityCallback
321
+ }
322
+ if (activity != null) callback?.invoke(activity)
323
+ return true
324
+ }
325
+
326
+ private fun unregisterRuntime(runtime: Any) {
327
+ synchronized(lock) { registeredRuntimes.remove(runtime) }
328
+ }
329
+
330
+ private fun hasRegisteredRuntime(): Boolean = synchronized(lock) { !registeredRuntimes.isEmpty() }
331
+
332
+ /** Whether a runtime that loaded [sourceURL] runs this launch's bundle. */
333
+ private fun runsLaunchOf(sourceURL: String?): Boolean =
334
+ synchronized(lock) {
335
+ val launchURL = launchURL ?: return@synchronized true
336
+ isSameBundle(sourceURL, launchURL)
337
+ }
338
+
339
+ private fun currentGeneration(): Long = synchronized(lock) { generation }
340
+
341
+ /** Whether the launch of [generation] is still the one monitored. */
342
+ fun monitorsLaunch(generation: Long): Boolean = synchronized(lock) { generation == this.generation && launchState.snapshot() != null }
343
+
344
+ /** A launch waiting for its first content, whose root view may not be found yet. */
345
+ private fun awaitsFirstContent(): Boolean = synchronized(lock) { launchState.snapshot() != null && !launchState.hasContentAppeared() }
346
+
152
347
  private fun writeCrashMarker(launch: HotUpdaterRecoveryState.Launch) {
153
348
  val atomicFile = AtomicFile(crashMarkerFile)
154
349
  var output: FileOutputStream? = null
@@ -168,26 +363,54 @@ internal class HotUpdaterRecoveryManager(
168
363
  }
169
364
  }
170
365
 
366
+ // The owners, the intents, the state file, and the alarm change together
367
+ // (see the companion's watchdog operations).
171
368
  private fun startRecoveryWatchdog() {
172
- try {
173
- watchdogStateFile.parentFile?.mkdirs()
174
- watchdogStateFile.writeText((System.currentTimeMillis() + MONITORING_GRACE_PERIOD_MS).toString())
175
- scheduleRecoveryWatchdogTick(appContext, WATCHDOG_TICK_INTERVAL_MS)
176
- } catch (e: Exception) {
177
- Log.e(TAG, "Failed to schedule recovery watchdog", e)
369
+ synchronized(recoveryLock) { startWatchdogLocked(appContext, name) }
370
+ }
371
+
372
+ /**
373
+ * Keeps the watchdog relaunching the app for this instance's claimed
374
+ * error while its claim is active: the host may prepare a replacement
375
+ * launch before the error ends the process, which reads the marker and
376
+ * leaves no other trace a later process could relaunch for.
377
+ */
378
+ private fun keepRelaunchIntentLocked() {
379
+ addRelaunchIntentLocked(appContext, name)
380
+ }
381
+
382
+ /** This instance's claimed error of the launch of [ownerGeneration] did not end the process. */
383
+ private fun leaveWatchdogAfterSurvivedError(ownerGeneration: Long?) {
384
+ synchronized(lock) {
385
+ removeRelaunchIntentLocked(appContext, name)
386
+ if (ownerGeneration != generation) return
387
+ releaseRecoveryWatchdog()
178
388
  }
179
389
  }
180
390
 
181
- private fun cancelRecoveryWatchdog() {
182
- watchdogStateFile.delete()
183
- cancelRecoveryWatchdogAlarm(appContext)
391
+ // One alarm serves the process: it stops once no instance waits for content.
392
+ private fun releaseRecoveryWatchdog() {
393
+ synchronized(recoveryLock) { removeWatchdogOwnerLocked(appContext, name) }
184
394
  }
185
395
 
186
- private fun requestAutomaticRecovery(exception: Exception? = null): Boolean {
396
+ /**
397
+ * Main's recovery: a launch waiting for its first content restarts the
398
+ * app once and leaves its marker for the restarted process.
399
+ */
400
+ private fun requestAutomaticRecoveryLocked(
401
+ exception: Exception? = null,
402
+ onSlotAsking: (() -> Unit)? = null,
403
+ ): Boolean {
187
404
  val request = launchState.requestRecovery()
188
405
  if (exception != null) {
189
406
  Log.e(TAG, "Caught React startup exception for bundleId=${launchState.snapshot()?.bundleId}", exception)
190
407
  }
408
+ // The same order as a named launch's charge: the slot asks for
409
+ // rollback (even one another claim left alone), then the claim, then
410
+ // the marker and the restart.
411
+ charged = true
412
+ updateNativeLaunchStateLocked()
413
+ onSlotAsking?.invoke()
191
414
  if (request == null) {
192
415
  launchState.snapshot()?.let(::writeCrashMarker)
193
416
  return false
@@ -204,78 +427,238 @@ internal class HotUpdaterRecoveryManager(
204
427
  return started
205
428
  }
206
429
 
207
- private fun handleJavaScriptException(exception: Exception): Boolean = requestAutomaticRecovery(exception)
208
-
209
- private fun ensureNativeSignalHandlerInstalled() {
210
- if (signalHandlerInstalled || !loadNativeLibrary()) {
211
- return
430
+ /**
431
+ * Charges this instance for the crash that ends the process, whichever
432
+ * path decided it: its own claimed error, an inferred or unclaimed
433
+ * JavaScript error, or an uncaught exception. A named instance never
434
+ * restarts the app: restarting reaches main's host, so its next launch
435
+ * rolls the bundle back instead.
436
+ *
437
+ * @param onSlotAsking runs once this launch's slot asks for rollback,
438
+ * before any file is written: a claim publishes itself there
439
+ * @param keepRelaunchIntent keeps the watchdog relaunching for a claimed
440
+ * error, whose marker a replacement launch may read first
441
+ */
442
+ private fun chargeCrash(
443
+ exception: Exception? = null,
444
+ onSlotAsking: (() -> Unit)? = null,
445
+ keepRelaunchIntent: Boolean = false,
446
+ ): Boolean =
447
+ synchronized(lock) {
448
+ if (isMain) return@synchronized requestAutomaticRecoveryLocked(exception, onSlotAsking)
449
+ val launch = launchState.snapshot()?.takeIf { it.shouldRollback } ?: return@synchronized false
450
+ if (exception != null) {
451
+ Log.e(TAG, "Caught React startup exception of $name for bundleId=${launch.bundleId}", exception)
452
+ }
453
+ writeRollbackMarkerLocked(launch, onSlotAsking, keepRelaunchIntent)
454
+ false
212
455
  }
213
456
 
214
- try {
215
- nativeInstallSignalHandler(crashMarkerFile.absolutePath)
216
- signalHandlerInstalled = true
217
- } catch (e: UnsatisfiedLinkError) {
218
- Log.w(TAG, "Signal handler not available", e)
457
+ /**
458
+ * The one place a named launch's rollback marker is written. The process
459
+ * may outlive it: a fatal error's handler may return, and the host start
460
+ * this instance's runtime again, so everything that start needs to roll
461
+ * the bundle back happens here too.
462
+ */
463
+ private fun writeRollbackMarkerLocked(
464
+ launch: HotUpdaterRecoveryState.Launch,
465
+ onSlotAsking: (() -> Unit)? = null,
466
+ keepRelaunchIntent: Boolean = false,
467
+ ) {
468
+ // The native slot asks for rollback first, with no file written: a
469
+ // signal from here on writes the marker natively.
470
+ charged = true
471
+ updateNativeLaunchStateLocked()
472
+ // An instance that found every slot taken gets no marker from a
473
+ // signal, and a launch none of whose views was shown has no
474
+ // launchInProgress: its marker is written first, so a signal from
475
+ // the claim on still rolls it back, after the watchdog's alarm, so a
476
+ // signal from the marker on relaunches the app. A receiver in a
477
+ // process that never set the alarm relaunches for any marker, with
478
+ // or without the watchdog's state, and clears the alarm without one.
479
+ val slotted = slots.slotOf(name) != null
480
+ if (!slotted) {
481
+ setWatchdogAlarmLocked(appContext)
482
+ writeCrashMarker(launch)
219
483
  }
484
+ // Then a claim publishes itself, still with no file written for a
485
+ // slotted launch: a signal from here on writes only this launch's marker.
486
+ onSlotAsking?.invoke()
487
+ // Then the watchdog, file and alarm: a launch none of whose views was
488
+ // shown has none yet, and the marker must relaunch the app.
489
+ startRecoveryWatchdog()
490
+ if (keepRelaunchIntent) keepRelaunchIntentLocked()
491
+ // A slotted launch's marker file last, with its alarm already set.
492
+ if (slotted) writeCrashMarker(launch)
493
+ // The next start prepares a new launch, which reads this marker.
494
+ chargedCallback?.invoke()
220
495
  }
221
496
 
222
- private fun updateNativeLaunchState(
223
- bundleId: String?,
224
- shouldRollback: Boolean,
225
- ) {
226
- if (!signalHandlerInstalled || !nativeLibraryLoaded) {
227
- return
497
+ /**
498
+ * Leaves this launch alone for another instance's crash: the generation
499
+ * of the launch it left, or null when the launch wasn't waiting for its
500
+ * content.
501
+ */
502
+ private fun handleCrashOfAnotherInstance(): Long? =
503
+ synchronized(lock) {
504
+ val launch = launchState.snapshot()?.takeIf { it.shouldRollback } ?: return@synchronized null
505
+ endedByAnotherInstance = true
506
+ // The slot stops asking before storage forgets the launch.
507
+ updateNativeLaunchStateLocked()
508
+ crashOfAnotherInstanceCallback?.invoke(launch.bundleId)
509
+ generation
228
510
  }
229
511
 
230
- try {
231
- nativeUpdateLaunchState(bundleId, shouldRollback)
232
- } catch (e: UnsatisfiedLinkError) {
233
- Log.w(TAG, "Failed to update native launch state", e)
512
+ /**
513
+ * Another instance's claimed error did not end the process: the launch of
514
+ * [generation] counts again, unless a newer launch started since.
515
+ */
516
+ private fun resumeAfterAnotherInstanceSurvived(generation: Long) {
517
+ synchronized(lock) {
518
+ if (generation != this.generation || !endedByAnotherInstance) return
519
+ endedByAnotherInstance = false
520
+ updateNativeLaunchStateLocked()
521
+ val action = launchStartedAction ?: return
522
+ if (launchState.snapshot()?.shouldRollback != true) return
523
+ armLaunch(generation, action)
234
524
  }
235
525
  }
236
526
 
527
+ // Two instances may prepare their launches at once. Installing twice
528
+ // would record Hot Updater's own handler as the one to forward to.
529
+ private fun ensureNativeSignalHandlerInstalled() {
530
+ synchronized(signalHandlerLock) {
531
+ if (signalHandlerInstalled) return
532
+ signalHandlerInstalled = nativeRecovery.installSignalHandler()
533
+ }
534
+ }
535
+
536
+ // Under the manager's lock, so the native slot follows this launch's
537
+ // transitions in order.
538
+ private fun updateNativeLaunchStateLocked() {
539
+ val slot = slots.slotOf(name) ?: return
540
+ val launch = currentLaunch()
541
+ nativeRecovery.updateSlot(
542
+ slot,
543
+ crashMarkerFile.absolutePath,
544
+ launch?.bundleId,
545
+ launch?.unverified == true || (charged && launch?.shouldRollback == true),
546
+ isMain,
547
+ )
548
+ }
549
+
237
550
  // Each architecture's source set reaches its own React instance, so the old
238
551
  // architecture's ReactNativeHost is compiled only into old architecture builds.
239
552
  private fun installJavaScriptExceptionHooks() {
240
- val application = appContext as? ReactApplication ?: return
241
- JavaScriptExceptionHooks.install(application)
553
+ JavaScriptExceptionHooks.install(appContext)
242
554
  }
243
555
 
244
- private fun loadNativeLibrary(): Boolean {
245
- if (nativeLibraryLoadAttempted) {
246
- return nativeLibraryLoaded
556
+ internal class RecoveryJSExceptionHandler(
557
+ private val previousHandler: JSExceptionHandler?,
558
+ private val sourceURL: () -> String?,
559
+ /** The runtime that threw, whose registered launch tells it apart from a replacement of the same bundle. */
560
+ private val runtime: () -> Any? = { null },
561
+ ) : JSExceptionHandler {
562
+ override fun handleException(e: Exception) {
563
+ val outcome = handleJavaScriptError(e, runCatching(sourceURL).getOrNull(), runCatching(runtime).getOrNull())
564
+ if (outcome.recovered) return
565
+ previousHandler?.handleException(e) ?: throw e
566
+ // The handler returned, however long it took: the error did not end the process.
567
+ handlerReturned(outcome)
247
568
  }
248
-
249
- nativeLibraryLoadAttempted = true
250
- nativeLibraryLoaded =
251
- try {
252
- System.loadLibrary("hotupdater_recovery")
253
- true
254
- } catch (e: UnsatisfiedLinkError) {
255
- Log.w(TAG, "Failed to load recovery native library", e)
256
- false
257
- }
258
- return nativeLibraryLoaded
259
569
  }
260
570
 
261
- private external fun nativeInstallSignalHandler(crashMarkerPath: String)
571
+ /** The deadline of the recovery watchdog, and the instances that armed it. */
572
+ internal data class WatchdogState(
573
+ val deadlineAt: Long,
574
+ val instances: List<String>,
575
+ /** The process that armed the watchdog, recorded beside named owners. */
576
+ val pid: Int? = null,
577
+ /** Named instances whose claimed error is in flight: they relaunch without a marker. */
578
+ val relaunching: List<String> = emptyList(),
579
+ ) {
580
+ // Main alone writes only its deadline, as it always has. Any other set
581
+ // of owners is listed on an `owners:` line, which may leave main out:
582
+ // a marker main left in an earlier process is not this launch's.
583
+ fun serialize(): String {
584
+ val owners = instances.distinct()
585
+ val relaunch = relaunching.distinct()
586
+ if (owners == listOf(HotUpdaterInstanceEnvironment.MAIN_NAME) && relaunch.isEmpty()) return deadlineAt.toString()
587
+ val pidLine = pid?.let { "\n$PID_PREFIX$it" }.orEmpty()
588
+ val relaunchLine = if (relaunch.isEmpty()) "" else "\n$RELAUNCH_PREFIX${relaunch.joinToString(",")}"
589
+ return "$deadlineAt\n$OWNERS_PREFIX${owners.joinToString(",")}$pidLine$relaunchLine"
590
+ }
591
+
592
+ /**
593
+ * Whether the watchdog, ticking in process [currentPid], relaunches the
594
+ * app for a marker that asks to roll back. It relaunches after the
595
+ * process that armed it died. A named instance's marker is written
596
+ * before its error is forwarded, and the error's handler may return:
597
+ * a development build shows it, and an app's handler may swallow it.
598
+ * Then the process that armed the watchdog lives on and is not
599
+ * restarted. Main's own file has no process, and relaunches as it
600
+ * always has.
601
+ */
602
+ fun relaunchesFor(
603
+ markerRequestsRollback: Boolean,
604
+ currentPid: Int,
605
+ ): Boolean = (markerRequestsRollback || relaunching.isNotEmpty()) && pid != currentPid
262
606
 
263
- private external fun nativeUpdateLaunchState(
264
- bundleId: String?,
265
- shouldRollback: Boolean,
266
- )
607
+ companion object {
608
+ private const val OWNERS_PREFIX = "owners:"
609
+ private const val PID_PREFIX = "pid:"
610
+ private const val RELAUNCH_PREFIX = "relaunch:"
267
611
 
268
- internal class RecoveryJSExceptionHandler(
269
- private val previousHandler: JSExceptionHandler?,
270
- ) : JSExceptionHandler {
271
- override fun handleException(e: Exception) {
272
- if (!handleJavaScriptExceptionForRecovery(e)) {
273
- previousHandler?.handleException(e) ?: throw e
612
+ private fun names(
613
+ lines: List<String>,
614
+ prefix: String,
615
+ ): List<String>? =
616
+ lines
617
+ .firstOrNull { it.startsWith(prefix) }
618
+ ?.removePrefix(prefix)
619
+ ?.split(',')
620
+ ?.map(String::trim)
621
+ ?.filter(String::isNotEmpty)
622
+
623
+ fun parse(text: String?): WatchdogState? {
624
+ val lines = text?.lines()?.map(String::trim)?.filter(String::isNotEmpty) ?: return null
625
+ val deadlineAt = lines.firstOrNull()?.toLongOrNull() ?: return null
626
+ val owners = names(lines, OWNERS_PREFIX)
627
+ val pid = lines.firstOrNull { it.startsWith(PID_PREFIX) }?.removePrefix(PID_PREFIX)?.toIntOrNull()
628
+ // A file with only a deadline is main's.
629
+ return WatchdogState(
630
+ deadlineAt,
631
+ owners ?: listOf(HotUpdaterInstanceEnvironment.MAIN_NAME),
632
+ pid,
633
+ names(lines, RELAUNCH_PREFIX).orEmpty(),
634
+ )
274
635
  }
275
636
  }
276
637
  }
277
638
 
278
639
  companion object {
640
+ /**
641
+ * The one lock of crash recovery's state in this process: every
642
+ * launch's state, the live launches, claims, the native slots and
643
+ * claim, and the watchdog's owners change under it, so no change ever
644
+ * sees another half done. What runs under it is short: small files,
645
+ * JNI stores, and alarm updates. View work, posts to the main thread,
646
+ * and a gate's handed-over activity run outside it.
647
+ */
648
+ internal val recoveryLock = Any()
649
+
650
+ // Whether this process ever had a named instance; until then every
651
+ // view is main's.
652
+ @Volatile
653
+ private var namedInstanceCreated = false
654
+
655
+ /** The instance whose runtime shows the root view of a root tag, and that runtime's launch; main thread. Tests stand in for the views. */
656
+ @Volatile
657
+ internal var rootTagOwner: (Int) -> HotUpdaterRootOwner? = { rootTag ->
658
+ attribution.rootOf(rootTag)?.let { HotUpdaterRootOwner(it.instanceName, launchOfRuntime(it.instanceName, it.runtime)) }
659
+ }
660
+ internal val defaultRootTagOwner = rootTagOwner
661
+
279
662
  private const val TAG = "HotUpdaterRecovery"
280
663
  private const val CRASH_MARKER_FILENAME = "recovery-crash-marker.json"
281
664
  private const val WATCHDOG_STATE_FILENAME = "recovery-watchdog-state.txt"
@@ -285,41 +668,830 @@ internal class HotUpdaterRecoveryManager(
285
668
  private const val JS_EXCEPTION_HOOK_RETRY_DELAY_MS = 50L
286
669
 
287
670
  @Volatile
288
- private var nativeLibraryLoadAttempted = false
671
+ private var signalHandlerInstalled = false
289
672
 
673
+ /** The native signal handler's slots and claim; tests stand in for it. */
290
674
  @Volatile
291
- private var nativeLibraryLoaded = false
675
+ internal var nativeRecovery: HotUpdaterNativeRecovery = HotUpdaterNativeRecovery.jni
292
676
 
293
677
  @Volatile
294
- private var signalHandlerInstalled = false
678
+ private var exceptionHandlerInstalled = false
295
679
 
296
680
  @Volatile
297
- private var exceptionHandlerInstalled = false
681
+ private var contentAppearedListenerInstalled = false
298
682
 
299
683
  @Volatile
300
684
  private var previousExceptionHandler: Thread.UncaughtExceptionHandler? = null
301
685
 
686
+ private val slots = HotUpdaterRecoverySlots()
687
+
688
+ // Read on use, so neither class's initialization waits on the other's.
689
+ private val attribution: HotUpdaterRecoveryAttribution
690
+ get() = HotUpdaterRecoveryAttribution.shared
691
+
692
+ /** The manager of each instance whose launch is monitored. */
693
+ private val liveManagers = ConcurrentHashMap<String, HotUpdaterRecoveryManager>()
694
+
695
+ private val signalHandlerLock = Any()
696
+ private const val MAX_CAUSE_DEPTH = 8
697
+
698
+ // The launches the current claim left alone, armed again if the
699
+ // process outlives the claim.
700
+ private var claimGeneration = 0
701
+ private val launchGenerations = AtomicLong()
702
+
703
+ // The instance and the launch generation of the latest claim.
704
+ private var claimOwner: Pair<String, Long?>? = null
705
+
706
+ // Every claim whose handler hasn't returned yet, by generation, with
707
+ // its owner. Fatal errors of several runtimes may be in flight at
708
+ // once: the claim stays published until the last of them ends.
709
+ private val activeClaims = LinkedHashMap<Int, Pair<String, Long?>>()
710
+
711
+ // The error each active claim was made for. React Native may report
712
+ // one error twice, through a runtime's context and its host, or wrap
713
+ // it on the way: a report of an error already claimed reuses its
714
+ // claim, which its handler's return or its fail-safe ends.
715
+ private val claimErrors = HashMap<Int, WeakReference<Throwable>>()
716
+
717
+ // The hooks still forwarding each claim's error: one report may reach
718
+ // a runtime's context and its host at once, each with a handler that
719
+ // returns in its own time. The claim ends when the last returns.
720
+ private val claimHolders = HashMap<Int, Int>()
721
+
722
+ // Whether each claim's charge started main's recovery restart. A
723
+ // repeated report of its error gets the same answer, so its hook
724
+ // doesn't forward the error while that restart is under way.
725
+ private val claimRecovering = HashMap<Int, Boolean>()
726
+
727
+ // Every owner of the claims still active, which overlap when errors
728
+ // return: each one's watchdog is left when the latest claim ends.
729
+ private val claimedOwners = LinkedHashSet<Pair<String, Long?>>()
730
+ private val disarmedByClaim = LinkedHashMap<HotUpdaterRecoveryManager, Long>()
731
+ private val watchdogOwners = linkedSetOf<String>()
732
+ private val relaunchIntents = linkedSetOf<String>()
733
+ private var watchdogDeadlineAt = 0L
734
+
735
+ // The application, for watchdog changes no live launch makes.
302
736
  @Volatile
303
- private var activeManager: HotUpdaterRecoveryManager? = null
737
+ private var watchdogContext: Context? = null
304
738
 
305
- private val patchedReactContextIds = mutableSetOf<Int>()
739
+ // Whether this process ever set the watchdog's alarm. A receiver in a
740
+ // process that never did runs after the process that set it ended,
741
+ // which may have died before its state file was written.
742
+ private var watchdogArmedInProcess = false
306
743
 
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
744
+ // The watchdog's state lives in this process (owners, intents), in
745
+ // its file, and in its alarm. Every change goes through these, under
746
+ // the recovery lock, so the three never disagree, whichever launch,
747
+ // claim, or fail-safe makes it, and whether its launch is still live.
310
748
 
311
- internal fun patchReactContextExceptionHandler(reactContext: ReactContext) {
312
- val contextIdentity = System.identityHashCode(reactContext)
313
- if (!patchedReactContextIds.add(contextIdentity)) {
749
+ private fun startWatchdogLocked(
750
+ context: Context,
751
+ name: String,
752
+ ) {
753
+ try {
754
+ watchdogOwners += name
755
+ watchdogDeadlineAt = System.currentTimeMillis() + MONITORING_GRACE_PERIOD_MS
756
+ // The alarm before the file: a process that dies in between
757
+ // leaves an alarm whose receiver reads the crash markers.
758
+ setWatchdogAlarmLocked(context)
759
+ persistWatchdogLocked(context)
760
+ } catch (e: Exception) {
761
+ Log.e(TAG, "Failed to schedule recovery watchdog", e)
762
+ }
763
+ }
764
+
765
+ private fun removeWatchdogOwnerLocked(
766
+ context: Context,
767
+ name: String,
768
+ ) {
769
+ // Another owner's watchdog is not this one's to change.
770
+ if (!watchdogOwners.remove(name) && watchdogOwners.isNotEmpty()) return
771
+ persistWatchdogLocked(context)
772
+ }
773
+
774
+ private fun addRelaunchIntentLocked(
775
+ context: Context,
776
+ name: String,
777
+ ) {
778
+ relaunchIntents += name
779
+ // An intent is only kept with an alarm that acts on it.
780
+ watchdogDeadlineAt = maxOf(watchdogDeadlineAt, System.currentTimeMillis() + MONITORING_GRACE_PERIOD_MS)
781
+ setWatchdogAlarmLocked(context)
782
+ persistWatchdogLocked(context)
783
+ }
784
+
785
+ private fun setWatchdogAlarmLocked(context: Context) {
786
+ watchdogArmedInProcess = true
787
+ scheduleRecoveryWatchdogTick(context, WATCHDOG_TICK_INTERVAL_MS)
788
+ }
789
+
790
+ private fun removeRelaunchIntentLocked(
791
+ context: Context,
792
+ name: String,
793
+ ) {
794
+ if (!relaunchIntents.remove(name)) return
795
+ persistWatchdogLocked(context)
796
+ }
797
+
798
+ // Writes the state, or ends the watchdog once nothing is left for it.
799
+ private fun persistWatchdogLocked(context: Context) {
800
+ val file = watchdogFile(context)
801
+ if (watchdogOwners.isEmpty() && relaunchIntents.isEmpty()) {
802
+ file.delete()
803
+ cancelRecoveryWatchdogAlarm(context)
314
804
  return
315
805
  }
806
+ try {
807
+ writeWatchdogState(
808
+ file,
809
+ WatchdogState(watchdogDeadlineAt, watchdogOwners.toList(), Process.myPid(), relaunchIntents.toList()),
810
+ )
811
+ } catch (e: Exception) {
812
+ Log.e(TAG, "Failed to update recovery watchdog", e)
813
+ }
814
+ }
815
+
816
+ private fun watchdogFile(context: Context): File = File(getBundleStoreDir(context), WATCHDOG_STATE_FILENAME)
817
+
818
+ // Whole or not at all: a process that dies while writing leaves the
819
+ // previous state, or none, never a part that reads as main's alone.
820
+ private fun writeWatchdogState(
821
+ file: File,
822
+ state: WatchdogState,
823
+ ) {
824
+ file.parentFile?.mkdirs()
825
+ val partial = File(file.parentFile, "${file.name}.tmp")
826
+ partial.writeText(state.serialize())
827
+ if (!partial.renameTo(file)) {
828
+ partial.delete()
829
+ throw IOException("Failed to replace ${file.name}")
830
+ }
831
+ }
832
+
833
+ private val patchedReactContexts = WeakIdentitySet<ReactContext>()
834
+
835
+ private val contentAppearedListener =
836
+ ReactMarker.MarkerListener { marker, _, rootTag ->
837
+ if (marker == ReactMarkerConstants.CONTENT_APPEARED) {
838
+ handleContentAppeared(rootTag)
839
+ }
840
+ }
841
+
842
+ // The flag is set only once the listener is registered, under one lock,
843
+ // so no launch can go on to render before the listener can hear it.
844
+ private fun ensureContentAppearedListenerInstalled() {
845
+ if (contentAppearedListenerInstalled) return
846
+ synchronized(this) {
847
+ if (contentAppearedListenerInstalled) return
848
+ ReactMarker.addListener(contentAppearedListener)
849
+ contentAppearedListenerInstalled = true
850
+ }
851
+ }
852
+
853
+ // React Native names the view by its root tag. A view no known activity
854
+ // shows is credited to main, as every view always was.
855
+ //
856
+ // A view no known activity shows yet may be a named runtime's: one that
857
+ // rendered before its module registered, or before its root view was
858
+ // attached to an activity. While a named launch waits for its first
859
+ // content, the content is held (HotUpdaterHeldContent) instead.
860
+ internal fun handleContentAppeared(rootTag: Int) {
861
+ // An app with only main: every view is main's, as it always was.
862
+ if (!namedInstanceCreated) {
863
+ contentAppeared(HotUpdaterInstanceEnvironment.MAIN_NAME)
864
+ return
865
+ }
866
+ // The launches when the content appeared: a launch that starts
867
+ // while the view is looked up is not the one that showed it.
868
+ val generations = launchGenerations()
869
+ // A named runtime may outlive its launch's monitoring and render
870
+ // again: its content is its own, never main's, monitored or not.
871
+ // Finding the view walks views and UI managers: outside the lock.
872
+ val owner = rootTagOwner(rootTag)
873
+ synchronized(recoveryLock) {
874
+ if (owner != null) {
875
+ contentAppeared(owner.instanceName, owner.boundTo(generations))
876
+ return
877
+ }
878
+ val waitingNamedLaunch = liveManagers.values.any { !it.isMain && it.awaitsFirstContent() }
879
+ if (!waitingNamedLaunch) {
880
+ contentAppeared(HotUpdaterInstanceEnvironment.MAIN_NAME, generations)
881
+ return
882
+ }
883
+ heldContent.hold(rootTag, generations)
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Main thread: verifies the launches whose held content's views can
889
+ * be found now, as when a root view was attached to an activity after
890
+ * its first content appeared.
891
+ */
892
+ internal fun resolveHeldContent() {
893
+ heldContent.resolveHeld()
894
+ }
895
+
896
+ private val heldContent =
897
+ HotUpdaterHeldContent(
898
+ mainThread = HotUpdaterMainThread.android,
899
+ clock = { SystemClock.uptimeMillis() },
900
+ resolve = { rootTag -> rootTagOwner(rootTag) },
901
+ credit = { instanceName, generation -> liveManagers[instanceName]?.handleContentAppeared(generation) },
902
+ )
903
+
904
+ /** First content of [instanceName]'s runtime appeared: it verifies only that instance's launch. */
905
+ internal fun contentAppeared(instanceName: String) {
906
+ liveManagers[instanceName]?.handleContentAppeared()
907
+ }
908
+
909
+ /** Content of [instanceName] verifies only its launch in [generations], when the content appeared. */
910
+ private fun contentAppeared(
911
+ instanceName: String,
912
+ generations: Map<String, Long>,
913
+ ) {
914
+ val generation = generations[instanceName] ?: return
915
+ liveManagers[instanceName]?.handleContentAppeared(generation)
916
+ }
917
+
918
+ // The launch each runtime registered for, by the runtime: a host may
919
+ // start a replacement launch of the same bundle while its previous
920
+ // runtime still renders, and that runtime's content is its own
921
+ // launch's, not the replacement's. Kept after the runtime is torn
922
+ // down, whose content may still arrive; weak, so it goes with it.
923
+ private val runtimeLaunches = WeakHashMap<Any, Pair<String, Long>>()
924
+
925
+ /** The launch of [instanceName] [runtime] registered for, or null when it didn't register for one. */
926
+ internal fun launchOfRuntime(
927
+ instanceName: String,
928
+ runtime: Any?,
929
+ ): Long? {
930
+ runtime ?: return null
931
+ return synchronized(recoveryLock) { runtimeLaunches[runtime]?.takeIf { it.first == instanceName }?.second }
932
+ }
933
+
934
+ /**
935
+ * Whether [runtime] registered for [instanceName]'s current launch;
936
+ * null when it registered for no launch of it, which its URL decides.
937
+ */
938
+ internal fun runtimeRunsCurrentLaunch(
939
+ instanceName: String,
940
+ runtime: Any?,
941
+ ): Boolean? {
942
+ runtime ?: return null
943
+ return synchronized(recoveryLock) {
944
+ val (name, launch) = runtimeLaunches[runtime] ?: return@synchronized null
945
+ if (name != instanceName) return@synchronized null
946
+ val manager = liveManagers[instanceName] ?: return@synchronized null
947
+ manager.currentGeneration() == launch
948
+ }
949
+ }
950
+
951
+ /** Each live instance's launch generation now. */
952
+ private fun launchGenerations(): Map<String, Long> =
953
+ synchronized(recoveryLock) { liveManagers.values.associate { it.name to it.currentGeneration() } }
954
+
955
+ private fun ensureExceptionHandlerInstalled() {
956
+ synchronized(this) {
957
+ if (exceptionHandlerInstalled) {
958
+ return
959
+ }
960
+
961
+ previousExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
962
+ exceptionHandlerInstalled = true
963
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
964
+ if (chargeUncaughtException(throwable)) {
965
+ // The separate restart activity kills this process after it starts.
966
+ // A second fatal error must not kill it while that request is in flight.
967
+ return@setDefaultUncaughtExceptionHandler
968
+ }
969
+
970
+ previousExceptionHandler?.uncaughtException(thread, throwable)
971
+ ?: run {
972
+ Process.killProcess(Process.myPid())
973
+ exitProcess(10)
974
+ }
975
+ }
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Writes the crash markers of an uncaught exception that ends the
981
+ * process; true when main's recovery restart takes over the process.
982
+ * The crash a claimed error ends in is that claim's, by the error
983
+ * itself. A JavaScript error is otherwise its own runtime's, by its
984
+ * stack, even while another runtime's claim is active; only then does
985
+ * the active claim take it. With no claim, an unclaimed one may be
986
+ * inferred to be one named launch's
987
+ * ([HotUpdaterRecoverySlots.inferJavaScriptErrorOwner]).
988
+ */
989
+ internal fun chargeUncaughtException(throwable: Throwable): Boolean =
990
+ synchronized(recoveryLock) {
991
+ activeClaimOf(throwable)?.let { claimed ->
992
+ return@synchronized handleUncaughtCrash(claim = activeClaims[claimed])
993
+ }
994
+ val claim = activeClaimLocked()
995
+ val isJavaScript = isJavaScriptError(throwable)
996
+ val owner =
997
+ when {
998
+ !isJavaScript -> null
999
+
1000
+ claim == null -> javaScriptErrorOwner(throwable)
1001
+
1002
+ // During another claim, a stack of main's own bundle is
1003
+ // main's distinct error, not the claim's.
1004
+ else -> attribution.instanceOfStack(javaScriptStacks(throwable))
1005
+ }
1006
+ if (owner != null) {
1007
+ claimJavaScriptError(throwable as? Exception, owner).recovered
1008
+ } else {
1009
+ handleUncaughtCrash(claim = claim)
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * The instance an unclaimed JavaScript error belongs to: the one its
1015
+ * stack runs from, main included, or only when the stack tells
1016
+ * nothing, the one inferred from the launches that haven't finished
1017
+ * loading; null when neither tells. A stack of main's bundle is
1018
+ * main's, charging no named launch however few have loaded. With only
1019
+ * main, every error is main's and is charged as it always was.
1020
+ */
1021
+ private fun javaScriptErrorOwner(throwable: Throwable): String? {
1022
+ when (val stackOwner = attribution.instanceOfStack(javaScriptStacks(throwable))) {
1023
+ null -> {}
1024
+
1025
+ HotUpdaterInstanceEnvironment.MAIN_NAME -> {
1026
+ return stackOwner.takeIf { namedInstanceCreated }
1027
+ }
1028
+
1029
+ else -> {
1030
+ return stackOwner
1031
+ }
1032
+ }
1033
+ return inferJavaScriptErrorOwner()
1034
+ }
1035
+
1036
+ // React Native formats a fatal error's frames without their files,
1037
+ // but keeps the engine's own stack, with each frame's bundle, in its
1038
+ // extra data.
1039
+ private fun javaScriptStacks(throwable: Throwable): List<String> =
1040
+ generateSequence(throwable) { it.cause }
1041
+ .take(MAX_CAUSE_DEPTH)
1042
+ .filterIsInstance<JavascriptException>()
1043
+ .flatMap { error -> listOfNotNull(error.message, error.extraDataAsJson?.let(::rawStackOf)) }
1044
+ .toList()
1045
+
1046
+ private fun rawStackOf(extraDataAsJson: String): String =
1047
+ runCatching { JSONObject(extraDataAsJson).optString("rawStack") }.getOrNull()?.takeIf { it.isNotEmpty() } ?: extraDataAsJson
1048
+
1049
+ private fun isJavaScriptError(throwable: Throwable): Boolean =
1050
+ generateSequence(throwable) { it.cause }.take(MAX_CAUSE_DEPTH).any { it is JavascriptException }
1051
+
1052
+ private fun inferJavaScriptErrorOwner(): String? =
1053
+ HotUpdaterRecoverySlots.inferJavaScriptErrorOwner(
1054
+ liveLaunches(),
1055
+ liveManagers.values
1056
+ .filter { it.hasRegisteredRuntime() }
1057
+ .map { it.name }
1058
+ .toSet(),
1059
+ )
1060
+
1061
+ private fun liveLaunches(): List<HotUpdaterRecoverySlots.Launch> = liveManagers.values.mapNotNull { it.currentLaunch() }
1062
+
1063
+ /**
1064
+ * Writes the crash markers of the crash that ends the process: the
1065
+ * claimed instance's, or every launch it can be blamed on. True when
1066
+ * main's recovery restart takes over the process.
1067
+ */
1068
+ private fun handleUncaughtCrash(
1069
+ exception: Exception? = null,
1070
+ claim: Pair<String, Long?>? = activeClaim(),
1071
+ ): Boolean =
1072
+ synchronized(recoveryLock) {
1073
+ // A claim belongs to the launch that threw, not to a newer launch
1074
+ // of its instance that started while the claim was active.
1075
+ val attributedName = claim?.first
1076
+ val targets =
1077
+ HotUpdaterRecoverySlots.markerTargets(liveLaunches(), attributedName).filter { target ->
1078
+ target.name != attributedName || liveManagers[target.name]?.currentGeneration() == claim?.second
1079
+ }
1080
+ // Named instances first: main's recovery restart ends the process.
1081
+ targets.filterNot { it.isMain }.forEach { liveManagers[it.name]?.chargeCrash(exception) }
1082
+ if (targets.none { it.isMain }) return@synchronized false
1083
+ liveManagers[HotUpdaterInstanceEnvironment.MAIN_NAME]?.chargeCrash(exception) == true
1084
+ }
1085
+
1086
+ /**
1087
+ * Hands a JavaScript error of the runtime that loaded [sourceURL] to
1088
+ * crash recovery; false when it does not recover it.
1089
+ */
1090
+ internal fun handleJavaScriptExceptionForRecovery(
1091
+ exception: Exception,
1092
+ sourceURL: String?,
1093
+ ): Boolean = handleJavaScriptError(exception, sourceURL).recovered
1094
+
1095
+ /**
1096
+ * [handleJavaScriptExceptionForRecovery], with the claim it made.
1097
+ * A hook that forwards the error and sees its handler return calls
1098
+ * [handlerReturned] with it.
1099
+ */
1100
+ internal fun handleJavaScriptError(
1101
+ exception: Exception,
1102
+ sourceURL: String?,
1103
+ runtime: Any? = null,
1104
+ ): JavaScriptErrorOutcome {
1105
+ // The launches when the error was thrown: one that starts while
1106
+ // the runtime's instance is resolved is not the one that threw.
1107
+ val generations = launchGenerations()
1108
+ val instanceName = attribution.instanceOf(sourceURL)
1109
+ return synchronized(recoveryLock) { handleJavaScriptExceptionLocked(exception, instanceName, sourceURL, runtime, generations) }
1110
+ }
1111
+
1112
+ private fun handleJavaScriptExceptionLocked(
1113
+ exception: Exception,
1114
+ instanceName: String?,
1115
+ sourceURL: String?,
1116
+ runtime: Any?,
1117
+ generations: Map<String, Long>,
1118
+ ): JavaScriptErrorOutcome {
1119
+ val manager = instanceName?.let { liveManagers[it] }
1120
+ if (instanceName != null &&
1121
+ instanceName != HotUpdaterInstanceEnvironment.MAIN_NAME &&
1122
+ manager != null &&
1123
+ (
1124
+ manager.currentGeneration() != generations[instanceName] ||
1125
+ // The launch the runtime registered for, when it did; else its bundle's URL.
1126
+ !(runtimeRunsCurrentLaunch(instanceName, runtime) ?: manager.runsLaunchOf(sourceURL))
1127
+ )
1128
+ ) {
1129
+ // A runtime of an earlier launch of the instance, whose context
1130
+ // still reports errors: the crash it may end in is its own, so
1131
+ // it charges no launch of this process.
1132
+ Log.w(TAG, "JavaScript error of an earlier launch's runtime of $instanceName", exception)
1133
+ return claimJavaScriptError(exception, "$instanceName$EARLIER_LAUNCH_SUFFIX")
1134
+ }
1135
+ if (instanceName == null) return handleClaimableJavaScriptException(exception, null)
1136
+ return claimJavaScriptError(exception, instanceName, generations[instanceName])
1137
+ }
1138
+
1139
+ // Instance names have no "@", so no launch is ever this claim's.
1140
+ private const val EARLIER_LAUNCH_SUFFIX = "@earlier-launch"
1141
+
1142
+ /**
1143
+ * Whether a runtime that loaded [sourceURL] runs [instanceName]'s
1144
+ * current launch; true while no launch of it is monitored.
1145
+ */
1146
+ internal fun runsCurrentLaunch(
1147
+ instanceName: String,
1148
+ sourceURL: String?,
1149
+ ): Boolean = liveManagers[instanceName]?.runsLaunchOf(sourceURL) ?: true
1150
+
1151
+ /**
1152
+ * A JavaScript error of [instanceName]'s runtime, or of an unknown one
1153
+ * when null, which counts as a native crash does. A claimed error
1154
+ * charges only its instance, and the crash it may end in charges no
1155
+ * other instance: their launches did not fail.
1156
+ */
1157
+ internal fun handleJavaScriptException(
1158
+ exception: Exception,
1159
+ instanceName: String?,
1160
+ ): Boolean = synchronized(recoveryLock) { handleClaimableJavaScriptException(exception, instanceName).recovered }
1161
+
1162
+ private fun handleClaimableJavaScriptException(
1163
+ exception: Exception,
1164
+ instanceName: String?,
1165
+ ): JavaScriptErrorOutcome {
1166
+ // An error no runtime claims is not the continuation of a claimed
1167
+ // one: it is the one named launch whose runtime never reached
1168
+ // HotUpdater, or it charges every launch it can be blamed on.
1169
+ if (instanceName == null) {
1170
+ val owner =
1171
+ javaScriptErrorOwner(exception)
1172
+ ?: return JavaScriptErrorOutcome(handleUncaughtCrash(exception, claim = null))
1173
+ return claimJavaScriptError(exception, owner)
1174
+ }
1175
+ return claimJavaScriptError(exception, instanceName)
1176
+ }
1177
+
1178
+ /**
1179
+ * The handler a hook forwarded a JavaScript error to returned: the
1180
+ * error did not end the process, so its claim ends now. Only the
1181
+ * handler's return ends it; a handler that takes long, then ends the
1182
+ * process, keeps it for that crash.
1183
+ */
1184
+ internal fun handlerReturned(outcome: JavaScriptErrorOutcome) {
1185
+ val claim = outcome.claim ?: return
1186
+ synchronized(recoveryLock) {
1187
+ val holders = (claimHolders[claim] ?: return) - 1
1188
+ if (holders > 0) {
1189
+ claimHolders[claim] = holders
1190
+ return
1191
+ }
1192
+ releaseClaim(claim)
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * @param eventGeneration the generation of [instanceName]'s launch
1198
+ * when the error was thrown; its current one when the error is handled
1199
+ * where it was thrown
1200
+ */
1201
+ private fun claimJavaScriptError(
1202
+ exception: Exception?,
1203
+ instanceName: String,
1204
+ eventGeneration: Long? = liveManagers[instanceName]?.currentGeneration(),
1205
+ ): JavaScriptErrorOutcome =
1206
+ synchronized(recoveryLock) {
1207
+ exception?.let(::activeClaimOf)?.let { claimed ->
1208
+ claimHolders[claimed] = (claimHolders[claimed] ?: 0) + 1
1209
+ return@synchronized JavaScriptErrorOutcome(claimRecovering[claimed] == true, claimed)
1210
+ }
1211
+ // The claim's generation, its attribution in both claims, its
1212
+ // owner, and the launches it leaves alone change at once: no
1213
+ // launch starts, and no release runs, in between. Only the
1214
+ // launch that threw is the owner.
1215
+ val owner = liveManagers[instanceName]?.takeIf { it.currentGeneration() == eventGeneration }
1216
+ val ownerGeneration = eventGeneration
1217
+ claimGeneration += 1
1218
+ val generation = claimGeneration
1219
+ // The claim holds until this release runs. It takes the
1220
+ // recovery lock, so it never runs before this section ends,
1221
+ // and its window starts with the claim.
1222
+ scheduleClaimRelease(generation)
1223
+ slots.attribute(instanceName)
1224
+ claimOwner = instanceName to ownerGeneration
1225
+ claimedOwners += instanceName to ownerGeneration
1226
+ activeClaims[generation] = instanceName to ownerGeneration
1227
+ exception?.let { claimErrors[generation] = WeakReference(it) }
1228
+ claimHolders[generation] = 1
1229
+ // For main and named owners alike: the owner's native slot asks
1230
+ // for rollback, then the native claim, both with no file
1231
+ // written; then the watchdog and relaunch intent (file and
1232
+ // alarm), the marker file, and main's recovery restart. A
1233
+ // signal before the claim finds no slot of the owner's charge
1234
+ // yet changed but its own, and after it writes only the
1235
+ // owner's marker. A claim no launch owns, such as an earlier
1236
+ // launch's runtime's, leaves the native handler unclaimed: a
1237
+ // signal then writes every marker that may still be needed.
1238
+ var published = false
1239
+ val publishClaim = {
1240
+ if (!published && owner != null) nativeRecovery.attributeCrash(slots.assignedSlot(instanceName) ?: -1)
1241
+ published = true
1242
+ }
1243
+ val recovering = owner?.chargeCrash(exception, onSlotAsking = publishClaim, keepRelaunchIntent = true) == true
1244
+ // Main's own recovery, or an owner with no launch to charge.
1245
+ publishClaim()
1246
+ // Claims overlap when errors return: every launch any of them
1247
+ // left alone waits for the last one, including that claim's own
1248
+ // instance, which an earlier claim may have left alone.
1249
+ liveManagers.values
1250
+ .filter { it.name != instanceName }
1251
+ .forEach { manager -> manager.handleCrashOfAnotherInstance()?.let { disarmedByClaim[manager] = it } }
1252
+ claimRecovering[generation] = recovering
1253
+ JavaScriptErrorOutcome(recovering, generation)
1254
+ }
1255
+
1256
+ /**
1257
+ * Adds the launch of [generation] of [manager] to the claim still
1258
+ * active; true when it joined.
1259
+ * Checked under the claim's lock, which the claim takes after it set
1260
+ * itself, so a launch that started after the claim left the live
1261
+ * launches alone joins it here.
1262
+ */
1263
+ private fun joinActiveClaim(
1264
+ manager: HotUpdaterRecoveryManager,
1265
+ generation: Long,
1266
+ ): Boolean {
1267
+ synchronized(recoveryLock) {
1268
+ // Every launch that starts during a claim joins it, a newer
1269
+ // launch of the claim's own instance too: only the launch
1270
+ // that threw is the claim's. It counts again when the last
1271
+ // active claim ends.
1272
+ activeClaimLocked() ?: return false
1273
+ disarmedByClaim[manager] = generation
1274
+ return true
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Ends the claim of [generation] (every active claim when null): its
1280
+ * error did not end the process. While another claim is active, that
1281
+ * one is published instead. When the last ends, the launches left
1282
+ * alone count again if they still wait for their first content.
1283
+ */
1284
+ internal fun releaseClaim(generation: Int? = null) {
1285
+ synchronized(recoveryLock) {
1286
+ if (generation == null) {
1287
+ activeClaims.clear()
1288
+ claimErrors.clear()
1289
+ claimHolders.clear()
1290
+ claimRecovering.clear()
1291
+ } else if (activeClaims.remove(generation) == null) {
1292
+ return
1293
+ } else {
1294
+ claimErrors.remove(generation)
1295
+ claimHolders.remove(generation)
1296
+ claimRecovering.remove(generation)
1297
+ }
1298
+ // Another fatal error is still in flight: the claim stays,
1299
+ // now its, with its marker and slot already in place.
1300
+ activeClaims.values.lastOrNull()?.let { (name, ownerGeneration) ->
1301
+ slots.attribute(name)
1302
+ claimOwner = name to ownerGeneration
1303
+ val owner = liveManagers[name]?.takeIf { it.currentGeneration() == ownerGeneration }
1304
+ if (owner != null) {
1305
+ nativeRecovery.attributeCrash(slots.assignedSlot(name) ?: -1)
1306
+ } else {
1307
+ nativeRecovery.clearCrashAttribution()
1308
+ }
1309
+ return
1310
+ }
1311
+ val survivedOwners = claimedOwners.toList()
1312
+ claimedOwners.clear()
1313
+ // The claim ends here, in Kotlin and natively at once: a
1314
+ // crash from now on charges the launches it can be blamed on.
1315
+ slots.clearAttribution()
1316
+ claimOwner = null
1317
+ nativeRecovery.clearCrashAttribution()
1318
+ val disarmed = disarmedByClaim.toList()
1319
+ disarmedByClaim.clear()
1320
+ // Each instance whose error was survived keeps its marker for its
1321
+ // next launch, but the process lives on: if it ends for another
1322
+ // reason, the watchdog must not relaunch the app for that marker.
1323
+ // A launch whose generation moved on is no longer the one that threw.
1324
+ survivedOwners.forEach { (name, ownerGeneration) ->
1325
+ if (name != HotUpdaterInstanceEnvironment.MAIN_NAME) {
1326
+ val manager = liveManagers[name]
1327
+ if (manager != null) {
1328
+ manager.leaveWatchdogAfterSurvivedError(ownerGeneration)
1329
+ } else {
1330
+ watchdogContext?.let { removeRelaunchIntentLocked(it, name) }
1331
+ }
1332
+ }
1333
+ }
1334
+ disarmed.forEach { (manager, launch) -> manager.resumeAfterAnotherInstanceSurvived(launch) }
1335
+ }
1336
+ }
1337
+
1338
+ /** The active claim made for [error], or for an error it wraps or that wraps it. */
1339
+ private fun activeClaimOf(error: Throwable): Int? {
1340
+ val chain = causes(error)
1341
+ return claimErrors.entries
1342
+ .firstOrNull { (generation, claimed) ->
1343
+ generation in activeClaims &&
1344
+ claimed.get()?.let { claimedError -> causes(claimedError).any { cause -> chain.any { it === cause } } } == true
1345
+ }?.key
1346
+ }
1347
+
1348
+ private fun causes(error: Throwable): List<Throwable> {
1349
+ val chain = ArrayList<Throwable>()
1350
+ var current: Throwable? = error
1351
+ while (current != null && chain.size < MAX_CAUSE_DEPTH && chain.none { it === current }) {
1352
+ chain += current
1353
+ current = current.cause
1354
+ }
1355
+ return chain
1356
+ }
1357
+
1358
+ /** The active claim: its instance and the generation of the launch that threw. */
1359
+ private fun activeClaim(): Pair<String, Long?>? = synchronized(recoveryLock) { activeClaimLocked() }
1360
+
1361
+ private fun activeClaimLocked(): Pair<String, Long?>? {
1362
+ val name = slots.attributedName() ?: return null
1363
+ return name to claimOwner?.takeIf { it.first == name }?.second
1364
+ }
1365
+
1366
+ /**
1367
+ * Runs [releaseClaim] for the claim of [generation] once its window
1368
+ * ends, on a thread of its own, so it runs even while the main thread
1369
+ * is blocked: a development build shows the error, and an app's
1370
+ * handler may swallow it, and then the process lived on.
1371
+ */
1372
+ private fun scheduleClaimRelease(generation: Int) {
1373
+ claimReleaseScheduler(HotUpdaterRecoverySlots.CLAIM_FAIL_SAFE_MS, Runnable { releaseClaim(generation) })
1374
+ }
1375
+
1376
+ private val claimReleaseExecutor by lazy {
1377
+ Executors.newSingleThreadScheduledExecutor { task -> Thread(task, "HotUpdaterRecoveryClaims").apply { isDaemon = true } }
1378
+ }
1379
+
1380
+ /** Schedules a claim's release after a delay; tests run it by hand. */
1381
+ internal val defaultClaimReleaseScheduler: (Long, Runnable) -> Unit = { delayMs, task ->
1382
+ claimReleaseExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS)
1383
+ }
1384
+
1385
+ @Volatile
1386
+ internal var claimReleaseScheduler: (Long, Runnable) -> Unit = defaultClaimReleaseScheduler
1387
+
1388
+ /**
1389
+ * Hooks the JavaScript errors of a named instance's runtime when its
1390
+ * HotUpdater module is created, since the app exposes only main's
1391
+ * host, and follows its activity for its views. Main's runtime is
1392
+ * left alone: main hooks its host only while it monitors a launch, as
1393
+ * it always has. Every runtime's root views, main's too, are found
1394
+ * through its UI managers: main may show a view in a dialog, outside
1395
+ * any activity's decor view, while a named launch waits for its
1396
+ * content. That is a weak reference, which an app with only main
1397
+ * never reads.
1398
+ */
1399
+ internal fun registerRuntime(reactContext: ReactContext): String? {
1400
+ attribution.registerRuntime(reactContext)
1401
+ val instanceName = attribution.instanceOf(runCatching { reactContext.sourceURL }.getOrNull())
1402
+ if (instanceName == null || !registersRuntime(instanceName)) {
1403
+ // Content main's runtime showed before can be told now.
1404
+ if (namedInstanceCreated) heldContent.runtimeRegistered()
1405
+ return null
1406
+ }
1407
+ try {
1408
+ JavaScriptExceptionHooks.installForRuntime(reactContext)
1409
+ } catch (e: Exception) {
1410
+ Log.w(TAG, "Failed to hook the runtime's JavaScript errors", e)
1411
+ }
1412
+ registerRuntime(
1413
+ instanceName,
1414
+ reactContext,
1415
+ runCatching { reactContext.sourceURL }.getOrNull(),
1416
+ reactContext.currentActivity,
1417
+ )
1418
+ return instanceName
1419
+ }
1420
+
1421
+ /**
1422
+ * Records that [instanceName]'s [runtime], which loaded
1423
+ * [sourceURL], hooks its errors, and shows its launch gate the
1424
+ * runtime's [activity]: the module is created while the bundle loads,
1425
+ * before its first content, and the activity may have started before
1426
+ * anything followed activities. A runtime of an earlier launch, whose
1427
+ * module may be created after the next launch started, counts for
1428
+ * nothing.
1429
+ */
1430
+ internal fun registerRuntime(
1431
+ instanceName: String,
1432
+ runtime: Any,
1433
+ sourceURL: String?,
1434
+ activity: Activity?,
1435
+ ): Boolean {
1436
+ val registered = liveManagers[instanceName]?.registerRuntime(runtime, sourceURL, activity) == true
1437
+ // Content its runtime showed before can be told now.
1438
+ if (registered) heldContent.runtimeRegistered()
1439
+ return registered
1440
+ }
1441
+
1442
+ /**
1443
+ * [reactContext] was torn down, as in a reload: its root views are no
1444
+ * longer looked for, and the runtime of [instanceName], the named
1445
+ * instance it was registered for, counts for its launch no more.
1446
+ */
1447
+ internal fun unregisterRuntime(
1448
+ reactContext: ReactContext,
1449
+ instanceName: String?,
1450
+ ) {
1451
+ attribution.unregisterRuntime(reactContext)
1452
+ instanceName?.let { unregisterRuntime(it, reactContext) }
1453
+ }
1454
+
1455
+ /** [instanceName]'s [runtime] was torn down, as in a reload. */
1456
+ internal fun unregisterRuntime(
1457
+ instanceName: String,
1458
+ runtime: Any,
1459
+ ) {
1460
+ liveManagers[instanceName]?.unregisterRuntime(runtime)
1461
+ }
1462
+
1463
+ // React Native may hold `/data/data/...` where the launch handed out
1464
+ // `/data/user/0/...`, a symlink to the same file.
1465
+ private fun isSameBundle(
1466
+ sourceURL: String?,
1467
+ launchURL: String,
1468
+ ): Boolean {
1469
+ val source = sourceURL?.takeIf { it.isNotEmpty() } ?: return false
1470
+ return source == launchURL || canonicalBundlePath(source) == canonicalBundlePath(launchURL)
1471
+ }
1472
+
1473
+ private fun canonicalBundlePath(url: String): String {
1474
+ val path = url.removePrefix("file://")
1475
+ return if (path.startsWith("/")) runCatching { File(path).canonicalPath }.getOrDefault(path) else url
1476
+ }
1477
+
1478
+ /**
1479
+ * Whether a runtime of [instanceName] is hooked when its module is
1480
+ * created: only a named instance's. A runtime whose bundle isn't known
1481
+ * yet is main's, as every runtime is in an app with only main.
1482
+ */
1483
+ internal fun registersRuntime(instanceName: String?): Boolean =
1484
+ instanceName != null && instanceName != HotUpdaterInstanceEnvironment.MAIN_NAME
1485
+
1486
+ internal fun patchReactContextExceptionHandler(reactContext: ReactContext) {
1487
+ if (!patchedReactContexts.add(reactContext)) return
316
1488
 
317
1489
  val previousHandler = reactContext.jsExceptionHandler
318
1490
  if (previousHandler is RecoveryJSExceptionHandler) {
319
1491
  return
320
1492
  }
321
1493
 
322
- reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
1494
+ reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler, { reactContext.sourceURL }, { reactContext }))
323
1495
  }
324
1496
 
325
1497
  internal fun findField(
@@ -337,20 +1509,38 @@ internal class HotUpdaterRecoveryManager(
337
1509
  return null
338
1510
  }
339
1511
 
1512
+ // The receiver runs on the main thread of a live process too, beside
1513
+ // launches that change the watchdog on other threads.
340
1514
  @JvmStatic
341
1515
  fun handleRecoveryWatchdog(context: Context) {
1516
+ synchronized(recoveryLock) { handleRecoveryWatchdogLocked(context) }
1517
+ }
1518
+
1519
+ private fun handleRecoveryWatchdogLocked(context: Context) {
342
1520
  val appContext = context.applicationContext
343
1521
  val watchdogStateFile = File(getBundleStoreDir(appContext), WATCHDOG_STATE_FILENAME)
344
- val deadlineAt = watchdogStateFile.takeIf(File::exists)?.readText()?.toLongOrNull()
345
- if (deadlineAt == null) {
1522
+ val state = WatchdogState.parse(watchdogStateFile.takeIf(File::exists)?.readText())
1523
+ // A process that never set the alarm runs after the process that
1524
+ // set it ended, which may have died before writing its state or
1525
+ // before rewriting it for a new owner: the file's owners may lag.
1526
+ // Any marker asking to roll back is then that process's, as one
1527
+ // left earlier was consumed or relaunched for then. A marker,
1528
+ // never a launch merely pending, so a relaunched process that
1529
+ // dies again relaunches no further.
1530
+ val relaunchesAfterArmingProcess = !watchdogArmedInProcess && anyCrashMarkerRequestsRollback(appContext.filesDir)
1531
+ if (state == null) {
346
1532
  cancelRecoveryWatchdogAlarm(appContext)
347
1533
  watchdogStateFile.delete()
1534
+ if (relaunchesAfterArmingProcess) {
1535
+ Log.i(TAG, "Recovery watchdog detected crash marker without its state, relaunching app")
1536
+ launchRecoveryRestart(appContext)
1537
+ }
348
1538
  return
349
1539
  }
350
1540
 
351
- val crashMarkerFile = File(getBundleStoreDir(appContext), CRASH_MARKER_FILENAME)
352
- val recovery = PendingCrashRecovery.loadFromFile(crashMarkerFile)
353
- if (recovery?.shouldRollback == true && recovery.launchedBundleId != null) {
1541
+ if (relaunchesAfterArmingProcess ||
1542
+ state.relaunchesFor(crashMarkerRequestsRollback(appContext.filesDir, state.instances), Process.myPid())
1543
+ ) {
354
1544
  Log.i(TAG, "Recovery watchdog detected crash marker, relaunching app")
355
1545
  watchdogStateFile.delete()
356
1546
  cancelRecoveryWatchdogAlarm(appContext)
@@ -358,7 +1548,21 @@ internal class HotUpdaterRecoveryManager(
358
1548
  return
359
1549
  }
360
1550
 
361
- if (System.currentTimeMillis() >= deadlineAt) {
1551
+ val now = System.currentTimeMillis()
1552
+ if (now >= state.deadlineAt) {
1553
+ // A claimed error's handler in this process may run long, then
1554
+ // end it: the watchdog stays until the claim ends, which drops
1555
+ // its relaunch intent (or the process does).
1556
+ if (state.relaunching.isNotEmpty() && state.pid == Process.myPid()) {
1557
+ watchdogDeadlineAt = now + MONITORING_GRACE_PERIOD_MS
1558
+ scheduleRecoveryWatchdogTick(appContext, WATCHDOG_TICK_INTERVAL_MS)
1559
+ writeWatchdogState(watchdogStateFile, state.copy(deadlineAt = watchdogDeadlineAt))
1560
+ return
1561
+ }
1562
+ // The watchdog ends here, in this process's state too: a
1563
+ // later owner starts a new one rather than reviving these.
1564
+ watchdogOwners.clear()
1565
+ relaunchIntents.clear()
362
1566
  watchdogStateFile.delete()
363
1567
  cancelRecoveryWatchdogAlarm(appContext)
364
1568
  return
@@ -367,7 +1571,47 @@ internal class HotUpdaterRecoveryManager(
367
1571
  scheduleRecoveryWatchdogTick(appContext, WATCHDOG_TICK_INTERVAL_MS)
368
1572
  }
369
1573
 
370
- private fun getBundleStoreDir(context: Context): File = File(context.filesDir, "bundle-store")
1574
+ /**
1575
+ * Whether a crash marker of [instances], the ones that armed the
1576
+ * watchdog, asks to roll a launch back. Only those: a marker another
1577
+ * instance left in an earlier process waits for that instance's next
1578
+ * launch, and must not relaunch this one.
1579
+ */
1580
+ internal fun crashMarkerRequestsRollback(
1581
+ filesDir: File?,
1582
+ instances: List<String>,
1583
+ ): Boolean =
1584
+ instances.distinct().any { name ->
1585
+ val root =
1586
+ if (name == HotUpdaterInstanceEnvironment.MAIN_NAME) {
1587
+ filesDir
1588
+ } else {
1589
+ filesDir?.let { File(File(it, HotUpdaterInstanceEnvironment.INSTANCES_DIRECTORY), name) }
1590
+ }
1591
+ val recovery = PendingCrashRecovery.loadFromFile(File(File(root, BUNDLE_STORE_DIRECTORY), CRASH_MARKER_FILENAME))
1592
+ recovery?.shouldRollback == true && recovery.launchedBundleId != null
1593
+ }
1594
+
1595
+ /** Whether the crash marker of main or of any named instance on disk asks to roll a launch back. */
1596
+ internal fun anyCrashMarkerRequestsRollback(filesDir: File?): Boolean {
1597
+ val named =
1598
+ filesDir
1599
+ ?.let { File(it, HotUpdaterInstanceEnvironment.INSTANCES_DIRECTORY).listFiles() }
1600
+ ?.filter(File::isDirectory)
1601
+ ?.map(File::getName)
1602
+ .orEmpty()
1603
+ return crashMarkerRequestsRollback(filesDir, listOf(HotUpdaterInstanceEnvironment.MAIN_NAME) + named)
1604
+ }
1605
+
1606
+ private const val BUNDLE_STORE_DIRECTORY = "bundle-store"
1607
+
1608
+ private fun getBundleStoreDir(context: Context): File = File(context.filesDir, BUNDLE_STORE_DIRECTORY)
1609
+
1610
+ // Main's path is the one it always had: filesRoot is the files directory.
1611
+ private fun getCrashMarkerFile(
1612
+ context: Context,
1613
+ environment: HotUpdaterInstanceEnvironment,
1614
+ ): File = File(File(environment.filesRoot(context), BUNDLE_STORE_DIRECTORY), CRASH_MARKER_FILENAME)
371
1615
 
372
1616
  private fun getRecoveryWatchdogIntent(context: Context): Intent =
373
1617
  Intent(context, HotUpdaterRecoveryReceiver::class.java).setAction(WATCHDOG_ACTION)
@@ -383,6 +1627,16 @@ internal class HotUpdaterRecoveryManager(
383
1627
  private fun scheduleRecoveryWatchdogTick(
384
1628
  context: Context,
385
1629
  delayMs: Long,
1630
+ ) = watchdogAlarm(context, delayMs)
1631
+
1632
+ /** Sets the watchdog's alarm; tests stand in for AlarmManager. */
1633
+ @Volatile
1634
+ internal var watchdogAlarm: (Context, Long) -> Unit = ::setRecoveryWatchdogAlarm
1635
+ internal val defaultWatchdogAlarm = watchdogAlarm
1636
+
1637
+ private fun setRecoveryWatchdogAlarm(
1638
+ context: Context,
1639
+ delayMs: Long,
386
1640
  ) {
387
1641
  val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
388
1642
  val triggerAt = SystemClock.elapsedRealtime() + delayMs
@@ -414,3 +1668,131 @@ internal class HotUpdaterRecoveryManager(
414
1668
  }
415
1669
  }
416
1670
  }
1671
+
1672
+ /**
1673
+ * What crash recovery did with a JavaScript error: whether main's recovery
1674
+ * restart took over, and the claim it made, which ends when the error's
1675
+ * handler returns.
1676
+ */
1677
+ internal data class JavaScriptErrorOutcome(
1678
+ val recovered: Boolean,
1679
+ val claim: Int? = null,
1680
+ )
1681
+
1682
+ /** What crash recovery tells the native signal handler (HotUpdaterRecovery.cpp). */
1683
+ internal interface HotUpdaterNativeRecovery {
1684
+ fun updateSlot(
1685
+ slot: Int,
1686
+ crashMarkerPath: String,
1687
+ bundleId: String?,
1688
+ shouldRollback: Boolean,
1689
+ writesWhenVerified: Boolean,
1690
+ )
1691
+
1692
+ /** [slot] is -1 for an instance that has none. */
1693
+ fun attributeCrash(slot: Int)
1694
+
1695
+ fun clearCrashAttribution()
1696
+
1697
+ /** Installs the signal handler, which reads the slots; true once it is installed. */
1698
+ fun installSignalHandler(): Boolean = true
1699
+
1700
+ companion object {
1701
+ /** The native library's slots and signal handler. */
1702
+ val jni: HotUpdaterNativeRecovery =
1703
+ object : HotUpdaterNativeRecovery {
1704
+ override fun installSignalHandler(): Boolean {
1705
+ if (!HotUpdaterRecoveryNative.load()) return false
1706
+ return try {
1707
+ HotUpdaterRecoveryNative.nativeInstallSignalHandler()
1708
+ true
1709
+ } catch (e: UnsatisfiedLinkError) {
1710
+ Log.w("HotUpdaterRecovery", "Signal handler not available", e)
1711
+ false
1712
+ }
1713
+ }
1714
+
1715
+ override fun updateSlot(
1716
+ slot: Int,
1717
+ crashMarkerPath: String,
1718
+ bundleId: String?,
1719
+ shouldRollback: Boolean,
1720
+ writesWhenVerified: Boolean,
1721
+ ) = call { HotUpdaterRecoveryNative.nativeUpdateSlot(slot, crashMarkerPath, bundleId, shouldRollback, writesWhenVerified) }
1722
+
1723
+ override fun attributeCrash(slot: Int) = call { HotUpdaterRecoveryNative.nativeAttributeCrash(slot) }
1724
+
1725
+ override fun clearCrashAttribution() = call { HotUpdaterRecoveryNative.nativeClearCrashAttribution() }
1726
+
1727
+ // The slots are written once the library loads, before the
1728
+ // handler that reads them is installed.
1729
+ private fun call(native: () -> Unit) {
1730
+ if (!HotUpdaterRecoveryNative.load()) return
1731
+ try {
1732
+ native()
1733
+ } catch (e: UnsatisfiedLinkError) {
1734
+ Log.w("HotUpdaterRecovery", "Native crash recovery is not available", e)
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ /** The signal handler of HotUpdaterRecovery.cpp, which writes each slot's crash marker. */
1742
+ internal object HotUpdaterRecoveryNative {
1743
+ @Volatile
1744
+ private var loadAttempted = false
1745
+
1746
+ @Volatile
1747
+ private var loaded = false
1748
+
1749
+ fun load(): Boolean {
1750
+ synchronized(this) {
1751
+ if (loadAttempted) return loaded
1752
+ loadAttempted = true
1753
+ loaded =
1754
+ try {
1755
+ System.loadLibrary("hotupdater_recovery")
1756
+ true
1757
+ } catch (e: UnsatisfiedLinkError) {
1758
+ Log.w("HotUpdaterRecovery", "Failed to load recovery native library", e)
1759
+ false
1760
+ }
1761
+ return loaded
1762
+ }
1763
+ }
1764
+
1765
+ @JvmStatic
1766
+ external fun nativeInstallSignalHandler()
1767
+
1768
+ @JvmStatic
1769
+ external fun nativeUpdateSlot(
1770
+ slot: Int,
1771
+ crashMarkerPath: String,
1772
+ bundleId: String?,
1773
+ shouldRollback: Boolean,
1774
+ writesWhenVerified: Boolean,
1775
+ )
1776
+
1777
+ /** [slot] is -1 for an instance that has none. */
1778
+ @JvmStatic
1779
+ external fun nativeAttributeCrash(slot: Int)
1780
+
1781
+ /** Ends the claim [nativeAttributeCrash] made. */
1782
+ @JvmStatic
1783
+ external fun nativeClearCrashAttribution()
1784
+ }
1785
+
1786
+ /**
1787
+ * The instance whose runtime shows a root view, and the launch that runtime
1788
+ * registered for, or null for a runtime that didn't register for one, such
1789
+ * as main's: its content counts for the instance's launch when it appeared.
1790
+ */
1791
+ internal data class HotUpdaterRootOwner(
1792
+ val instanceName: String,
1793
+ val launchGeneration: Long? = null,
1794
+ ) {
1795
+ /** The launches [generations] had when the content appeared, with this view's runtime's own. */
1796
+ fun boundTo(generations: Map<String, Long>): Map<String, Long> =
1797
+ launchGeneration?.let { generations + (instanceName to it) } ?: generations
1798
+ }