libxposed API 102 support: hot reload, detach and atomic hook replacement - #757
Conversation
|
重新提交的 #743 |
|
API 102 contains far more changes than just module hot reload. |
|
大概还要多久正式构建? |
你在這問也沒用啊,看JingMatrix什麼時候處理該PR |
|
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 |
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 The vendored What conformsVerified on device:
Contradicts the documentation1. system_server is hooked by the module but never becomes a hot reload target.
With the test module scoped to and 2. Hot reload of a frozen process is reported as a module refusal.
Freezing the target's cgroup and requesting a reload gives This is the common case, not a corner: a module's targets are usually cached background processes, and Android freezes those. The The same contract is broken more generally: 3.
4.
5.
6. A module-thrown
7. Hook replacement is documented atomic and is not.
and 8. In-flight calls are supposed to be snapshot-based. Same javadoc:
That skip path has a second defect: Documented but not implemented9.
and 10. The 102 behaviour change for legacy APIs is not enforced.
11. The Not covered by the specificationDesign and security concerns. The docs say nothing either way, so treat these as my opinion rather than as breaches. 12. 13. 14. 15. Old code can still register hooks during 16. The daemon's 17. Reporting 18. 19. Pre-existing, not this PRFound while checking the chain against the spec. Worth separate issues rather than scope creep here.
Nits
RecommendationTo 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:
Bundling an unrelated privilege change on top of that settles it — the full read-write 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. |
|
The branch has been replaced with the rewrite. It is 11 commits on top of current 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:
Measured against the review
Two more, not in the review but in the same area: What running it found that reading the diff did notYour point about this stands, so it is worth being specific about where it applied here.
The same applied to the cgroup layout. This device exposes Not verified
Left alone
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. |
|
@HSSkyBoy Please resolve all conflicts with the master. |
Completed |
|
@HSSkyBoy can you rebase this with latest changes |
…c hook replacement) Takes HSSkyBoy's branch as the base rather than reimplementing it. The head it was reviewed at is not the head it has now: seventeen commits later the targets are derived from the daemon's process registry rather than registered by the injected process, so system_server is a target by construction, and a cached target is thawed before the transaction instead of being reported as a module refusal. Both of the device-only defects the earlier review found are fixed there. Resolved: master's libs.versions.toml no longer carries hiddenapibypass or a pinned kotlin-stdlib, so only libxposed-annotation is taken from the branch; the lifecycle manager keeps both detach() and master's Q gate on onPackageLoaded. xposed/libxposed moves on to 39cac08, one commit past the 102.0.0 tag, which requires a module to declare at least one Java entry.
Two throwaway apps for exercising the libxposed API 102 hot reload contract from adb: a single-entry module that hooks one class in a target app, and a target that reports the hooked values along with its pid and process age, so a reload can be shown not to have restarted it. The module's generation is baked in at build time, so the log states unambiguously which generation of code answers after a reload. Extras passed through HotReloadingParam drive the documented failure paths: refusal, exceptions with and without a message, a SecurityException, old-classloader saved state, hook registration from frozen old code, and a throw from onHotReloaded. Written while reviewing JingMatrix#757. The README records what it found, notably that system_server never becomes a hot reload target and that a frozen target is reported to the module app as a module refusal. (cherry picked from commit dd3e297)
…cement Adds the whole API 102 surface: hot reloading a module generation in place, XposedInterfaceWrapper#detach, hook ids with atomic replacement, and the rule that modules targeting 102 or higher cannot reach the legacy de.robv API. Hot reload targets are derived from the daemon's existing process registry rather than registered by the injected process, so system_server - whose modules load long before that cache exists - is a target by construction. getRunningTargets() reports every hooked process, including modules that cannot be reloaded, which answer UNSUPPORTED rather than being hidden. hotReloadModule() validates and enqueues, then reports through the callback. A frozen target is thawed for the transaction instead of surfacing as FAILED with a null message, which the API reserves for a module refusal. Squashed from the branch this PR carried (head f02468f, seventeen commits) so that the pull request is a linear series against master with no merge commits and none of master's own history in it. The corrections that follow rewrite parts of this, and reviewing them against a moving base was the problem this solves.
The interface releases the old generation "after this callback returns or throws", so a throw is not a signal to undo the reload. Rolling back could not have honoured that anyway: by the time onHotReloaded throws, the new code may already have called replaceHook, and unhooking only what was added since leaves the restored old entries running new hookers - a half-swapped state with no name. So the swap is committed first and a throw is reported as FAILED with the exception's diagnostic, which is exactly what the API describes: the process runs new code that migrated some of its hooks and not others. That makes 'did the reload succeed' and 'did the generation change' two different questions, so HotReloadOutcome now answers both. Without the second one the daemon would keep reporting the old loadedVersionCode for a target that is demonstrably running the new code, and getRunningTargets() would be lying about which generation is loaded.
The interface promises that replacing a hook is atomic and that a call already in flight keeps the chain it started with. Mutating the hooker inside the installed record gets the first for free - the native map never sees two records - but pays for the second by having VectorChain freeze an Array(hooks.size) of hookers at the start of every hooked call. That is an allocation on the hottest path in the framework, for a property only hot reload needs. HookBridge.replaceCallback swaps the jobject inside the callback multimap under the same lock callbackSnapshot takes, so a snapshot sees exactly one of the two, and a snapshot taken earlier keeps working because it copied the reference into a Java array of its own. The record goes back to being immutable, the per-call array goes away, and in-flight isolation falls out of the existing design. It also fixes a defect by construction. A superseded handle could still unhook the hook that replaced it - unhook() checked only whether the record was installed, never whether this handle still owned it - although the interface says such a handle 'is no longer valid'. Now the record it holds is no longer in the map, so IsSameObject finds nothing and there is nothing to cancel. Hook identity moves into VectorHookHandle and VectorHookRegistry: one live handle per registration, ids scoped to (module, executable, id) under a per-module lock that the reload also takes to freeze old code, so a registration racing the freeze either lands before it and is handed to the successor, or fails. And setId now keeps the caller's priority and exception mode. Only the handle-based replaceHook is specified to inherit them; registering a new hook that happens to carry the same id is a new hook.
RegisterNativeLib appends to a list the dlopen hook walks without stopping at the first match, so a name recorded twice means native_init runs twice for one library. Two paths were producing duplicates: loadModule called recordNativeEntrypoint after buildGeneration had already done it, and every hot reload records the same module's names again for the new generation. Deduplicating natively fixes both, and is the only place that can - the spec is explicit that the framework does not dlclose or call JNI_OnUnload across a hot reload, so there is no removal to pair a re-registration with. Also drops minApiVersion from PreLoadedApk. It was parsed and stored and never read: the API puts that check on the module through getApiVersion(), and the manager reads module.prop itself for what it displays.
API 102 forbids a module targeting 102 or higher from reaching de.robv.android.xposed, and the module class loader is the only place that can be enforced - it is the one chokepoint direct linkage and Class.forName both funnel through. But it is handed a name, and the name is not the one in the source. daemon/src/main/jni/obfuscation.cpp rewrites Lde/robv/android/xposed/ - in the framework dex and in every module dex - to a fresh random string on every boot. So on a build with dex obfuscation on, which is every release build, a module's type reference resolves to a name the literal test never matched, and the rule was simply not enforced there. The prefixes now come from the same map the rest of the framework reads, and cover AndroidAppHelper and the XResources family as well as the package: those are part of the same legacy surface and the same signature table, and guarding only the package left the legacy resource API reachable.
The reload transaction ran the old code's onHotReloading and the new code's onHotReloaded synchronously, so a daemon thread was pinned for as long as the module cared to take - and binder has no timeout of its own. A module that never returned left its target in RELOADING for the life of the process, and every later request answered IN_PROGRESS forever. Only process death recovered it. So the request becomes oneway, the outcome comes back through IHotReloadOutcomeCallback, and the daemon waits on it for thirty seconds. The process side hops off the incoming binder thread too, since holding one of the app's own binder threads for the length of a module's onHotReloading is the same mistake one process along. PROCESS_DIED now comes from the heartbeat registry rather than from the exception type. DeadObjectException does not mean the process died: a frozen but perfectly alive target fails a transaction exactly the same way, and reporting that as 'died' is a claim the module app has no way to check. The registry knows, because it is driven by a DeathRecipient. A target that is still frozen after the thaw attempt is reported at once rather than after the timeout, and with a message saying so - the timeout would have been indistinguishable from a module that hung.
ensureModule() proves only that the caller shares the module's app id. The same module installed for two users is two module apps with two sets of preferences, so without a user check the copy in user 10 could enumerate and reload user 0's processes - which the AIDL reserves SecurityException for. System uids stay addressable from every user rather than being scoped to user 0. system_server runs once for the whole device and carries a module enabled in any user, so the strict reading would make it unreachable from every secondary user's copy of the module.
The four paths this probed - under /sys/fs/cgroup/apps/ and /sys/fs/cgroup/system/ - do not exist on SM-A145R running Android 15, which uses /uid_<uid>/pid_<pid> with nothing above it. freezeFile() returned null there, so thaw() did nothing, isFrozen() was always false, and a cached target could not be hot reloaded at all. Nothing said so: the reload just failed later, for a different-looking reason. The 0:: line of /proc/<pid>/cgroup is the process's own cgroup v2 path relative to the mount point, which is the kernel's own answer and holds whatever the layout. Only the process's own group is used; the uid-level group holds every process of the app and thawing there moves processes this has no business touching. The restore re-reads before writing. The framework's app compaction owns this state too, and if it has thawed the process meanwhile - because the user brought the app to the foreground - freezing it again from here would stop a process the system believes is running. A device with no cgroup v2 freezer at all is an ordinary answer rather than a failure; minSdk is 27 and it does not exist across that range.
…does
The AIDL is the framework's real API between three security domains, and it was
the least documented thing in the tree: no comments, LSPosed-inherited names that
mean nothing here, and at least one name that was actively wrong.
That last one is not a style complaint. registerHotReloadTarget reads as
'register a target', so the first attempt at hot reload had the injected process
pass a module name, which made the daemon look the module up - and system_server
loads its modules before that cache exists, so system_server could never become a
target. The method hands over a channel; it now says so.
ILSPApplicationService -> IFrameworkService
IDaemonService -> IVectorDaemon
ILSPSystemServerService -> ISystemServerBootstrap
IHotReloadTarget -> IProcessChannel (a channel, not a target)
IHotReloadOutcomeCallback -> IHotReloadResultReceiver
ILSPInjectedModuleService -> IModuleService
Module -> LoadedModule
PreLoadedApk -> ModuleCode (dexes, entries and policy;
not 'an apk' in any sense)
registerHotReloadTarget() -> attachProcessChannel()
requestApplicationService() -> attachProcess()
heartBeat -> processLifeToken
Every file now carries prose on what each method is for, who may call it, which
process it runs in, and the constraints that were otherwise oral tradition: that
IFrameworkService numbers its transactions implicitly so methods may only be
appended; that a null message on a hot reload outcome is reserved for a module
refusal and nothing else may claim it; that the life token exists only for
linkToDeath and that letting it be collected looks exactly like dying; and that
attachProcessChannel must not be made oneway, because the binder driver records
the sending thread only for synchronous transactions and an async call therefore
arrives with getCallingPid() == 0, which fails the (uid, pid) authentication with
no symptom beyond every later reload answering UNSUPPORTED.
IRemotePreferenceCallback and IModuleService came along even though remote
preferences are not part of 102, because LoadedModule carries the latter and
leaving them behind would have left the new package importing back into the old
one for no reason.
Deliberately not org.matrix.vector.service: that package is already the zygisk
bridge's, and it is one of the three prefixes daemon/src/main/jni/obfuscation.cpp
rewrites. Landing the AIDL there would have started obfuscating it - harmless for
the daemon-to-process interfaces, fatal for anything the manager APK also
compiles, since the manager is a separate APK the daemon's obfuscator never sees
and the binder descriptors would stop matching. org.matrix.vector.ipc is in no
prefix, so what is obfuscated does not change.
manager-service keeps org.lsposed.lspd for now, as does the Utils logger: neither
has anything to do with API 102.
…s using
Names, now that the package move put the inconsistencies next to each other:
getModulesList / getLegacyModulesList -> getModules / getLegacyModules
getRemoteFileList -> getRemoteFileNames (it returns names)
IHotReloadResultReceiver.onHotReloadOutcome
-> IHotReloadOutcomeReceiver.onOutcome
(Result and Outcome for one concept)
IRemotePreferenceCallback.onUpdate(map) -> onRemotePreferencesChanged(diff)
(it is a diff, and the old name said nothing about
what had updated)
LoadedModule.file -> LoadedModule.code (its type is
ModuleCode)
Two structural changes rather than renames.
ISystemServerBootstrap is deleted. Nothing ever held it as an interface: the only
implementer is SystemServerService, nothing calls Stub.asInterface, and the
zygisk side reaches it by transacting BRIDGE_TRANSACTION_CODE on the hijacked
service name, which onTransact answers before super ever sees it. The generated
dispatch table was unreachable and the descriptor was checked by nobody.
SystemServerService is a plain Binder now and says why, so the next person does
not add the interface back.
requestInjectedManagerBinder(out List<IBinder>) becomes openManagerApk() and
requestManagerService(). It was two unrelated results in one call, one of them
through an out-parameter, and the caller read binderList[0] without checking - so
a process that was not granted the manager service got an
IndexOutOfBoundsException swallowed by an outer catch, which is a confusing way
to spell 'no'. Splitting it lets the caller stop before opening an APK it has no
use for, and puts the side effect where it can be documented: asking for the
service is what makes this process the manager's host, which is a claim rather
than a query.
0e06d8a to
c7c5a29
Compare
Both were found by reading and then confirmed on hardware, and both had the same
shape: the framework reported success for something that had not happened.
addressableBy() tested `uid < PER_USER_RANGE`, meaning every uid in user 0 rather
than the AID_* uids the carve-out was for. A module app in a secondary user could
therefore enumerate and hot reload every user-0 process running that module -
the exact cross-user access the commit that added the check says it prevents. On
the test device a copy of the harness module installed only for user 10 listed
uids 10135 and 10136 and reloaded a user-0 app that was not installed for user 10
at all. The boundary is FIRST_APPLICATION_UID; system_server keeps its carve-out,
which is what the check existed for.
buildGeneration() returned a Generation whose entry list was empty when every
entry class failed to instantiate, and no caller treated that as failure. The
initial load reported the module loaded. A hot reload was worse: it committed the
empty generation, never called onHotReloaded, never unhooked the old hooks,
answered HOT_RELOAD_SUCCEEDED, and left the target wedged, since the committed
generation had no live entry for any later reload to hand over to. Reproduced
with a module whose constructor throws only inside the target process: the daemon
reported SUCCEEDED and loadedVersionCode 8 while the process went on running V7's
hookers, and every later reload answered UNSUPPORTED.
Also corrects documentation the same review found to be wrong, which matters
because this branch's claim is that it documents things - a confidently wrong
comment is worse than none:
- The dedup comment said the dlopen hook walks the library list without a break,
so a duplicate would call native_init twice. It does break at the first match.
The list never shrinking is the real reason to dedup, and now what it says.
- Two comments said postStartManager decides here that this process hosts the
manager. It is `pid == managerPid`, a comparison; the decision was taken when
the daemon launched the manager.
- IFrameworkService claimed every call is authenticated. isLogMuted is not, and
deliberately so.
- IProcessChannel claimed to be the only interface the daemon calls into a
process on. IRemotePreferenceCallback, in the same package, is another.
- HotReloadOutcome.message said null means a refusal. Success is null too; what
is reserved is FAILED with a null message.
- LoadedModule.versionCode and ModuleCode.targetApiVersion described values the
daemon does not always produce.
- The AIDL rename had leaked "LoadedModule" into two English diagnostics, one of
which reaches the module app as HotReloadResult.message().
- daemon, zygisk and legacy READMEs still named IDaemonService,
ILSPApplicationService and getLegacyModulesList.
setModuleScope normalises a framework scope row to user 0 whoever asked for it, because system_server is one process for the whole device and a module in a work profile hooking it is hooking the same one as everyone else. removeModuleScope did not normalise - it refused outright for any user but 0 - so a module outside user 0 could take system scope and never give it back. The bad path is IXposedService.removeScope, which returns void: the module asked, nothing happened, and it was told nothing. Through the CLI it is at least visible as "removed 0 apps". On a device: cli scope rm org.matrix.hrmodule system/11 -> removed 0 apps cli scope rm org.matrix.hrmodule system/0 -> removed 1 apps Normalising on the way out, the same way the write normalises on the way in, is all it needed. The notification-approval path had the mirror image of the same confusion: it tested whether the scope was already granted by comparing the requesting user against the stored row, which for "system" is always 0, so the test never matched. Every approval appended a duplicate and rewrote the whole scope table. setModuleScope's normalisation and CONFLICT_IGNORE collapsed it again, so nothing was corrupted - the check was simply dead for the one package it matters most for. Also corrects why addressableBy carves out the AID_* uids. The reason is not that system_server is special; it is that a module in any user may hold this scope and the row records none of them, so nothing downstream can tell which user asked, and every user holding the module is equally entitled to the one generation loaded there.
A module is one package and one APK for the whole device, so the configuration keys it by package alone: one enabled flag, one scope set, and a user id on each scope row naming which instance of the target app it points at. Nothing checked that the module itself existed for that user, so a row was expanded on the strength of the target resolving and the module went wherever it pointed. Reproduced on a device: a module installed for user 11 alone loaded into and hooked a user 0 app. The rebuild now records which users hold each module and refuses to expand a row into a user that does not. The framework is exempt, and has to be: system_server is one process for the whole device belonging to no user, its row is stored under user 0 whoever asked for it, and every user holding the module is equally entitled to it. Which users hold it has to be asked separately. MATCH_ALL_FLAGS carries MATCH_ANY_USER and MATCH_UNINSTALLED_PACKAGES, so getPackageInfoCompat answers for a user that does not hold the package - deliberately, because answering for every user is what distinguishes "no user has this any more", which deletes the configuration, from "not in this user", which must not. Nor does the uid in the answer help: the ApplicationInfo is generated for the user asked about, so a module held only by users 11 and 12 still reports 10136 for user 0. isPackageAvailable is the per-user installed state and answers correctly, and hidden counts as held so a locked private space keeps its modules. Two things the same scenario exposed. A holder now wins the ApplicationInfo, so the data directory the module is handed is one that exists - which means appId is read from a secondary user's uid, and it is stored modulo the user range because every reader compares it against someUid % PER_USER_RANGE. Otherwise a module held only by user 11 would fail its own authentication in ensureModule and never be sent its binder. And the system_server path read the module's uid off /data/user_de/0, which such a module does not have, so it started life with an app id of -1 and data paths pointing at nothing; it now looks for the directory that exists. getScope now answers with the caller's user plus the framework row. requestScope asks for the caller's user and removeScope gives back the caller's user, so returning every row showed a copy in user 11 packages in user 0 it could neither have asked for nor give back. The self-scope and legacy-self-scope expansions walked every user on the device to build uids that could never start a process. They walk the users holding the module instead.
Finishes the namespace move #757 started, doing the three things it deferred: the manager's interface, the Utils logger, and the daemon's implementation class names. /data/adb/lspd stays as it is — on-disk state wants a migration, not a rename. ILSPManagerService and its parcelables become IManagerService, IFrameworkInstallReceiver, ScopeEntry and DeviceUser under org.matrix.vector.ipc. org.lsposed.lspd.util.Utils becomes org.matrix.vector.util, with its inner Log promoted to top level. ApplicationService and ModuleService take the names of the interfaces they implement. 45 methods become 39: three were dead, and getUnloadableModules plus getModuleLoadState collapse into one call returning a map, which drops the placeholder reason a lost transaction used to report as a missing APK. The hand-written transaction ids go too — they kept a number stable rather than a meaning, and getProtocolVersion, declared first and so transaction zero in every revision, guards that at the handshake instead. The interface name is the binder descriptor, so a separately installed manager stops working until it is updated. It now says so rather than drawing blank screens. Seven unrelated defects come with it, each its own commit: a discarded insert() result, a health flag latched before the work it reports, an unbounded wait on a binder thread, and four smaller ones.
libxposed 102.0.0 adds hot reload — replacing a module's code inside a running process — plus three smaller things it needs to work:
detach(), so one entry class can stop receiving lifecycle callbacks while its siblings carry on; hook ids andreplaceHook(), so a hooker can be swapped atomically; and a rule that modules targeting 102 may no longer reachde.robv.android.xposed. It also bringsautoHotReloadinmodule.prop, andgetRunningTargets()andhotReloadModule()for module apps.The motivation is iteration cost. Changing one line in a module means force-stopping every process it is injected into, and if it hooks SystemUI or system_server that means a reboot — a minute per attempt, with whatever state you were reproducing a bug in gone with it. Reloading in place takes twenty to forty milliseconds. The rest follows: without atomic replacement a migration has to unhook and re-hook, leaving the method briefly unhooked, and the legacy API is global static state with no notion of generations, so a module that can still reach it leaves things behind that a reload cannot clean up.
For developers this is the edit-test loop, and state survives the swap through
setSavedInstanceState(). For users,autoHotReload=truemeans an update reaches apps already running the module instead of leaving them on old code until they are next killed — offered rather than imposed, sinceonHotReloading()may refuse. It is not a way to push settings; remote preferences remain that.The daemon already knows which modules it handed to which process, which is all
getRunningTargets()needs, so a target is recorded as a side effect of answering rather than registered by the process. That matters for system_server, which loads its modules before the daemon's module cache exists; the channel the daemon calls back through is attached during bootstrap, carrying no module identity, for the same reason. Reloads are asynchronous with a timeout the daemon owns, since the callee runs module code and binder has none of its own. A new generation is built in full before the old one is touched; old code is then frozen, its handles captured, andonHotReloading()asked. A refusal returns FAILED with a null message, which the API reserves for exactly that, and every other failure carries a diagnostic. The swap commits beforeonHotReloaded()runs, because the API releases the old generation after that callback returns or throws. Replacement happens natively, swapping the record inside the callback map under the lock the per-call snapshot already takes, so a call in flight is answered by the generation it started with. Cached targets are thawed first, or a frozen process looks exactly like a module refusing.Most of this is IPC, so the interfaces it touches have moved to
org.matrix.vector.ipcand now carry documentation: what each call is for, which process it runs in, and the constraints that are otherwise invisible — thatIFrameworkServicenumbers its transactions implicitly and may only be appended to, and thatattachProcessChannelcannot be oneway, since an asynchronous binder call arrives with a calling pid of zero. The names follow:IProcessChannelis the channel the daemon calls back through,ModuleCodea module's dex with the policy read from itsmodule.prop.The conformance harness sits on
api102-harnessrather than here, since it tests a device rather than a tree. It ran against v2.1 (3076) on an Android 15 device, in Debug and Release, so the legacy-API rule was checked with dex obfuscation off and on. system_server is a target and reloads in 21 ms without restarting; a frozen target thaws, reloads in 33 ms and is re-frozen; saved state crosses the swap; a superseded handle can neither replace again nor unhook its replacement; a call already inside the chain is answered by the generation it started with; a two-entry module answers UNSUPPORTED without running module code; andautoHotReloadreloads every target unprompted.Left for later, in order:
manager-service's AIDL out oforg.lsposed.lspd, together with the-keeprule in the manager's proguard config; theorg.lsposed.lspd.util.Utilslogger; the daemon-side implementation class names, which no longer match their interfaces; and/data/adb/lspd, on-disk state and so a migration rather than a rename.Description rewritten by @JingMatrix.