Skip to content

Stop a patched app from depending on the manager being alive - #106

Open
JingMatrix wants to merge 4 commits into
masterfrom
manager-liveness
Open

Stop a patched app from depending on the manager being alive#106
JingMatrix wants to merge 4 commits into
masterfrom
manager-liveness

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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.

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.
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.

1 participant