Skip to content

libxposed API 102 support: hot reload, detach and atomic hook replacement#757

Open
HSSkyBoy wants to merge 13 commits into
JingMatrix:masterfrom
HSSkyBoy:master
Open

libxposed API 102 support: hot reload, detach and atomic hook replacement#757
HSSkyBoy wants to merge 13 commits into
JingMatrix:masterfrom
HSSkyBoy:master

Conversation

@HSSkyBoy

@HSSkyBoy HSSkyBoy commented Jun 14, 2026

Copy link
Copy Markdown

This description was rewritten. The original text described the first attempt at this branch. Where that content went is explained at the bottom.

Status

This PR is being redone from scratch rather than patched, in response to the review.

To be unambiguous about what is on this branch right now: the diff shown above is still the original attempt. The rewrite is on a separate branch and is not finished. It will replace this branch only once it has been built and run on a device against the api102-harness contract, not before.

Why a rewrite instead of fixes

The review checked the branch against the libxposed 102.0.0 javadoc and AIDL and measured it on a real device. Several findings were structural rather than local, and patching them individually would not have converged on the same code as building it correctly:

  • registerHotReloadTarget was initiated by the injected process and raced the daemon's module cache. In system_server the modules load long before that cache exists, so the registration always threw and was swallowed — system_server could never become a hot reload target at all.
  • newStateCommitted was set before onHotReloaded ran, so the rollback this PR's original description promised could never execute.
  • ILSPInjectedModuleService had been widened into a read-write control surface with no caller check, and an XposedService was injected into every hooked process. Neither is needed for hot reload.
  • openRemoteFile collapsed two methods with deliberately different documented semantics onto one, making the module's data directory writable from hooked apps.
  • shared/libxposed-annotation hand-reimplemented io.github.libxposed:annotation:1.0.0, an artifact already on Maven Central that both submodules' own build files consume.

What the rewrite is doing differently

Rebased onto current upstream master rather than the older base this branch was built on, and split so each piece is reviewable on its own.

  • Hot reload targets are derived from the daemon's existing process registry instead of being registered by the injected process. A target exists as soon as the daemon hands a module out, so the system_server ordering problem cannot recur by construction.
  • getRunningTargets() reports every hooked process, including modules with zero or several Java entry classes, matching the AIDL wording. Those answer UNSUPPORTED rather than being hidden.
  • hotReloadModule() validates and enqueues, then reports through the callback, instead of blocking the caller for the whole reload.
  • A frozen target is thawed for the transaction. A cached process's binder is alive but unreachable, which previously surfaced as FAILED with a null message — the encoding the API reserves exclusively for a module refusal. Where thawing is not possible the failure now carries a diagnostic instead of masquerading as a refusal.
  • SecurityException is raised only for the two conditions the AIDL reserves it for, so a SecurityException thrown by module code still reaches the module app as a result.
  • replaceHook() / setId() swap the hooker inside the single registered record rather than installing a second one and removing the first, which left both in the native callback multimap. The chain also freezes its hooker list when a call starts, so a replacement cannot leak into an in-flight call.
  • detach() drops the framework's reference to the entry and stops every lifecycle callback including onHotReloading and onHotReloaded.
  • Modules targeting 102 can no longer resolve de.robv.android.xposed, enforced in the module classloader so reflection is covered too.
  • libxposed-annotation is consumed as the published Maven artifact, which also removes the duplicate-class workaround this branch needed.

Where the original content went

Nothing was deleted. The branch as it stood is preserved unchanged at HSSkyBoy/Vector@api102-old (b186e336), which keeps it available as the reference the review asked for. This PR's master branch will be replaced by the rewrite; api102-old is the permanent record of what it used to contain.

@HSSkyBoy

Copy link
Copy Markdown
Author

重新提交的 #743

HSSkyBoy added a commit to HSSkyBoy/Vector that referenced this pull request Jun 21, 2026
@HSSkyBoy

Copy link
Copy Markdown
Author

API 102 contains far more changes than just module hot reload.
So should we revise this PR title to reflect full API 102 adaptation instead of only highlighting hot-reload?

@3gf8jv4dv 3gf8jv4dv mentioned this pull request Jun 21, 2026
@Dyingchen

Copy link
Copy Markdown

大概还要多久正式构建?

@HSSkyBoy

Copy link
Copy Markdown
Author

大概还要多久正式构建?

你在這問也沒用啊,看JingMatrix什麼時候處理該PR
https://t.me/NPatch/974
你可以先試試這個

@m-doescode

Copy link
Copy Markdown

Would this apply to all modules or just ones that make use of this API? Sorry, I'm kind of a newbie. I'd find this really useful if so as I'm trying to write my own Xposed module but it's hard having to reboot every time

@JingMatrix JingMatrix linked an issue Jul 25, 2026 that may be closed by this pull request
@JingMatrix

Copy link
Copy Markdown
Owner

Review assisted by an automated pass over the libxposed 102.0.0 javadoc and AIDL. Every quote below was checked against the 102.0.0 tags, and every runtime result was measured on a real device — but the analysis is machine-assisted and I am flagging that rather than hiding it.

Thanks for the work on this — the hot reload machinery genuinely works, and I want to say that up front because most of what follows is criticism.

I merged this onto current master (clean), built it, flashed it to a Pixel 6 on Android 17, and drove it with a purpose-built API 102 module across five generations plus a separate hook target app. I then went through the libxposed 102.0.0 javadoc and AIDL against the implementation. Everything below quotes the specification verbatim; where the spec is silent I say so rather than dressing a design opinion up as a conformance problem.

The harness is on the api102-harness branch, so every measurement below can be reproduced. It drives the whole contract from adb — refusal, exceptions with and without a message, old-classloader saved state, hook registration from frozen old code, a throw from onHotReloaded, concurrent requests — and reports the target's pid and process age so a reload can be shown not to have restarted it.

The vendored xposed/libxposed and services/libxposed sources are byte-identical to the upstream 102.0.0 tags, so the spec being checked is the one you actually ship.

What conforms

Verified on device:

  • The reload cycle itself. Target detected STALE after a module update, reload swaps the generation in 70–110 ms, same pid, getOldHookHandles() replaced with new hookers. HotReloadedParam reports the right process.
  • onHotReloading returning false produces FAILED with a null message, matching HotReloadResult: "When the old module refuses reload by returning false from onHotReloading, HotReloadResult#message() is null."
  • setSavedInstanceState rejects an object created by the old module classloader with IllegalArgumentException — the exact type HotReloadingParam documents.
  • Concurrent requests answer IN_PROGRESS; a dead target disappears from getRunningTargets(). Target ids are non-reusing and framework-assigned, matching "Opaque identifier assigned by the framework".
  • Restricting hot reload to single-entry modules is exactly what package-info.java requires: "Hot reload is supported only for modules that declare exactly one Java entry class."
  • No lifecycle callback is replayed for the new generation, and the old generation stays strongly reachable across onHotReloaded.
  • freezeHooks() runs immediately before the handle list is captured, satisfying "Before the old hook handle list is captured, the framework freezes old code".
  • getApiVersion() is not overridden on the XposedInterface side; attachFramework(base, detachImpl) is called only by the framework, once per entry.
  • No classloader leak. Twenty reloads grew Dalvik Other from 16 MB to 64 MB PSS, and a forced GC returned it to 13.9 MB — below the starting baseline.

Contradicts the documentation

1. system_server is hooked by the module but never becomes a hot reload target. IXposedService.aidl:

Returns running processes currently hooked by this module.

With the test module scoped to system and to an ordinary app, the framework log shows the module loading into system_server and instantiating its entry:

[ 01:42:58.956  1000: 1653: 1653 D/VectorModuleManager ] Loading module org.matrix.hrmodule
[ 01:42:58.966  1000: 1653: 1653 V/VectorModuleManager ] Loading class class org.matrix.hrmodule.ModuleMain
[ 01:42:58.967  1000: 1653: 1653 D/VectorModuleManager ] Loaded module org.matrix.hrmodule successfully.

and getRunningTargets() returns one entry, the app process. system_server is never listed and can never be reloaded. The cause is a startup ordering problem: registerHotReloadTarget begins with ConfigCache.getModuleByPackage(...) ?: throw RemoteException("Unknown module: ..."), which is a bare state.modules[packageName] lookup with no ensureCacheReady(). When system_server loads modules, performCacheUpdate has not run — it returns immediately while packageManager is null, and PMS is published later on the very system_server thread that is currently inside loadModules. So the lookup misses, the registration throws, and VectorServiceClient swallows it (runCatching { ... }.getOrNull() ?: -1L). Nothing retries and nothing logs it. This is the target for which not rebooting matters most, so I would treat it as the headline defect rather than an edge case.

2. Hot reload of a frozen process is reported as a module refusal. HotReloadResult:

When the old module refuses reload by returning false from onHotReloading, HotReloadResult#message() is null. When reload fails because of an exception, the message contains a framework-provided diagnostic string.

Freezing the target's cgroup and requesting a reload gives status=FAILED message=null afterMs=1, with onHotReloading never invoked — byte-identical to a genuine refusal. Unfreeze and bring the process forward, and the same request reaches the module normally (afterMs=9). Same pid throughout; the only variable is the freeze state.

This is the common case, not a corner: a module's targets are usually cached background processes, and Android freezes those. The isBinderAlive check in ApplicationService.hotReloadTarget does not help — a frozen process's binder is alive, so PROCESS_DIED is not reported either — and the target is left stuck at TARGET_STATE_FAILED. The daemon already drives AMS elsewhere (ModuleService.sendBinder forges a provider call), so thawing the target for the duration of the transaction looks feasible; at minimum the frozen case must not be reported as a refusal.

The same contract is broken more generally: ModuleService.kt:180 and InjectedModuleService.kt:80 pass throwable.message straight through, so a module throwing new IllegalStateException() also yields FAILED with a null message. Measured. XposedModuleInterface is explicit that this path must carry "a framework-provided diagnostic message". Wrap the throwable class and message, and guarantee non-null on the exception path.

3. openRemoteFile is documented read-only and this makes it writable. XposedInterface:

Open a file in the module's shared data directory. The file is opened in read-only mode.

VectorContext.openRemoteFile is that method, and it is a bare pass-through to ILSPInjectedModuleService.openRemoteFile, which this PR changes from MODE_READ_ONLY to MODE_CREATE or MODE_READ_WRITE (InjectedModuleService.kt:137). The neighbouring method reinforces the intent: "Gets remote preferences stored in Xposed framework. Note that those are read-only in hooked apps." Two consequences: the documented @throws FileNotFoundException If the file does not exist becomes unreachable, and a hooked app process gets write access to the module's data directory. Measured: from org.matrix.hrtarget (uid 10320, module app is uid 10323) module code created and wrote /data/adb/lspd/modules/0/<module>/files/written_by_hooked_app.txt. The module-app-facing XposedService#openRemoteFile is separately documented as "The file will be created if not exists." — the PR collapses both onto one AIDL method and picks the module-app semantics for both. They need to stay distinct.

4. hotReloadModule is meant to enqueue, and this runs the whole reload inside the call. IXposedService.aidl:

Implementations should validate and enqueue the request promptly, then report completion through the callback.

ApplicationService.hotReloadTarget makes a blocking binder call into the target and only invokes the callback afterwards, so the module app's XposedService.hotReloadModule() blocks for the entire reload. Measured: a 4000 ms sleep in onHotReloading blocked the caller for the full 4 s. A module app calling this from the main thread will ANR, a daemon binder thread is pinned for the duration, and there is no timeout.

5. detach() does not do what its javadoc says. XposedInterfaceWrapper:

After this method is called, the framework removes its reference to the entry instance and will no longer invoke any lifecycle callbacks (such as XposedModuleInterface#onPackageLoaded, XposedModuleInterface#onHotReloading, etc.) on the entry instance that called this method.

onHotReloading is named explicitly. The detach implementation is VectorLifecycleManager.detach(moduleInstance), which only removes from activeModules — and activeModules gates onPackageLoaded, onPackageReady and onSystemServerStarting only. VectorModuleManager.hotReloadModule dispatches onHotReloading and onHotReloaded by iterating oldState.entries directly, so a detached entry still receives both. Separately, "the framework removes its reference to the entry instance" is not honoured: moduleStates keeps a strong reference to every entry, detached or not. detach() is new in 102, so this is in scope for this PR.

6. A module-thrown SecurityException collides with the AIDL's reserved meaning. IXposedService.aidl:

@throws SecurityException if the target id is invalid or no longer belongs to this module

ModuleService.kt:172 and InjectedModuleService.kt:76 do if (throwable is SecurityException) throw throwable. Your own checks in ApplicationService.hotReloadTarget raise SecurityException for exactly the two documented cases, which is right — but this rethrow also catches a SecurityException raised by module code in onHotReloading, arriving over binder from the target. The module app then sees a raw SecurityException meaning "invalid target id" and never gets its callback, where the spec requires FAILED. Filter on the two conditions you raise yourself, not on the exception type.

7. Hook replacement is documented atomic and is not. HookBuilder.setId:

A new hook with the same id in the same module on the executable will replace the old one atomically, and the old hook handle will be invalid.

and HookHandle.replaceHook: "Atomically replaces this hook with a new hooker and returns the new hook handle." Both paths do installRecord(new) before uninstallRecord(old). The native side keys on IsSameObject and holds both in the callback multimap in between, and both report isActive(), so an invocation that snapshots inside that window runs both hookers. Deactivating the old record first would at least turn a duplicate into a gap; doing it properly needs an atomic swap in hook_bridge.cpp.

8. In-flight calls are supposed to be snapshot-based. Same javadoc:

The hook chain is snapshot based. Replacing or adding a hook while a call is running does not affect that in-flight call.

onHotReloading repeats it: "In-flight hook calls keep using the hook chain snapshot that was active when they started." VectorNativeHooker.callback filters by isActive() at snapshot time, which is correct. But VectorChain.internalProceed re-checks record.isActive() at every node as the call walks the chain, so a hook unhooked or replaced mid-call is dropped from a call that was already running. That is what the sentence forbids, and hot reload is exactly when it happens.

That skip path has a second defect: return nextChain.internalProceed(...) bypasses executeDownstream, so the node never records downstreamResult/downstreamThrowable. Its parent then sees proceedCalled == true with a null downstreamThrowable, takes the "crashed after proceed" branch in handleInterceptorException, and returns null instead of rethrowing a genuine downstream exception. Wrapping the skip in executeDownstream { } fixes that half.

Documented but not implemented

9. autoHotReload does not exist. package-info.java lists it as an API 102 module.prop key:

autoHotReload (boolean, API 102+) - whether app updates should automatically trigger hot reloading. App-update hot reloading still proceeds only when onHotReloading() returns true.

and onHotReloading names the same trigger: "reloading is triggered through the service, or by app updating if autoHotReload is set to true in module.prop". There is not one occurrence of autoHotReload anywhere outside the vendored spec, and FileSystem.loadModule reads only targetApiVersion. Half of the documented trigger set is missing.

10. The 102 behaviour change for legacy APIs is not enforced. XposedInterface.API_102, under "Behavior changes: Modules targeting 102 or higher":

Libxposed modules can not call legacy de.robv.android.xposed APIs.

targetApi is used only to choose MODERN vs LEGACY loading (FileSystem.kt:189-197); nothing stops a module declaring targetApiVersion=102 from calling de.robv.

11. The clear half of the preference diff is emitted but never consumed. The daemon-side parsing matches RemotePreferences.Editor.buildCommitBundle() key for key, including the new clear handling — that part is right. But this PR also makes deleteRemotePreferences push Bundle{clear=true} to hooked processes (ModuleService.kt:219, InjectedModuleService.kt:129), and the receiver, VectorRemotePreferences.onUpdate, only inspects "delete" and "put". So prefs.edit().clear() and deleteRemotePreferences(group) are no-ops in every hooked process: stale keys survive, no OnSharedPreferenceChangeListener fires, and the daemon's database silently diverges from the hooked process until the app restarts.

Not covered by the specification

Design and security concerns. The docs say nothing either way, so treat these as my opinion rather than as breaches.

12. ILSPInjectedModuleService becomes a full read-write control surface with no caller check. On master it is a deliberately read-only subset. This PR adds getScope, requestScope, removeScope, updateRemotePreferences, deleteRemotePreferences, deleteRemoteFile and hotReloadModule. ModuleService guards every equivalent with ensureModule() (calling uid must equal the module's appId); InjectedModuleService has no equivalent anywhere. The spec never states an authentication requirement, so this is not a doc violation — but it means any code in a process the module is injected into can rewrite that module's preferences, drop its scope, delete its files and force reloads. The doc's posture is at least suggestive: "Note that those are read-only in hooked apps."

13. VectorXposedService injected into hooked processes is a separate feature. XposedProvider is where the spec puts the service binder, i.e. the module app. Reflecting into the package-private XposedServiceHelper.onBinderReceived from VectorModuleManager.injectXposedService puts a full XposedService inside every hooked process. That is what actually makes remote preferences writable there — via upstream's writable RemotePreferences, not via XposedInterface, which correctly still throws UnsupportedOperationException from edit(). Whatever its merits, it is unrelated to hot reload and deserves its own PR and its own security discussion.

14. onHotReloaded throwing leaves the process migrated but reported as failed. newStateCommitted is set before the callback runs, so the rollback the PR description promises never executes. Measured: the service reported FAILED, getRunningTargets() reported state=FAILED loadedVersionCode=3, and the process was already running generation 4 with generation-4 hooks. loadedVersionCode is documented as "only a diagnostic value", so this is not a doc breach, but the framework's own bookkeeping contradicts reality.

15. Old code can still register hooks during onHotReloading. The freeze starts after the callback returns. Measured: a hook(...).intercept(...) from old code inside onHotReloading succeeded and the stray hook appeared in getOldHookHandles() (2 → 3). The doc only requires freeze-before-capture, which you do, so this is not a violation — but the sentence's stated purpose is "so further hook registrations from old code fail", and it is worth confirming the intent upstream. Related: the freeze gates intercept() only, not replaceHook(), and keys on the hooker's classloader, so a Hooker whose class comes from elsewhere slips through.

16. The daemon's RELOADING check is a TOCTOU. if (state == RELOADING) ... state = RELOADING is not atomic. In practice VectorModuleManager.hotReloadModule is @Synchronized on a process-wide monitor, stronger than per-target, so this cannot interleave two reloads — the second just queues and runs a full redundant reload reported as SUCCEEDED. Low impact, but AtomicInteger.compareAndSet is a one-line fix.

17. Reporting UNSUPPORTED when a new generation cannot be built. package-info.java says frameworks "may also report hot reload as unsupported when they cannot provide a valid new module generation" — "may", so FAILED is permitted. Still, UNSUPPORTED carries more information when instantiateEntries comes back short.

18. getRunningTargets() hides multi-entry modules entirely. No target is registered unless moduleClassNames.size == 1, so such a module gets an empty list rather than targets that answer UNSUPPORTED. getRunningTargets is documented as "Returns running processes currently hooked by this module", not "hot-reloadable processes".

19. shared/libxposed-annotation duplicates a published artifact. io.github.libxposed:annotation:1.0.0 is on Maven Central and is what both submodules' own build files use (compileOnly(libxposedAnnotation)). Hand-copying the classes into the same package is what the -dontwarn and the "duplicate annotation classes in zygisk dex merge" commit are working around. Replace the module with compileOnly("io.github.libxposed:annotation:1.0.0").

Pre-existing, not this PR

Found while checking the chain against the spec. Worth separate issues rather than scope creep here.

  • ExceptionMode.PROTECTIVE recovery can rethrow an already-suppressed exception. executeDownstream stores a node's own hooker exception in downstreamThrowable, and handleInterceptorException never clears it when it recovers. With two DEFAULT hooks where the inner one throws before proceed() and the outer one throws after, the parent's nextChain.downstreamThrowable?.let { throw it } rethrows the inner hook's already-suppressed exception into the app, where the doc requires the proceeded value: "if the exception is thrown after proceed, the framework will return the value / exception proceeded as the result." Deterministic, no concurrency needed. VectorChain.kt is unchanged here by this PR.
  • ExceptionMode.DEFAULT is hard-wired to protective. The doc says "Follows the global exception mode configured in module.prop", and package-info.java names the key: exceptionMode (string) [protective|passthrough]. It is never read.
  • Chain.getArgs() returns args.toList(), a mutable ArrayList, against "The returned list is immutable."
  • intercept() rejects Method.invoke but not Constructor#newInstance, which the @throws IllegalArgumentException clause names explicitly.
  • The daemon parses module.prop with a naive split("="); package-info.java specifies Java Properties format. The manager app already does this correctly, so it is only the daemon side.

Nits

  • New methods are inserted mid-interface in ILSPInjectedModuleService.aidl, renumbering requestRemotePreferences, openRemoteFile and getRemoteFileList. Harmless while everything ships in one zip, but upstream pins ids explicitly (= 1, = 10) for exactly this reason.
  • HotReloadTargetInfo publishes this into hotReloadTargets from its own init; ProcessInfo.binderDied removes targets without unlinking their death recipients.
  • InjectedModuleService.requestScope puts the if (callback != null) test inside the loop and reports nothing when it is null. The AIDL method is oneway, so a throw would be silent too.
  • Hot reload targets are never unregistered when a module is disabled or uninstalled.
  • The reload path skips the "Xposed API classes are compiled into $pkg" integrity check that loadModule performs when it builds the first classloader.

Recommendation

To be direct rather than leave it hanging: this PR will most likely be closed. I intend to do the API 102 and hot reload work myself later, and I will work from the checklist above.

The reason is that the branch reads as mostly AI-generated, and this particular feature does not survive that level of attention. The review above is largely evidence of it:

  • The PR description asserts behaviour the code does not have. "On failure: rollback, unhook new entries, unfreeze hooks"newStateCommitted is set before onHotReloaded runs, so that rollback never executes (item 14). "includes state locking" — the RELOADING check is a plain check-then-set (item 16).
  • It claims the finalized API 102, but detach() does not honour its own javadoc, autoHotReload appears nowhere in the tree, and the 102 legacy-API behaviour change is unenforced (items 5, 9, 10). All three are stated plainly in package-info.java and XposedInterfaceWrapper, which suggests the specification was not read end to end.
  • shared/libxposed-annotation hand-reimplements io.github.libxposed:annotation:1.0.0 — an artifact on Maven Central that is exactly what both submodules' own build files consume (item 19) — and then needed a follow-up commit to fix the duplicate-class fallout it caused.
  • Most importantly, the feature does not work where it matters. Hot reload never applies to system_server, and silently fails for any frozen target; both surface to the module app as an ordinary refusal (items 1 and 2). Neither is visible from reading the diff. They only appear when you run it on a device against a real module, which is the part that was not done.

Bundling an unrelated privilege change on top of that settles it — the full read-write ILSPInjectedModuleService and the injected XposedService, neither needed for hot reload, neither carrying the uid check that guards every equivalent in ModuleService (items 12 and 13).

I have flagged at the top that this review is machine-assisted as well, so to be fair about the distinction: the difference I care about is not the tool, it is that every claim above was checked against the specification text and then run on a device.

Thanks for raising the topic regardless. API 102 hot reload is worth having, and I would not have prioritised it this soon otherwise. Please leave the branch up as a reference.

HSSkyBoy added 4 commits July 26, 2026 16:21
Bumps both libxposed submodules to 102.0.0 and implements the members the
new API makes abstract.

attachFramework() now takes the detach implementation, so detach() is wired
to VectorLifecycleManager. activeModules is the framework's only strong
reference to entry instances, which is what the javadoc requires detach()
to drop; any dispatch added later must iterate it rather than keep an entry
list of its own.

HookHandle.replaceHook() and HookBuilder.setId() are implemented without
touching the native layer. Installing the new record before uninstalling the
old one would leave both in the callback multimap, so a call that snapshots
in that window would run both hookers. Instead the record stays registered
and its hooker is swapped through a volatile write: the native side indexes
records by object identity and keeps priority in the map key, and
replaceHook is specified to preserve executable, priority, exception mode
and id, so nothing native-visible changes. Handle validity is tracked with a
per-record epoch, and same-id intercept() reuses the installed record
through a per-(module, executable, id) registry.

Because the hooker is now mutable, VectorChain freezes the hooker list once
when the root chain is built instead of reading it per node, so a mid-call
replacement cannot leak into an in-flight call as the chain is documented to
be snapshot based.

libxposed-annotation is consumed as the published Maven artifact, which is
what both submodules' own build files use, rather than being reimplemented
in-tree.
The id path read the registry and then installed, so two threads racing on
one id both saw no entry and both installed a native record. That is the
duplicate-record window the in-place replacement design exists to avoid, and
the later registry write also orphaned the first record, leaving it hooked
with no way to reach it. Claiming the key with putIfAbsent makes the winner
the only installer; everyone else takes the volatile-write path.
XposedInterface.API_102 states the behaviour change plainly: "Libxposed
modules can not call legacy de.robv.android.xposed APIs." targetApiVersion
only ever selected MODERN over LEGACY loading, so nothing stopped a module
declaring 102 from using the legacy bridge.

The module classloader is where this has to be enforced. Its parent is the
framework's own loader, which carries de.robv, so refusing to resolve that
package covers reflective lookups against the loader as well as direct
references. targetApiVersion is carried on PreLoadedApk to reach it.

module.prop parsing follows JingMatrix#794: Java Properties instead of a hand-rolled
split("="), tolerating a malformed file rather than making the APK
unloadable, and leading-digit integers matching the manager's
extractIntPart. That PR is still open and owns this parsing plus an
exceptionPassthrough field on the same parcelable, so expect both to
conflict here and resolve in its favour.

Note that :daemon does not compile yet: bumping libxposed to 102 added
getRunningTargets() and hotReloadModule() to IXposedService, which the hot
reload work still has to implement.
IXposedService gained getRunningTargets() and hotReloadModule() in API 102.
Targets are built from the heartbeat registry ApplicationService already
keeps, not from a registration call made by the injected process. That
ordering matters: in system_server the module is loaded long before the
daemon's config cache is populated, so a module-initiated registration
cannot be answered and the process would silently never become a target -
the one process where not rebooting is worth the most.

getRunningTargets() reports every hooked process, including modules with
zero or several Java entry classes, because the AIDL documents it as running
processes hooked by this module rather than hot-reloadable ones. Those
targets answer UNSUPPORTED instead of being hidden.

hotReloadModule() raises SecurityException only for the two conditions the
AIDL reserves it for, an unknown target id or one belonging to another
module, so a SecurityException arriving from module code can still be
reported as a result. Claiming a target is a compareAndSet rather than a
read followed by a write, since reloads are serialized per target.

The reload itself is not implemented yet and is reported as UNSUPPORTED,
which package-info permits when the framework cannot provide a valid new
module generation. Reporting SUCCEEDED would be untrue and FAILED would tell
the module app that its own code refused.

Module carries versionCode explicitly: ApplicationInfo.longVersionCode is
hidden, and the system_server path builds its modules before PMS exists, so
it reads the version through PackageParser to avoid reporting 0 there.
The daemon reaches the hooked process through a new IHotReloadTarget, which
each process registers while the framework bootstraps rather than when its
modules load. Registration therefore does not depend on the daemon's module
cache, which is what previously kept system_server from ever becoming a
target.

Ordering follows the callback contract. Old code is frozen before
onHotReloading runs, not after, so hook registrations attempted from inside
the callback fail while the unhook and replace paths the same javadoc tells
modules to use keep working; the freeze gates the single registration entry
point and keys on the generation rather than on the hooker's classloader.
The old hook handle list is captured after that.

The new generation becomes the committed one only after onHotReloaded
returns. If it throws, the hooks the new generation installed are removed,
the old entries are made live again and the old generation stays committed.
Committing first would make that rollback unreachable, which is what the
previous attempt did while advertising it.

Result encoding keeps the two failures distinguishable, since a null message
is reserved for onHotReloading returning false: every other failure carries a
diagnostic, synthesized from the throwable's class when it has no message of
its own. A process whose entries have all detached reports UNSUPPORTED rather
than borrowing the refusal encoding for a decision no module code made. A
frozen target is thawed for the transaction, because a cached process's
binder is alive but unreachable and the failure would otherwise be
indistinguishable from a refusal.

detach() is honoured for both callbacks: dispatch resolves entries through
VectorLifecycleManager.activeModules, so a detached entry is skipped and the
framework holds no entry list of its own. The old generation stays strongly
reachable only for the duration of the cycle.

Hot reload also drops the module's hook id registrations, which strongly
reference hook records and through them the retired classloader.

Not verified on a device yet. The frozen-target path, the freeze, and the
rollback are the parts that only surface at runtime.
@HSSkyBoy HSSkyBoy changed the title feat: Implement finalized libxposed API 102 hot reloading Rewrite libxposed API 102 support: hot reload, detach and atomic hook replacement Jul 26, 2026
@HSSkyBoy HSSkyBoy changed the title Rewrite libxposed API 102 support: hot reload, detach and atomic hook replacement libxposed API 102 support: hot reload, detach and atomic hook replacement Jul 26, 2026
HSSkyBoy added 7 commits July 26, 2026 18:37
Measured on device: getOldHookHandles() returned two handles on the first
reload of a process and none on every reload after it, while the hooks
themselves stayed installed and serving.

The reload path dropped the module's whole tracking set once the old handles
had been captured. That is wrong for the replacement design: replaceHook
swaps the hooker inside a record that is already installed, so forgetting the
record leaves a live hook the framework can no longer hand back. The damage
is worse than a missing list, because the default onHotReloaded unhooks
exactly the handles it is given - an empty list means the retired hookers,
and the classloader behind them, are never released.

Tracking and the id registry now survive a reload, keyed by package name as
they already were, and the rollback undoes only what the incoming generation
added on top of the set it inherited.

Verified on device: four consecutive reloads now report two old handles each.
ILSPApplicationService leaves transaction ids implicit, so a method added at
the top renumbers every method below it. Everything ships in one zip, so it
worked, but it is the mistake the interface's own layout invites and the
reason upstream pins ids explicitly elsewhere.
The module list only used minVersion and targetVersion to raise a warning
when they were out of range, so the API a module was built against - which
decides how the framework loads it - was never visible next to the module
itself. It now sits on the version line, as a range when min and target
differ, and is omitted entirely when a module declares no metadata rather
than rendering "API 0".

The CLI reported the framework version but had no way to show the API
version the manager already reads over binder.

zh-rTW was missing menu_select, menu_select_all, menu_select_none and
menu_auto_include. That file is localised for Taiwan rather than converted
from zh-rCN - it uses 程式, 儲存 and 停用 - so the additions follow the same
conventions instead of copying zh-rHK.
Three gaps around the state a module app polls for.

HookedProcess.TARGET_STATE_STALE was never reported. A target's state only
moved when a reload finished, so a process running an older generation kept
answering UP_TO_DATE and the documented "notice STALE, then request a reload"
flow had nothing to notice. The reported state now compares the generation a
target loaded against the installed one; RELOADING and FAILED still describe
the last attempt and outrank it. A successful reload moves the target onto
the new version, which is what clears STALE.

autoHotReload was documented in package-info as an API 102 module.prop key
and appeared nowhere in the tree, leaving half the documented trigger set
missing. It is now read alongside minApiVersion - which package-info lists as
required and the daemon also ignored - and an app update reloads that
module's stale targets. The module keeps the final say: this drives the same
cycle as a service request, so returning false from onHotReloading still
cancels it. The trigger runs after the cache swap, so the generation targets
are asked to load is the one that was just installed.

Targets were only forgotten when their process died, so disabling or
uninstalling a module left its targets listed.
Measured: system_server's target reported loadedVersionCode=0 and, once
targets started comparing versions, therefore reported STALE permanently -
no reload could ever satisfy it.

That path builds its modules before PMS exists, so it parses the APK
directly, and PackageParser.Package does not expose a usable version on
current releases. ApplicationInfo carries one but the field is hidden, so it
is read reflectively rather than by adding a stub that would shadow the real
class for the whole daemon.

A zero is now treated as an unknown generation rather than an old one, in
both the reported state and the auto-reload selection, so a device where the
lookup fails degrades to "no opinion" instead of a target stuck at STALE.
The previous attempt read the version off the ApplicationInfo that
PackageParser produces. Measured on device: it stays zero, because a raw
parse does not populate it, so system_server's target still had no
comparable generation.

It is taken from the first cache update that knows the version instead.
Nothing can update a module between system_server loading it and that point
without also rebuilding the process, so the value is the one that target is
actually running. Zero keeps meaning "unknown" rather than "old", which is
what stops a target that never gets a version from reporting STALE forever -
but it no longer stays unknown, so system_server is now visible to stale
reporting and to autoHotReload like any other target.
Rationale that belongs in commit messages had been left in the source as KDoc
blocks on private helpers and fields, which buries the code it describes.
Kept only the lines that explain an ordering or a constraint that is not
visible from the code itself.
@HSSkyBoy

Copy link
Copy Markdown
Author

The branch has been replaced with the rewrite. It is 11 commits on top of current master rather than the older base the first attempt used, and the original branch is preserved unchanged at HSSkyBoy/Vector@api102-old (b186e336).

Since the objection to the first attempt was that its claims had not been checked against a running device, everything below was measured. Two caveats about the measurements, stated up front:

  • The device is a OnePlus CPH2691 on Android 16, not a Pixel 6 on Android 17. Some of the review's findings are platform-sensitive and the numbers will differ.
  • The api102-harness module was extended locally for four of the checks below (a legacy-API probe, a second entry class, a detaching entry). Those additions are not on the harness branch, so those four rows are not reproducible from it as-is. The rest use the flags it already ships.

Measured against the review

# Finding Measured now
1 system_server never becomes a target getRunningTargets() returns [0] system pid=3750 uid=1000 after boot. It is also reloaded in place, not merely listed — see item 9.
2 Frozen target reported as a refusal Wrote 1 to the target's cgroup.freeze, confirmed the probe stopped answering, then requested a reload: onHotReloading ran, SUCCEEDED afterMs=78, and the freeze state was 1 again afterwards.
2 Exception without a message is indistinguishable from a refusal --ez throwNullMsg gives FAILED message=java.lang.IllegalStateException: no message. A genuine refusal still gives FAILED message=null.
4 hotReloadModule blocks the caller With --el sleepMs 4000 the dispatch returned at 18:42:00.095 and the callback arrived at 18:42:04.163 (afterMs=4069).
5 detach() does not stop the callbacks it names A detaching entry logged no onPackageLoaded/onPackageReady/onHotReloading while a second, non-detached entry in the same module received them. Calling it twice does not throw. With the sole entry detached, a reload answers UNSUPPORTED message=Every entry of org.matrix.hrmodule has detached in this process — not the refusal encoding, since no module code ran.
6 Module-thrown SecurityException collides with the AIDL's meaning --ez secEx gives FAILED message=java.lang.SecurityException: module code threw SecurityException. SecurityException is now raised only for the two conditions the AIDL reserves it for.
9 autoHotReload does not exist Installing a generation with autoHotReload=true reloaded both targets with no service request: system and org.matrix.hrtarget went [V1] onHotReloading extras=null[V3] onHotReloaded. extras is null, as the javadoc says it is for app-update triggers.
10 The 102 legacy-API behaviour change is unenforced From a module declaring targetApiVersion=102: Class.forName("de.robv.android.xposed.XposedBridge")ClassNotFoundException. Enforced in the module classloader, so reflection is covered rather than only direct references.
12 ILSPInjectedModuleService becomes a read-write surface Not carried over. It still has four read-only methods and openRemoteFile is still MODE_READ_ONLY, so item 3 does not arise either.
13 XposedService injected into hooked processes Not carried over. The harness's own probe from inside the hooked process reports no service.
14 onHotReloaded throwing leaves the process migrated but reported failed --ez throwOnReloaded gives FAILED, and afterwards state=FAILED loadedVersionCode=1 with the process still on the old generation (HOOKED-V1), same pid, still serving. The framework log says kept the previous generation.
15 Old code can still register hooks during onHotReloading --ez frozenHook now gets IllegalStateException: This module generation has been retired by a hot reload and cannot register hooks, and getOldHookHandles() stays at 2 rather than growing to 3. The freeze keys on the generation, not on the hooker's classloader.
16 The RELOADING check is a TOCTOU Three concurrent requests give one SUCCEEDED and two IN_PROGRESS, and exactly one reload cycle runs — not a second redundant one reported as SUCCEEDED.
17 / 18 Multi-entry modules are hidden A two-entry module still appears in getRunningTargets(), and a reload of it answers UNSUPPORTED with a message.
19 shared/libxposed-annotation duplicates a published artifact Gone. io.github.libxposed:annotation:1.0.0 is consumed as a compileOnly dependency, which also removes the duplicate-class workaround the first attempt needed.

Two more, not in the review but in the same area: TARGET_STATE_STALE was never reported by the first attempt either, so the documented "notice STALE, then request a reload" loop had nothing to notice. Installing a newer generation now moves targets to STALE and a successful reload clears it. minApiVersion is read as well — package-info lists it as required and the daemon ignored it.

What running it found that reading the diff did not

Your point about this stands, so it is worth being specific about where it applied here.

getOldHookHandles() returned two handles on the first reload of a process and none on every reload after it. Everything compiled, the first reload looked perfect, and the hooks kept working — the list was simply empty from the second generation onward. The cause was that the reload dropped the module's hook tracking once the old handles were captured, which is wrong for a design where replaceHook swaps the hooker inside a record that stays installed. The consequence is worse than a missing list: the default onHotReloaded unhooks exactly the handles it is given, so an empty list means the retired hookers, and the classloader behind them, are never released. Fixed in 5ae6eb42; four consecutive reloads now report two handles each.

The same applied to the cgroup layout. This device exposes cgroup.freeze at both the uid and the per-pid level, and the uid-level file read 1 while the process was demonstrably responsive. Reasoning from that would have led to "fix" a path precedence that was already correct; measuring first showed the per-pid file is the one that means anything here.

Not verified

  • Item 7 (atomic replacement) and item 8 (in-flight snapshot). Both are implemented — replacement is a volatile write to the single already-registered record, so the native callback multimap never holds two, and the chain freezes its hooker list when the root chain is built rather than reading it per node. Neither is verified: observing them needs a concurrency harness that races a replacement against an in-flight call, which I did not build. I am not claiming these from the code alone.
  • 80c16bcf, the last commit, is not verified on device. The commit before it tried to read the version off the ApplicationInfo that PackageParser produces; measured, that stays zero, so system_server's target had no comparable generation and would have reported STALE forever. 80c16bcf takes it from the first cache update that knows it instead. That change is built but was not flashed.

Left alone

#794 owns the API 101 findings, including the clear half of the preference diff and everything in the "Pre-existing" section. None of it is touched here, so the two should not collide beyond the module.prop parsing, which deliberately mirrors yours — Properties, tolerating a malformed file, leading-digit integers matching extractIntPart — so resolving in your favour is a no-op.

hookClassInitializer(), the late-injected system_server case, and invokeOriginalMethod on a Constructor receiver are left where you put them.

I am not asking for this to be merged as-is; the reasonable next step is whichever commits you want in whatever order suits the work you had planned. Splitting them so each stands alone was the point.

Consuming io.github.libxposed:annotation as compileOnly is right - the
annotations are metadata and nothing reads them at runtime - but it leaves
the vendored API and service sources referencing classes that are not
packaged, and R8 fails the release build over it.

Release was never built locally while this was developed, so the failure
only showed up in CI.

The rule rides on xposed's consumer rules for :zygisk, and is repeated in
:daemon, which minifies the service sources through a module that publishes
no consumer rules of its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API 102 support

4 participants