Skip to content

Work out the rendezvous name and hold it across a restart - #1034

Merged
JingMatrix merged 13 commits into
masterfrom
system-server-restart-gate
Oct 26, 2026
Merged

JingMatrix merged 13 commits into
masterfrom
system-server-restart-gate

Conversation

@JingMatrix

Copy link
Copy Markdown
Owner

The daemon and the Zygisk module must meet under the same ServiceManager name while system_server is specialized: serial at boot, when nothing has registered it yet, and serial_vector when Zygisk injected into a system_server that was already running and the real serial would answer instead. The daemon was told which by --late-inject, and nothing passes it — service.sh is the same script either way — so it claimed serial while the module polled serial_vector and gave up. Both sides now work it out from one observable, whether activity already answers. The module believes Zygisk's LATE_INJECT flag first and falls back on that probe. serial_vector is labelled default_android_service, so system_server needs one service_manager find rule.

A soft reboot exposed three more faults, all on the side of a daemon that outlives system_server. It kept offering the name it chose at startup, while the replacement, forked by a zygote that already carries our hooks, looks up serial exactly as at boot; the death recipient now restores that name and clears systemServerRequested, left latched by a module that died with its process. The death recipient itself was being collected, since it lives only as long as the BinderProxy it was registered through and that was a local, so some restarts went unnoticed. And serial_vector was never released: tryUnregisterService refuses while a service is marked as having a client, and nothing clears that mark without a client callback, so registering one is the only sequence servicemanager accepts.

Finally the daemon no longer restarts system_server on a failed handshake. Every start counts as a boot to PackageWatchdog, whose mitigation count persists and escalates; a test device reached FACTORY_RESET and was sent to recovery. The manager's soft reboot stays, and clears the boot-loop window count first under both sys.rescue_boot_count and crashrecovery.rescue_boot_count.

Tested on a Pixel 7, Android 17, KernelSU LKM with NeoZygisk: a live jailbreak and two soft reboots, injected every time. The boot path and the release build are untested.

A failed bridge handshake had one answer, restart system_server and try again,
and that is right for exactly one of its two causes. When our module is in
system_server and the handshake went wrong, the replacement is specialized by
the same hooked zygote and gets another try. When nothing injected us the hooks
are not there at all, so each restart takes the user's screen down and arrives
back here, the retry budget spends itself on a row of them, and the framework is
no more present at the end than at the start.

systemServerRequested separates the two without guessing. The module latches it
from postServerSpecialize, before system_server has started a single service,
while sendToBridge only transacts once the activity service answers a ping,
which needs a system_server well past specialization. A live activity with no
handshake behind it therefore means there is no module rather than a race with
one.
The rendezvous has two names. At boot the module looks up `serial`, which nothing
has registered yet, so a binder under that name can only be the daemon's proxy.
When Zygisk has instead injected us into a system_server that was already running
the real `serial` is there, a lookup returning it would read as a successful
rendezvous, and the module looks up `serial_vector` instead. The daemon has to
claim the same one.

It was told by `--late-inject`, and nothing passes it: `service.sh` is the same
script either way. So the daemon always claimed `serial` while the module polled
`serial_vector` for ten seconds and gave up, with no module in system_server and
no Xposed on the device afterwards.

The daemon can see it for itself. `activity` belongs to system_server and is
registered long after it is specialized, so nothing answers at boot and a live
binder means the framework came up without us. The module still believes Zygisk's
LATE_INJECT flag first and falls back on the same test, which covers
implementations that do not set it. Read with `checkService`, since `getService`
blocks for seconds on an absent name before Android R and absent is the boot path.

A name of our own appears in no `service_contexts`, so servicemanager labels it
`default_android_service` and refused system_server the lookup; the rule for it
joins the module's sepolicy. Nothing will ever replace that name either, the way
system_server replaces `serial` with the real service, so once the module has
been served the daemon asks for it back. servicemanager refuses while the service
is still marked as having a client on the way, which the handshake just put there
and which it clears from an interval of its own, so the asking is spaced out over
a few of those intervals and then given up on. A name left registered is only
what the last release did anyway.
… one did

A soft reboot leaves the daemon running and takes system_server with it. The
replacement is forked by a zygote that already carries our hooks, so Zygisk sets
no LATE_INJECT and the `activity` of the system_server that just died answers
nothing: the module looks for `serial`, exactly as it does at boot. The daemon
went on offering `serial_vector`, the name it chose for the system_server it was
started into, and the two never met.

So the death recipient puts the daemon back the way a boot would find it. It
claims `serial`, which the dying system_server has just freed, and stops holding
the invented name, which nothing will look up again. It also stops expecting to
replay a boot: the replacement broadcasts one of its own.

`systemServerRequested` is reset with them. It is read to tell a handshake that
went wrong, which a restart can fix, from an injection that never happened,
which a restart only repeats; latched from the previous system_server it said
yes for a module that died with it, and the daemon spent its whole retry budget
restarting a framework no module was reaching — three times across the screen
before it gave up. The question belongs to the system_server in front of it.

Re-claiming now goes through `registerProxyService`, so the watcher that adopts
the real service follows the name across the restart instead of being left on
the old one. It will not register the same name twice.
A death recipient lives exactly as long as the BinderProxy it was registered
through. `bridgeService` is a local, and once sendToBridge returns nothing else
refers to that proxy: when ART collects it, the kernel clears the registration
with it. Nothing is logged, and the daemon has simply stopped listening.

After which a soft reboot passes unnoticed. The daemon never learns that
system_server died, never re-claims the rendezvous, and the replacement polls
for ten seconds and gives up. The binder driver shows what is left of the
daemon's side afterwards: every ref carries `d 0000000000000000`, no death
registered anywhere. It depends on when a collection happens to fall, which is
why the first restart after a successful handshake is usually noticed and a
later one is not.

The proxy now lives in a field for as long as the daemon cares about it, which
is the whole point of a daemon that outlives the system_server it was started
with.

The restart budget is kept there too, instead of being counted down through the
recursion. Every death spent one, including the ones that went on to inject
perfectly well, so a daemon left running across a few soft reboots arrived at
zero and could no longer restart a system_server that genuinely needed it. It
is spent where a restart is asked for and refilled by a handshake that works.
Running out now says so rather than returning in silence.
servicemanager will not unregister a service on request. `tryUnregisterService`
refuses outright while the service is marked as having a client on the way,
which handing the binder out is what sets, and the only statement that clears
that mark sits behind an early return for any name with no client callback:

    auto serviceIt = mNameToService.find(serviceName);
    if (serviceIt == mNameToService.end() || mNameToClientCallback.count(serviceName) < 1) {
        return true; // return we do have clients a.k.a. DON'T DO ANYTHING
    }

So for a service registered the ordinary way the mark is permanent from the
first lookup and the refusal is forever. The device said as much: ten attempts
over a minute, every one `Can't unregister, pending client.`

Registering a client callback is what puts the name on servicemanager's own
interval, which clears the mark and then reports when the last client is gone.
Unregistering from inside that report is how a lazy service shuts itself down,
and it is the only sequence that is ever accepted. The retries are gone with it;
they could not have worked.
Every system_server start is a boot to PackageWatchdog. Enough of them close
together and it raises a mitigation count that is kept on disk and never
lowered, and far enough along that ladder the mitigation stops being a reboot:
the device is sent to recovery to be asked whether to erase itself. A device
used to test this branch got there, twice in thirteen minutes:

    RescueParty: Attempting rescue level FACTORY_RESET
    uncrypt: received command: [--prompt_and_wipe_data --reason=RescueParty,null
    init: Received sys.powerctl='reboot,recovery' from pid: 31864 (system_server)

Its `/data/system/package-watchdog.xml` reads `mitigation-count="10"` on the
rescue-party observer, which is how it had come to pick the last rung rather
than a harmless settings reset. A soft reboot does not clear the window count
either, since init and its properties live through one, so restarts accumulate
inside a single real boot.

So the automatic restart goes. Its only trigger was a failed handshake, and by
the time the daemon transacts, `activity` has answered a ping and the module's
fate was settled long before: its hooks go in during specialization, before
system_server registers a service. Another boot wins no race that was lost
there. Every firing of it in these logs came from a stale `systemServerRequested`
latched by a system_server that had already died, and not one of them ever
recovered anything — three inside eighteen seconds on one occasion, which is
precisely the shape the detector is built to catch.

What remains is the soft reboot the manager offers, which someone asked for.
That one says so: it clears the window count immediately before restarting,
cancelling the single tick it is about to add and nothing else. A real loop
around it counts again from there and still trips.
`--propagation slave` is util-linux's spelling. The busybox a root manager puts
ahead of /system/bin accepts it, Android's own unshare does not, and there the
result is not a weaker namespace but no daemon at all:

    $ adb shell unshare --propagation slave -m /system/bin/true
    unshare: Unknown option 'propagation' (see "unshare --help")
    exit=1

    usage: unshare [-imnpuUr] COMMAND...

Both create the namespace with -m, and once inside it a plain
`mount -o rslave none /` says the rest, so service.sh no longer depends on
which binary the name resolves to. The root entry in the new namespace then
carries a master peer group, which is what a slave mount is:

    23611 23610 254:6 / / ro,noatime master:1 - ext4 /dev/block/dm-6 ro,seclabel

Should that mark ever fail, the daemon still starts and stderr says its mounts
are not contained.
`binderDied` arrives on a binder thread, and this branch gave it three fields
to write that the daemon's main thread reads: the proxy it linked through, the
latch that decides the rendezvous name, and the record of whether a module ever
attached. Nothing published them, so the main thread is entitled to go on
reading the values it cached — a restart that re-claims `serial` while
`sendToBridge` still believes it is late-injected, or a bridge failure logged
as the wrong one of its two cases. `@Volatile` on each.

The comments around them say what holds now rather than what used to, and the
claim that a written field could be optimised away goes with them: the field is
read on the path that matters.
NeoZygisk#174 is what produces the case the second name exists for: a late
load attaches to a system_server that is already serving and drives
`postServerSpecialize` from a thread of its own, announcing itself with the
`LATE_INJECT` runtime flag. The module reads the flag first and keeps the
`activity` probe as the fallback; the daemon has only the probe, since the same
service.sh starts it either way.

That PR also decides which shell runs a module's boot scripts. A root manager
execs `busybox sh`, whose unshare takes `--propagation slave`; where no
framework fans the stages out, the tracer execs `/system/bin/sh`, whose toybox
unshare does not. Only the second can be counted on, so service.sh says which
of the two it is written for.
A restart was never shown to recover an injection, and that is the reason not
to have one: the module's hooks go in during specialization, long before the
bridge that would be restarted over, so a handshake failing that late is a bug
in the injection or the bootstrap, and a restart only hides it until someone
fixes it. The PackageWatchdog ladder is what keeping it would cost, not the
argument against it, and the comment now reads in that order.

service.sh is written for toybox because a manual bootstrap has nothing in
front of it but Android's own utilities. Which shell a root manager happens to
use is beside the point.
A missing `checkService` does not leave the probe coping, it leaves it unable
to run: `IsSystemServerRunning` returns false and `LATE_INJECT` decides alone.
And the flag is NeoZygisk's, set when it drives the specialization of a
system_server that is already serving, so the comment names it rather than
Zygisk in general.
Four claims this branch added that its code does not support.

The daemon's probe said it runs "beside a zygote that has yet to fork one".
It has no such guarantee: a root manager starts service.sh from the service
stage, after init has started zygote. What the probe actually rests on is that
`activity` is registered long after the specialization the module waits in, so
the comment says that instead.

Twice, and once more in the stub, `getService` was said to block for seconds
on an absent name before Android R. The difference that matters is the one
servicemanager documents in its own interface: `checkService` reports what it
holds, `getService` may try to start the service behind the name.

And `Main.forkCommon`'s `isLateInject` was described as replaying a missed
boot, which is the daemon's job, not this one's. On this side it runs
`initSystemServer` and loads modules by hand, because `ActivityThread.attach`
is already behind the process it was injected into.
…have

AOSP says otherwise, in three places. Android 11's `ServiceManagerProxy`:

    public IBinder getService(String name) throws RemoteException {
        // Same as checkService (old versions of servicemanager had both methods).
        return mServiceManager.checkService(name);
    }

so from Java `getService` never reaches servicemanager's own `getService`,
which is the only one that starts a service behind an unheld name
(`tryGetService(name, true)` against `checkService`'s `false`). And
`ServiceManager.checkService` consults the same `sCache` `getService` does,
so there is no cache to avoid either. On Android 10 and earlier both sides
went to the legacy servicemanager, which answered GET_SERVICE and
CHECK_SERVICE from one `do_find_service`.

There is no blocking to dodge and no lazy start to avoid: `checkService` is
simply the method that names the question, and the comments now claim no more
than that.

The one Android R line that does hold is the gate on taking a name back.
`IServiceManager.aidl` arrives in Android 11 with `registerForNotifications`,
`registerClientCallback` and `tryUnregisterService`; Android 10's
`BpServiceManager` implements only getService, checkService, addService and
listServices.
@JingMatrix
JingMatrix merged commit eaffc81 into master Oct 26, 2026
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