@hot-updater/react-native 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HotUpdater.podspec +14 -2
- package/Package.swift +24 -3
- package/android/build.gradle +2 -6
- package/android/hot-updater-fingerprint.gradle +135 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
- package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
- package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
- package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
- package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
- package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
- package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
- package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
- package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
- package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
- package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
- package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
- package/android/src/newarch/HotUpdater.kt +53 -15
- package/android/src/newarch/HotUpdaterModule.kt +42 -17
- package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
- package/android/src/oldarch/HotUpdater.kt +51 -12
- package/android/src/oldarch/HotUpdaterModule.kt +43 -17
- package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
- package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
- package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
- package/ios/HotUpdater/Internal/CohortService.swift +8 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
- package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
- package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
- package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
- package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
- package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
- package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
- package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
- package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
- package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
- package/ios/HotUpdater/Package.swift +11 -0
- package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
- package/ios/HotUpdater/Public/HotUpdater.h +21 -2
- package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
- package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
- package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
- package/lib/commonjs/checkForUpdate.js +3 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +9 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +24 -79
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/nativeUpdateEvents.js +66 -0
- package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +10 -13
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +19 -73
- package/lib/module/native.js.map +1 -1
- package/lib/module/nativeUpdateEvents.js +61 -0
- package/lib/module/nativeUpdateEvents.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -14
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +9 -46
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -14
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +9 -46
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +7 -1
- package/src/index.ts +9 -17
- package/src/native.ts +19 -91
- package/src/nativeUpdateEvents.ts +77 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +20 -3
- package/android/libs/README.md +0 -50
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/bridge/BridgeReactInstance.kt +0 -38
- package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
- package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -185
- 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
|
|
29
|
-
private val
|
|
30
|
-
private val crashMarkerFile =
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
JavaScriptExceptionHooks.install(application)
|
|
553
|
+
JavaScriptExceptionHooks.install(appContext)
|
|
242
554
|
}
|
|
243
555
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
675
|
+
internal var nativeRecovery: HotUpdaterNativeRecovery = HotUpdaterNativeRecovery.jni
|
|
292
676
|
|
|
293
677
|
@Volatile
|
|
294
|
-
private var
|
|
678
|
+
private var exceptionHandlerInstalled = false
|
|
295
679
|
|
|
296
680
|
@Volatile
|
|
297
|
-
private var
|
|
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
|
|
737
|
+
private var watchdogContext: Context? = null
|
|
304
738
|
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|