Skip to content

Commit 3b195ce

Browse files
committed
Update hot reload finalization
The final libxposed API 102 hot reload contract makes the old generation responsible for retiring its own Java and native resources before returning true from onHotReloading. The framework also does not call UnregisterNatives, JNI_OnUnload, or dlclose during hot reload, so it must avoid keeping stale framework-owned references after the new generation is handed off. This updates the libxposed submodule pointers to the finalized API 102 revisions and aligns Vector's implementation with that contract. onHotReloading exceptions now remain diagnostic failures instead of being collapsed into a false rejection, saved state is checked for nested old-classloader objects, old entries are detached after the new state is committed, and failed pre-commit reload attempts clean up only newly-created hooks while preserving the old hook handles. The daemon-side service status mapping is also updated for the final service API: the removed PROP_RT_HOT_RELOAD bit is no longer advertised, HOT_RELOAD_SUCCEEDED is used instead of the draft HOT_RELOAD_SUCCESS name, unsupported targets report HOT_RELOAD_UNSUPPORTED, concurrent reloads report HOT_RELOAD_IN_PROGRESS, and dead target processes report HOT_RELOAD_PROCESS_DIED. Documentation References: libxposed XposedModuleInterface hot reload callbacks: https://libxposed.github.io/api/io/github/libxposed/api/XposedModuleInterface.html#onHotReloaded(io.github.libxposed.api.XposedModuleInterface.HotReloadedParam)
1 parent 7cc67f8 commit 3b195ce

7 files changed

Lines changed: 115 additions & 29 deletions

File tree

daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ const val DEX_TRANSACTION_CODE =
2727
const val OBFUSCATION_MAP_TRANSACTION_CODE =
2828
('_'.code shl 24) or ('O'.code shl 16) or ('B'.code shl 8) or 'F'.code
2929

30+
internal class HotReloadInProgressException(message: String) : IllegalStateException(message)
31+
32+
internal class HotReloadProcessDiedException(message: String) : IllegalStateException(message)
33+
34+
internal class HotReloadUnsupportedException(message: String) : IllegalStateException(message)
35+
3036
object ApplicationService : ILSPApplicationService.Stub() {
3137

3238
data class ProcessKey(val uid: Int, val pid: Int)
@@ -217,7 +223,7 @@ object ApplicationService : ILSPApplicationService.Stub() {
217223
throw SecurityException("Target $targetId does not belong to ${module.packageName}")
218224
}
219225
if (target.state == HookedProcess.TARGET_STATE_RELOADING) {
220-
throw IllegalStateException("Target $targetId is already reloading")
226+
throw HotReloadInProgressException("Target $targetId is already reloading")
221227
}
222228

223229
target.state = HookedProcess.TARGET_STATE_RELOADING
@@ -227,9 +233,11 @@ object ApplicationService : ILSPApplicationService.Stub() {
227233
target.state = HookedProcess.TARGET_STATE_UP_TO_DATE
228234
}
229235
.onFailure {
230-
target.state =
231-
if (target.target.asBinder().isBinderAlive) HookedProcess.TARGET_STATE_FAILED
232-
else HookedProcess.TARGET_STATE_FAILED
236+
if (!target.target.asBinder().isBinderAlive) {
237+
hotReloadTargets.remove(target.id, target)
238+
throw HotReloadProcessDiedException("Target process died before hot reload completed")
239+
}
240+
target.state = HookedProcess.TARGET_STATE_FAILED
233241
throw it
234242
}
235243
}

daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,6 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() {
112112
override fun getFrameworkProperties(): Long {
113113
ensureModule()
114114
var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE
115-
if (loadedModule.file.moduleClassNames.size == 1) {
116-
prop = prop or IXposedService.PROP_RT_HOT_RELOAD
117-
}
118115
if (ConfigCache.state.isDexObfuscateEnabled)
119116
prop = prop or IXposedService.PROP_RT_API_PROTECTION
120117
return prop
@@ -158,18 +155,26 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() {
158155
ensureModule()
159156
runCatching {
160157
if (loadedModule.file.moduleClassNames.size != 1) {
161-
throw SecurityException("Hot reload requires exactly one Java entry class")
158+
throw HotReloadUnsupportedException("Hot reload requires exactly one Java entry class")
162159
}
163160
val latest =
164161
ConfigCache.getModuleByPackage(loadedModule.packageName)
165-
?: throw SecurityException("Module ${loadedModule.packageName} is not enabled")
162+
?: throw HotReloadUnsupportedException(
163+
"Module ${loadedModule.packageName} is not enabled")
164+
if (latest.file.moduleClassNames.size != 1) {
165+
throw HotReloadUnsupportedException(
166+
"Hot reload requires exactly one Java entry class")
167+
}
166168
ApplicationService.hotReloadTarget(targetId, latest, data)
167-
callback?.onHotReloadResult(IXposedService.HOT_RELOAD_SUCCESS, null)
169+
callback?.onHotReloadResult(IXposedService.HOT_RELOAD_SUCCEEDED, null)
168170
}
169171
.onFailure { throwable ->
172+
if (throwable is SecurityException) throw throwable
170173
val status =
171174
when (throwable) {
172-
is IllegalStateException -> IXposedService.HOT_RELOAD_IN_PROGRESS
175+
is HotReloadInProgressException -> IXposedService.HOT_RELOAD_IN_PROGRESS
176+
is HotReloadProcessDiedException -> IXposedService.HOT_RELOAD_PROCESS_DIED
177+
is HotReloadUnsupportedException -> IXposedService.HOT_RELOAD_UNSUPPORTED
173178
else -> IXposedService.HOT_RELOAD_FAILED
174179
}
175180
callback?.onHotReloadResult(status, throwable.message)

xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import io.github.libxposed.api.XposedModuleInterface.HotReloadedParam
99
import io.github.libxposed.api.XposedModuleInterface.HotReloadingParam
1010
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
1111
import java.io.File
12+
import java.lang.reflect.Array
13+
import java.util.Collections
14+
import java.util.IdentityHashMap
1215
import org.lsposed.lspd.models.Module
1316
import org.lsposed.lspd.util.Utils.Log
1417
import org.matrix.vector.impl.VectorContext
1518
import org.matrix.vector.impl.VectorLifecycleManager
1619
import org.matrix.vector.impl.hooks.freezeHooks
1720
import org.matrix.vector.impl.hooks.getActiveHookHandles
21+
import org.matrix.vector.impl.hooks.unhookAllModuleHooks
1822
import org.matrix.vector.impl.hooks.unfreezeHooks
1923
import org.matrix.vector.impl.utils.VectorModuleClassLoader
2024
import org.matrix.vector.nativebridge.NativeAPI
@@ -118,14 +122,15 @@ object VectorModuleManager {
118122
throw IllegalArgumentException("Hot reload requires exactly one Java entry class")
119123
}
120124

125+
val oldClassLoaders =
126+
oldState.entries.mapNotNullTo(mutableSetOf<ClassLoader>()) { it.javaClass.classLoader }
121127
var savedInstanceState: Any? = null
122128
val reloadingParam =
123129
object : HotReloadingParam {
124130
override fun getExtras(): Bundle? = extras
125131

126132
override fun setSavedInstanceState(outState: Any?) {
127-
val loader = outState?.javaClass?.classLoader
128-
if (loader != null && oldState.entries.any { it.javaClass.classLoader == loader }) {
133+
if (containsOldClassLoaderObject(outState, oldClassLoaders)) {
129134
throw IllegalArgumentException(
130135
"Saved state must not be created by the old module classloader"
131136
)
@@ -134,21 +139,24 @@ object VectorModuleManager {
134139
}
135140
}
136141

137-
val allowReload =
138-
oldState.entries.fold(true) { allowed, entry ->
139-
allowed && runCatching { entry.onHotReloading(reloadingParam) }
142+
var allowReload = true
143+
oldState.entries.forEach { entry ->
144+
if (!allowReload) return@forEach
145+
allowReload =
146+
runCatching { entry.onHotReloading(reloadingParam) }
140147
.onFailure { Log.e(TAG, "Error in onHotReloading for ${module.packageName}", it) }
141-
.getOrDefault(false)
142-
}
148+
.getOrThrow()
149+
}
143150
if (!allowReload) {
144-
throw IllegalStateException("Module ${module.packageName} rejected hot reload")
151+
Log.d(TAG, "Module ${module.packageName} rejected hot reload")
152+
throw IllegalStateException()
145153
}
146154

147-
freezeHooks(module.packageName, oldState.entries.mapNotNull { it.javaClass.classLoader })
155+
freezeHooks(module.packageName, oldClassLoaders)
148156
val oldHandles = getActiveHookHandles(module.packageName)
157+
var newStateCommitted = false
158+
var newEntries: List<XposedModule> = emptyList()
149159
try {
150-
oldState.entries.forEach(VectorLifecycleManager::detach)
151-
152160
val librarySearchPath = buildLibrarySearchPath(module)
153161
val moduleClassLoader =
154162
VectorModuleClassLoader.loadApk(
@@ -163,7 +171,11 @@ object VectorModuleManager {
163171
applicationInfo = module.applicationInfo,
164172
service = module.service,
165173
)
166-
val newEntries = instantiateEntries(module, moduleClassLoader, vectorContext)
174+
newEntries = instantiateEntries(module, moduleClassLoader, vectorContext)
175+
if (newEntries.size != module.file.moduleClassNames.size) {
176+
throw IllegalStateException("Failed to instantiate hot reload entry")
177+
}
178+
167179
val param =
168180
object : HotReloadedParam {
169181
override fun isSystemServer(): Boolean = oldState.isSystemServer
@@ -176,13 +188,24 @@ object VectorModuleManager {
176188

177189
override fun getOldHookHandles(): List<XposedInterface.HookHandle> = oldHandles
178190
}
191+
moduleStates[module.packageName] =
192+
ModuleState(module, oldState.processName, oldState.isSystemServer, newEntries)
193+
newStateCommitted = true
194+
// Keep oldState strongly reachable until callbacks finish, but stop lifecycle dispatch.
195+
oldState.entries.forEach(VectorLifecycleManager::detach)
179196
newEntries.forEach { entry ->
180197
VectorLifecycleManager.activeModules.add(entry)
181-
entry.onHotReloaded(param)
198+
runCatching { entry.onHotReloaded(param) }
199+
.onFailure { Log.e(TAG, "Error in onHotReloaded for ${module.packageName}", it) }
200+
.getOrThrow()
182201
}
183-
moduleStates[module.packageName] =
184-
ModuleState(module, oldState.processName, oldState.isSystemServer, newEntries)
185202
} finally {
203+
if (newStateCommitted) {
204+
oldState.entries.forEach(VectorLifecycleManager::detach)
205+
} else {
206+
newEntries.forEach(VectorLifecycleManager::detach)
207+
unhookAllModuleHooks(module.packageName, oldHandles.toSet())
208+
}
186209
unfreezeHooks(module.packageName)
187210
}
188211
}
@@ -225,4 +248,39 @@ object VectorModuleManager {
225248
return entries
226249
}
227250

251+
@Suppress("DEPRECATION")
252+
private fun containsOldClassLoaderObject(
253+
value: Any?,
254+
oldClassLoaders: Set<ClassLoader>,
255+
seen: MutableSet<Any> = Collections.newSetFromMap(IdentityHashMap<Any, Boolean>()),
256+
): Boolean {
257+
if (value == null || !seen.add(value)) return false
258+
if (value is ClassLoader && value in oldClassLoaders) return true
259+
if (value is Class<*> && value.classLoader in oldClassLoaders) return true
260+
if (value.javaClass.classLoader in oldClassLoaders) return true
261+
if (value is Bundle) {
262+
return value.keySet().any { key ->
263+
runCatching { containsOldClassLoaderObject(value.get(key), oldClassLoaders, seen) }
264+
.getOrDefault(true)
265+
}
266+
}
267+
if (value is Map<*, *>) {
268+
return value.entries.any {
269+
containsOldClassLoaderObject(it.key, oldClassLoaders, seen) ||
270+
containsOldClassLoaderObject(it.value, oldClassLoaders, seen)
271+
}
272+
}
273+
if (value is Iterable<*>) {
274+
return value.any { containsOldClassLoaderObject(it, oldClassLoaders, seen) }
275+
}
276+
if (value.javaClass.isArray) {
277+
for (index in 0 until Array.getLength(value)) {
278+
if (containsOldClassLoaderObject(Array.get(value, index), oldClassLoaders, seen)) {
279+
return true
280+
}
281+
}
282+
}
283+
return false
284+
}
285+
228286
}

xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,18 @@ internal fun unfreezeHooks(modulePackageName: String) {
130130
HookRegistry.unfreeze(modulePackageName)
131131
}
132132

133+
internal fun unhookAllModuleHooks(
134+
modulePackageName: String,
135+
except: Set<HookHandle> = emptySet(),
136+
) {
137+
val excludedRecords = except.mapNotNull { (it as? VectorHookHandle)?.record }.toSet()
138+
HookRegistry.allRecords
139+
.filter { it.modulePackageName == modulePackageName && it !in excludedRecords }
140+
.forEach(::uninstallRecord)
141+
}
142+
133143
private class VectorHookHandle(
134-
private val record: VectorHookRecord,
144+
val record: VectorHookRecord,
135145
private val hookKey: HookKey?,
136146
) : HookHandle {
137147
override fun getExecutable(): Executable = record.executable
@@ -188,6 +198,9 @@ private fun installRecord(record: VectorHookRecord) {
188198
private fun uninstallRecord(record: VectorHookRecord): Boolean {
189199
if (!record.deactivate()) return false
190200
HookBridge.unhookMethod(true, record.executable, record)
201+
record.id?.let { id ->
202+
HookRegistry.records.remove(HookKey(record.modulePackageName, record.executable, id), record)
203+
}
191204
HookRegistry.allRecords.remove(record)
192205
return true
193206
}

zygisk/proguard-rules.pro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@
77
-keepclasseswithmembers class org.matrix.vector.service.BridgeService {
88
public static boolean *(android.os.IBinder, int, long, long, int);
99
}
10+
11+
-dontwarn io.github.libxposed.annotation.**
1012
-repackageclasses
1113
-allowaccessmodification

0 commit comments

Comments
 (0)