Stop a patched app from depending on the manager being alive - #106
Open
JingMatrix wants to merge 4 commits into
Open
Stop a patched app from depending on the manager being alive#106JingMatrix wants to merge 4 commits into
JingMatrix wants to merge 4 commits into
Conversation
A manager-backed patch asks the manager for its modules the moment the app starts, and the manager is an ordinary app: a device that reaps background processes, or a person who force-stopped it, leaves that bind unanswered. The app then started with no modules and no way to say so, after holding its own start-up open for the five seconds the wait allowed. The list is not secret and barely changes, so the host keeps its own copy of it and loads from that when the manager does not answer. What is recorded is only identity -- which module, which APK, what the manager said about it. The code is read from the module's installed APK at load time by the same step the served path uses, so a restored module and a served one are the same dex from the same file; the fallback changes where the list came from and nothing else. That step is now one shared function, because the embedded, served and restored paths differed in what they passed to it rather than in what they built. What the snapshot cannot restore is a change made while the manager was unreachable, which is the price of loading anything at all in that state. A module app updated since the snapshot has moved its APK, so the package manager is asked where it is now; that question can be refused by package visibility, and then the module is genuinely unreachable until a launch reaches the manager again. The binding is kept and re-established rather than made once. A manager reaped for memory is restarted by the system on the binding that still exists, and a force-stop breaks the binding for good, so the first is waited for and the second is rebound explicitly with a growing delay. Reconnecting re-establishes what the previous manager process was holding: the channel it drives a hot reload over, which modules this process is running, and a live service for each module the hook still holds. Asking for the module lists again is what carries all three, and the code mapped for that answer is released rather than left to a finalizer. The service each module is handed is now the host's own, forwarding to whichever manager binder is current. It has to be, twice over. The framework keeps the object it is given for the life of the process and nothing re-delivers it, so pointing it at the manager made the manager's death permanent for that app. And the framework keeps its client only if it can take a binder from the service and watch that binder die, so a service answering with the manager's binder is dropped entirely the moment the manager is away -- at exactly the moment this exists to cover. The identity is therefore local and never dies. Everything the interface offers is read-only, which is what makes caching safe: there is no local write that could diverge from the store, so a cached value is an older read and never a conflicting one. On reconnect each subscription is made again and the difference since is delivered, because the manager only pushes a change when the module app writes one. The bind budget falls from five seconds to one and a half, and expiry is no longer fatal. A miss is counted in the host's own storage and handed over on the next bind that does reach the manager, which is a channel both sides already have and costs no change to the interface they speak over.
Nothing an ordinary app does makes its process unkillable, so the aim is not to survive but to be reachable: to sit outside the bucket ordinary memory pressure empties first, to come back after the events that end the process without anyone asking, and -- where the person has granted a shell -- to be started again from outside itself. The foreground service that existed only for log collection now carries that presence, and stands down on its own when nothing on the device is patched, so a fresh install does not hold an ongoing notification about work that is not happening. Its notification says which of the two it is doing. A receiver brings it back after a reboot and after the manager is updated; those are the two broadcasts a foreground start is permitted from, and a package the system considers stopped receives neither, which is what the shell covers instead. The shell watchdog lives in the Shizuku user service, because that process runs as the shell user and is owned by the Shizuku server: a force-stop and a background reaper both act on the manager's package and leave it untouched. It checks whether the manager's process exists and, finding it gone, starts the service again -- from the shell, which also clears the stopped state a force-stop leaves behind. It matches the process name exactly: this service runs as the manager's package with a suffix, so a prefix match found itself and reported the manager alive at every moment it could have acted. It gives up after five starts that changed nothing. While it is armed the shell service is asked for as a daemon, since a service tied to the app's lifetime dies with the app it exists to restart, and a daemon from a previous life of the app can outlive it and answer with code that predates the call, so a failed arm releases it and tries once against a fresh one. It is off unless asked for: reviving a process someone has just force-stopped is the opposite of what they asked the system to do. The service the shell starts is exported, because the shell may only start a component that says it may. This grants nothing new -- any app can already cause the manager to run by binding the module service, and all this one does is make it present. Asking the shell to lift the platform's background limits reports each request as the device answered it, and separates a device that does not have a limit from one that has it and said no. Four of the five are AOSP; the auto-start op is a vendor's and does not exist elsewhere, so a phone that has never heard of it has nothing to refuse, and naming that a refusal sends the reader looking for a setting they do not have. A patched app that had to start without the manager reports the count on its next bind, and that is kept and shown, because the one party who could have noticed was the one not running. The package name is the caller's own claim and is kept only when it names an app this device has patched: identity cannot be established where the bind arrives, since that method does not run inside the transaction that triggered it and answers with this manager's own uid. It is established where it can be -- every call on the framework service resolves the calling uid while a transaction is live and serves only that uid's modules. The module service is pushed to a companion on the two occasions the manager knows one is worth reaching: the module app being installed or replaced, and its scope changing. The companion cannot ask for it; the client library is push-only and has no discovery of its own. On the manager's first open, one sheet says what LSPatch needs in order to still be there when a patched app starts, and asks for both. Neither is required, and refusing costs the ongoing notification and leaves the manager subject to doze. It is asked once: the notification permission was declared and never requested, so the service ran as a foreground service whose notification the system silently withheld, which is the worst of both. Everything about the shell now sits in the drawer the Shizuku row opens, rather than that row launching another application -- what its reader is asking about is what LSPatch does with the shell, and opening Shizuku is one icon in that drawer's title.
The strings this work added are what a person reads at the moment they are being asked to grant something, and an untranslated request is one that is easier to refuse without understanding it. All twenty-six are translated into the eighteen locales the project ships, in the per-locale files the translation pipeline writes, so Crowdin contributors refine them rather than starting from English. Five strings the finished screens no longer reference are deleted rather than translated, and the two sheets that each had their own wording for the same battery grant now share one, so the two surfaces cannot drift apart in a language neither author reads. The keys named after a card that no longer exists are renamed for what they now label. The names of the five background limits moved out of the code that runs them: they are shown to the reader when one is refused, which makes them user-facing text rather than command labels.
…ll as it assembles The loader dex and native library land in a directory shared between projects, and Gradle cannot infer their producers from a shared directory. The asset merge names them explicitly for that reason; lint reads the same directory to model each variant and named nobody, so its analysis carried an undeclared dependency on tasks that may not have run. Gradle treats that as a hard error rather than a warning, which left `gradlew build` failing on a project that assembles perfectly well -- lint runs there and nowhere else. The declaration the merge already makes is extended to the lint tasks of the same variant, from one list so the two cannot disagree about who writes what.
JingMatrix
force-pushed
the
manager-liveness
branch
from
August 19, 2026 09:36
ef245b2 to
c778c2f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A manager-backed patch asks the manager for its modules the moment the app starts, and the manager is an ordinary app. On a device that reaps background processes, or after someone force-stops it, that bind goes unanswered: the app then started with no modules, no explanation, and five seconds of its own start-up already spent waiting. Keeping an ordinary app alive is not a fight that can be won, so this work does not try to. It makes the manager's absence survivable, its return automatic, and its uptime something to ask for only after those two hold.
The patched app now keeps its own copy of the module list. After any successful query it records identity alone — which module, which APK, and what the manager said about it — and loads from that copy when the manager does not answer. The code itself is read from the module's installed APK by the same step the served path uses, so a restored module and a served one are the same dex from the same file; only the origin of the list differs. That step became a single shared function, since the embedded, served and restored paths differed in what they passed to it rather than in what they built. Because a miss is no longer fatal, the bind budget falls from five seconds to one and a half. What the copy cannot carry is a change made while the manager was unreachable, which is the price of loading anything at all in that state.
The binding is kept and re-established rather than made once. A manager reaped for memory returns on the binding that still exists; a force-stop breaks that binding for good and is rebound explicitly, with a growing delay. Reconnecting restores everything the previous manager process was holding: the channel it drives a hot reload over, which modules this process is running, and a live service for each module the hook still holds. Each module's service is now an object belonging to the host, forwarding to whichever manager binder is current. It has to be: the framework keeps what it is given for the life of the process and nothing re-delivers it, and it keeps its client only while it can take a binder from that service and watch it die. Everything the interface offers is read-only, which is what makes caching it safe — a cached value is an older read, never a conflicting one.
Presence is handled separately, and modestly. The foreground service that existed only for log collection now carries it generally, and stands down when nothing on the device is patched. A receiver restores it after a reboot and after the manager is updated, those being the two broadcasts a foreground start is permitted from. Where the person has granted Shizuku, a watchdog inside the shell user service starts the manager again when it finds it gone; that process runs as the shell user and is not what a background reaper or a force-stop acts upon, and a start issued from the shell also clears the stopped state a force-stop leaves behind. It is off unless asked for, since reviving a process someone has just force-stopped is the opposite of what they asked the system to do.
What a device does is reported rather than inferred. A patched app that had to start without the manager carries the count to the next bind that reaches it, because the one party able to notice was the one not running. Asking the shell to lift the platform's background limits reports each request as the device answered it, and distinguishes a limit the device does not have from one it has and withheld — four of the five are AOSP, while the auto-start operation belongs to particular vendors, so a phone that has never heard of it has nothing to refuse.