From 20d7a70b315066ecc3bd3ba4c97888655856e026 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 24 Oct 2026 23:55:19 +0200 Subject: [PATCH 01/13] Restart system_server only when a module is there to retry 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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index b4fad62af1..1f7a1cd9f1 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -193,7 +193,21 @@ object VectorDaemon { Log.i(TAG, "Successfully injected Vector IPC binder for applications.") } else { Log.e(TAG, "Failed to inject VectorService into system_server") - if (restartRetry > 0) restartSystemServer() + // Restarting is a recovery only when our module is in system_server and the + // handshake went wrong, because the replacement is specialized by the same hooked + // zygote and gets another try. When no module reached system_server in the first + // place the hooks are not there, so every restart takes the user's screen down and + // arrives back here; the retry budget then spends itself on a row of them. + // + // `systemServerRequested` separates the two: the module latches it from inside + // `postServerSpecialize`, which runs before system_server starts a single service, + // while the loop above only transacts once `activity` answers a ping. So a live + // `activity` with no handshake behind it means no module, not a race. + when { + !SystemServerService.systemServerRequested -> + Log.e(TAG, "No Vector module reached system_server; leaving it running") + restartRetry > 0 -> restartSystemServer() + } } } .onFailure { Log.e(TAG, "Error during injecting DaemonService", it) } From bd6c54a3f8f093361adef350204dbffe851b159b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 25 Oct 2026 02:32:20 +0200 Subject: [PATCH 02/13] Decide the late rendezvous instead of waiting to be told 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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 40 +++++++++++--- .../org/matrix/vector/daemon/VectorService.kt | 4 +- .../vector/daemon/ipc/SystemServerService.kt | 54 +++++++++++++++++++ .../main/java/android/os/IServiceManager.java | 2 + .../main/java/android/os/ServiceManager.java | 11 ++++ zygisk/README.md | 14 ++++- zygisk/module/sepolicy.rule | 3 ++ zygisk/src/main/cpp/include/ipc_bridge.h | 8 +++ zygisk/src/main/cpp/ipc_bridge.cpp | 20 +++++++ zygisk/src/main/cpp/module.cpp | 11 ++-- .../kotlin/org/matrix/vector/core/Main.kt | 3 +- 11 files changed, 157 insertions(+), 13 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 1f7a1cd9f1..b964d027d6 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -44,8 +44,27 @@ object VectorDaemon { val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) val bridgeServiceName = "activity" - var isLateInject = false - var proxyServiceName = "serial" + /** + * Whether a `system_server` was already serving the moment this daemon started, which is how + * Zygisk injecting into a booted system rather than into a fresh fork shows up on our side. The + * boot it missed then has to be replayed, and it has to be met under another name. + * + * Latched once during [main] and never revisited: by the time anything reads it a system_server + * is running either way, and what matters is which of the two we were started into. + */ + var startedAfterSystemServer = false + + /** + * The name the module looks the daemon up under while system_server is specialized. `serial` is + * free at boot, because system_server registers it only much later, so a binder found there can + * only be ours. On a booted system the real service holds it, and a lookup returning that is + * indistinguishable from a successful rendezvous, so a name of our own is used instead. + * + * Derived rather than stored: the two were separate fields until a restart moved one and left + * the other behind, and the daemon then waited under a name nothing would ever ask for. + */ + val proxyServiceName: String + get() = if (startedAfterSystemServer) "serial_vector" else "serial" @JvmStatic fun main(args: Array) { @@ -55,13 +74,19 @@ object VectorDaemon { for (arg in args) { if (arg.startsWith("--system-server-max-retry=")) { systemServerMaxRetry = arg.substringAfter('=').toIntOrNull() ?: 1 - } else if (arg == "--late-inject") { - isLateInject = true - proxyServiceName = "serial_vector" } } - Log.i(TAG, "Vector daemon started: lateInject=$isLateInject, proxy=$proxyServiceName") + // `activity` belongs to system_server, and at boot we are started beside a zygote that has yet + // to fork one. checkService, not getService: the latter blocks for seconds on an absent name + // before Android R, and absent is the boot path, where the module is already polling for the + // proxy we have not registered yet. + startedAfterSystemServer = ServiceManager.checkService(bridgeServiceName) != null + + Log.i( + TAG, + "Vector daemon started: afterSystemServer=$startedAfterSystemServer, " + + "proxy=$proxyServiceName") // The hash is here rather than in the version the manager prints: Home should stay readable, // but every saved bug report should say exactly which commit produced the daemon that wrote it. Log.i( @@ -191,6 +216,9 @@ object VectorDaemon { if (success) { Log.i(TAG, "Successfully injected Vector IPC binder for applications.") + // Only the name we invented for a late injection: `serial` is a real service name, + // and system_server registering it is what takes our proxy out of servicemanager. + if (startedAfterSystemServer) SystemServerService.unregisterProxyService() } else { Log.e(TAG, "Failed to inject VectorService into system_server") // Restarting is a recovery only when our module is in system_server and the diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 7376ac66f7..685a3664a0 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -59,8 +59,8 @@ object VectorService : IVectorDaemon.Stub() { // Initialize OS Observers using Coroutines for the dispatch blocks registerReceivers() - if (VectorDaemon.isLateInject) { - Log.i(TAG, "Late injection detected. Forcing boot completed event.") + if (VectorDaemon.startedAfterSystemServer) { + Log.i(TAG, "Started after system_server. Forcing boot completed event.") dispatchBootCompleted() } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index 4aa4f9fb55..789808ba19 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -7,12 +7,26 @@ import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager import android.util.Log +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" +/** + * How long to leave between attempts at dropping the proxy, and how many to make. + * + * servicemanager marks a service the moment it hands the binder out and refuses to unregister it + * until that mark is cleared, which it does from a five second interval of its own. Asking once, + * straight after the handshake, therefore always lands inside the mark; these space the asking out + * across a few of those intervals instead. + */ +private const val UNREGISTER_RETRY_MILLIS = 6_000L +private const val UNREGISTER_ATTEMPTS = 5 + /** * The Android R half of [SystemServerService.registerProxyService], in a class of its own on * purpose. @@ -43,6 +57,10 @@ private object ServiceRegistrationWatcher { runCatching { getSystemServiceManager().registerForNotifications(serviceName, callback) } .onFailure { Log.e(TAG, "Failed to register IServiceCallback", it) } } + + fun unregister(serviceName: String, binder: IBinder) { + getSystemServiceManager().tryUnregisterService(serviceName, binder) + } } /** @@ -58,6 +76,7 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null + private var unregisterJob: Job? = null var systemServerRequested = false @@ -80,6 +99,37 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } + /** + * Drops our proxy from servicemanager, once the module it was there for has been served. + * + * Best effort, and asked more than once: `tryUnregisterService` arrived in Android R, and + * servicemanager refuses while the service is still marked as having a client on the way, which + * the handshake we have just finished is exactly what put there. See [UNREGISTER_RETRY_MILLIS]. + * + * Worth the trouble because the name invented for a late injection is ours alone, so unlike + * `serial` nothing will ever come along and replace it. A refusal costs nothing: the name stays + * registered, as it did before any of this. + */ + fun unregisterProxyService() { + val name = proxyServiceName ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + unregisterJob?.cancel() + unregisterJob = + VectorDaemon.scope.launch { + repeat(UNREGISTER_ATTEMPTS) { attempt -> + if (attempt > 0) delay(UNREGISTER_RETRY_MILLIS) + val failure = + runCatching { ServiceRegistrationWatcher.unregister(name, SystemServerService) } + .exceptionOrNull() + if (failure == null) { + Log.d(TAG, "Dropped the proxy service `$name`") + return@launch + } + Log.d(TAG, "Proxy service `$name` stays registered: $failure") + } + } + } + /** * Adopts the real system service once servicemanager reports its registration, so that * [onTransact] starts forwarding to it. Only ever called from [ServiceRegistrationWatcher]. @@ -148,6 +198,10 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { } override fun binderDied() { + // A pending attempt belongs to the handshake of the system_server that has just died. The + // caller re-claims the name for its replacement right after this, and dropping that + // registration would leave the replacement with nowhere to meet us. + unregisterJob?.cancel() originService?.unlinkToDeath(this, 0) originService = null } diff --git a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java index 1e1193ee96..099d12623b 100644 --- a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java @@ -6,6 +6,8 @@ public interface IServiceManager extends IInterface { public void registerForNotifications(String name, IServiceCallback cb); + public void tryUnregisterService(String name, IBinder service); + abstract class Stub extends Binder implements IServiceManager { public static IServiceManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); diff --git a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java index bf1a9f21a6..443d625cc1 100644 --- a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java @@ -15,6 +15,17 @@ public static IBinder getService(String name) { throw new RuntimeException("STUB"); } + /** + * Returns a reference to a service with the given name, without blocking when it is absent. + * + * @param name the name of the service to get + * @return a reference to the service, or null if the service doesn't exist + */ + @Nullable + public static IBinder checkService(String name) { + throw new RuntimeException("STUB"); + } + /** * Place a new @a service called @a name into the service * manager. diff --git a/zygisk/README.md b/zygisk/README.md index 0da0126d67..f29a45c5d3 100644 --- a/zygisk/README.md +++ b/zygisk/README.md @@ -19,11 +19,23 @@ When a transaction occurs, the hook inspects the transaction code. If it matches ### Phase 1: System Server Initialization The `system_server` acts as the primary proxy router for the framework. During the `postServerSpecialize` callback, the following sequence occurs: -1. The native module queries `ServiceManager` for the `serial` service (or `serial_vector` for late-inject scenarios). This service acts as a temporary rendezvous point. +1. The native module queries `ServiceManager` for the rendezvous service, `serial` or `serial_vector` depending on whether a `system_server` was already running (see _Choosing the rendezvous name_ below). 2. The module sends a `_VEC` transaction to retrieve a temporary binder, which it uses to fetch the framework DEX file descriptor and the obfuscation map. 3. The module installs the JNI Binder Trap (`HookBridge`) and bootstraps the Kotlin layer via `Main.forkCommon`. 4. Concurrently, the root daemon initiates a Binder transaction directly to the `system_server`. The JNI trap intercepts this, and BridgeService processes the `SEND_BINDER` action, storing the daemon's primary `IVectorDaemon` binder, sending back `system_server` context and linking a `DeathRecipient`. +#### Choosing the rendezvous name +`serial` is a real `system_server` service, registered long after specialization. At boot it is therefore free, and a binder found under it can only be the daemon's proxy. When Zygisk instead attaches to a `system_server` that booted without Vector, the real service is already registered, and a lookup returning it would be indistinguishable from a successful rendezvous; `serial_vector`, a name no platform service uses, is claimed for that case. + +Neither side is told which case it is in. Both ask the same question independently: does `activity` already answer? Nothing has registered it during a boot, and a running `system_server` always has. The module also honours Zygisk's own `LATE_INJECT` runtime flag where the implementation sets it, and falls back to the probe where it does not. + +`serial_vector` is the daemon's alone, so unlike `serial` nothing will ever come along and replace it. Once the module it existed for has let go of the binder, the daemon unregisters it through `registerClientCallback`, which is the only sequence servicemanager accepts — `tryUnregisterService` on its own is refused for as long as the service is marked as having a client, and for a name without a client callback that mark is never cleared. + +#### When `system_server` restarts +A soft reboot, a crash or `ctl.restart zygote` leaves the daemon running and replaces the process it was talking to. The `DeathRecipient` the daemon linked on the `activity` binder is what notices, which is why that binder is retained for as long as the link is meant to last: the notification lives exactly as long as the `BinderProxy` it was registered on. + +The replacement is forked by a zygote that already carries Vector's hooks, so it arrives exactly as one does at boot and looks the daemon up under `serial`, which its predecessor freed by dying. The daemon re-claims `serial` on the death notification and stops treating itself as late-injected: the replacement broadcasts a boot of its own, so there is nothing to replay. + ### Phase 2: User Application Rendezvous Standard applications initialize their IPC connection by routing requests through the `system_server`. 1. In `postAppSpecialize`, the application queries `ServiceManager` for the `activity` service (which resides in `system_server`). diff --git a/zygisk/module/sepolicy.rule b/zygisk/module/sepolicy.rule index e27ad3a052..8c96645da4 100644 --- a/zygisk/module/sepolicy.rule +++ b/zygisk/module/sepolicy.rule @@ -13,3 +13,6 @@ allow zygote dex2oat unix_stream_socket * type xposed_data file_type typeattribute xposed_data mlstrustedobject allow * xposed_data {file dir} * + +# `serial_vector` is in no service_contexts, so it is labelled default_android_service +allow system_server default_android_service service_manager find diff --git a/zygisk/src/main/cpp/include/ipc_bridge.h b/zygisk/src/main/cpp/include/ipc_bridge.h index 7488406710..d3f6cec0bc 100644 --- a/zygisk/src/main/cpp/include/ipc_bridge.h +++ b/zygisk/src/main/cpp/include/ipc_bridge.h @@ -46,6 +46,13 @@ class IPCBridge { */ lsplant::ScopedLocalRef RequestAppBinder(JNIEnv *env, jstring nice_name); + /** + * @brief Tells whether a system_server is already serving binder calls. + * @param env JNI environment pointer. + * @return True if the rendezvous service of running applications answers. + */ + bool IsSystemServerRunning(JNIEnv *env); + /** * @brief Requests the system_server's dedicated Binder from the host service. * @param env JNI environment pointer. @@ -127,6 +134,7 @@ class IPCBridge { // android.os.ServiceManager jclass service_manager_class_ = nullptr; jmethodID get_service_method_ = nullptr; + jmethodID check_service_method_ = nullptr; // android.os.IBinder jmethodID transact_method_ = nullptr; diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index bc19adfd8a..c0ffb2a50f 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -157,6 +157,13 @@ void IPCBridge::Initialize(JNIEnv *env) { LOGE("IPCBridge: ServiceManager.getService method not found!"); return; } + // IsSystemServerRunning needs it: getService blocks for seconds on an absent name before + // Android R, which is precisely the case it asks about. Optional, the probe copes without it. + check_service_method_ = lsplant::JNI_GetStaticMethodID( + env, service_manager_class_, "checkService", "(Ljava/lang/String;)Landroid/os/IBinder;"); + if (!check_service_method_) { + LOGW("IPCBridge: ServiceManager.checkService method not found."); + } // IBinder auto ibinder_class = lsplant::ScopedLocalRef(env, env->FindClass("android/os/IBinder")); @@ -297,6 +304,19 @@ lsplant::ScopedLocalRef IPCBridge::RequestAppBinder(JNIEnv *env, jstrin return result_binder; } +bool IPCBridge::IsSystemServerRunning(JNIEnv *env) { + if (!initialized_ || !check_service_method_) { + return false; + } + + // kBridgeServiceName is published by system_server well after it is specialized, so nothing + // answers at boot and the live service answers once it has booted without us. + auto service_name = lsplant::ScopedLocalRef(env, env->NewStringUTF(kBridgeServiceName.data())); + auto binder = lsplant::JNI_CallStaticObjectMethod(env, service_manager_class_, + check_service_method_, service_name.get()); + return binder.get() != nullptr; +} + lsplant::ScopedLocalRef IPCBridge::RequestSystemServerBinder( JNIEnv *env, std::string bridgeServiceName) { if (!initialized_) { diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 84ff88312d..7bae492523 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -415,9 +415,14 @@ void VectorModule::postServerSpecialize(const zygisk::ServerSpecializeArgs *args // --- Framework Injection for System Server --- auto &ipc_bridge = IPCBridge::GetInstance(); - std::string bridgeServiceName = "serial"; - bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0; - if (is_late_inject) bridgeServiceName = "serial_vector"; + // Zygisk says so itself when it attached to a running system_server. Implementations that do + // not set the flag are caught by the fallback, an already serving system_server being the one + // thing a late injection always leaves behind, and the same thing the daemon tests. + bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0 || + ipc_bridge.IsSystemServerRunning(env_); + // The real `serial` service is registered by then, and a lookup returning it would be + // indistinguishable from a successful rendezvous. + std::string bridgeServiceName = is_late_inject ? "serial_vector" : "serial"; auto system_binder = ipc_bridge.RequestSystemServerBinder(env_, bridgeServiceName); if (!system_binder) { LOGE("Failed to get system server IPC binder. Aborting injection."); diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index f41c2a6662..782d5cb825 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -19,7 +19,8 @@ object Main { * Shared initialization logic for both System Server and Application processes. * * @param isSystem True if this is the system_server process. - * @param isLateInject True if Zygisk APIs are not invoked via hooks + * @param isLateInject True if Zygisk injected us into a system_server that was already + * running, so the boot it missed has to be replayed rather than awaited. * @param niceName The process name (e.g., package name or "system"). * @param appDir The application's data directory. * @param binder The Binder token associated with the application service. From a4261ed88bc42aeff141ad92e69b6d9d4c616f25 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 25 Oct 2026 02:19:41 +0100 Subject: [PATCH 03/13] Meet the replacement system_server where it looks, not where the last one did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kotlin/org/matrix/vector/daemon/VectorDaemon.kt | 12 +++++++++++- .../matrix/vector/daemon/ipc/SystemServerService.kt | 7 +++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index b964d027d6..7916527a22 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -184,9 +184,19 @@ object VectorDaemon { bridgeService.unlinkToDeath(this, 0) clearSystemCaches() SystemServerService.binderDied() // Cleanup old references + // Nothing will look the invented name up again, so stop holding it. + if (startedAfterSystemServer) SystemServerService.unregisterProxyService() + // Whatever this daemon was started into, the replacement is forked by a zygote + // that already carries our hooks, so it arrives the way one does at boot: it + // looks us up under `serial`, which its predecessor has just freed by dying, + // and it broadcasts a boot of its own that there is no need to replay. + startedAfterSystemServer = false + // And the question of whether a module reached system_server is about the one + // ahead of us, not the one that died with ours inside it. + SystemServerService.systemServerRequested = false // Re-claim the service name immediately to ensure that when system_server // restarts, our proxy is already there for the Zygisk module to find. - ServiceManager.addService(proxyServiceName, SystemServerService) + SystemServerService.registerProxyService(proxyServiceName) ManagerService.guard = null // Remove dead guard Handler(Looper.getMainLooper()).post { sendToBridge(binder, true, restartRetry - 1) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index 789808ba19..7f16022901 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -41,7 +41,13 @@ private const val UNREGISTER_ATTEMPTS = 5 */ private object ServiceRegistrationWatcher { + private var watchedName: String? = null + fun watch(serviceName: String) { + // servicemanager keeps every callback it is handed, and a system_server restart brings the + // daemon back through here; without this a long-lived daemon stacks up duplicates that all + // report the same registration. + if (watchedName == serviceName) return val callback = object : IServiceCallback.Stub() { // The IServiceCallback will tell us when the real Android service is ready, @@ -55,6 +61,7 @@ private object ServiceRegistrationWatcher { override fun asBinder(): IBinder = this } runCatching { getSystemServiceManager().registerForNotifications(serviceName, callback) } + .onSuccess { watchedName = serviceName } .onFailure { Log.e(TAG, "Failed to register IServiceCallback", it) } } From 7719a5e2a2191c1dfe87b248dd4ee9b5be9e4049 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 25 Oct 2026 02:28:38 +0100 Subject: [PATCH 04/13] Keep the handle that keeps the system_server death notification alive 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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 7916527a22..67156cb628 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -66,16 +66,33 @@ object VectorDaemon { val proxyServiceName: String get() = if (startedAfterSystemServer) "serial_vector" else "serial" + /** + * The `activity` binder this daemon last linked its death recipient through. + * + * A death notification lives exactly as long as the `BinderProxy` it was registered on. Once + * [sendToBridge] returns nothing else refers to that proxy, and when ART collects it the kernel + * registration goes with it, silently: the daemon then sleeps through the next system_server + * restart, never re-claims the rendezvous, and the module that comes back finds nothing to meet. + * Holding the proxy here is what makes a daemon outlive the system_server it was started with, + * and dropping the link goes through this field rather than through the local it was taken + * from, so that a field only ever written cannot be optimised away along with the guarantee. + */ + private var bridgeBinder: IBinder? = null + + /** How many times [sendToBridge] may answer a failed handshake by restarting system_server. */ + private var systemServerMaxRetry = 1 + private var systemServerRestartBudget = 0 + @JvmStatic fun main(args: Array) { if (!FileSystem.tryLock()) kotlin.system.exitProcess(0) - var systemServerMaxRetry = 1 for (arg in args) { if (arg.startsWith("--system-server-max-retry=")) { systemServerMaxRetry = arg.substringAfter('=').toIntOrNull() ?: 1 } } + systemServerRestartBudget = systemServerMaxRetry // `activity` belongs to system_server, and at boot we are started beside a zygote that has yet // to fork one. checkService, not getService: the latter blocks for seconds on an absent name @@ -135,7 +152,7 @@ object VectorDaemon { val isVerboseLog = ManagerService.isVerboseLogEnabled() // Setup IPC channel for applications by injecting DaemonService binder - sendToBridge(VectorService.asBinder(), false, systemServerMaxRetry) + sendToBridge(VectorService.asBinder(), false) if (!isVerboseLog) { LogcatMonitor.stopVerbose() @@ -154,11 +171,7 @@ object VectorDaemon { // The bridge is setup in `system_server` via Zygisk API @Suppress("DEPRECATION") - private fun sendToBridge( - binder: IBinder, - isRestart: Boolean, - restartRetry: Int, - ) { + private fun sendToBridge(binder: IBinder, isRestart: Boolean) { check(Looper.myLooper() == Looper.getMainLooper()) { "sendToBridge MUST run on the main thread!" } @@ -181,7 +194,8 @@ object VectorDaemon { object : IBinder.DeathRecipient { override fun binderDied() { Log.w(TAG, "System Server died! Clearing caches and re-injecting...") - bridgeService.unlinkToDeath(this, 0) + bridgeBinder?.unlinkToDeath(this, 0) + bridgeBinder = null clearSystemCaches() SystemServerService.binderDied() // Cleanup old references // Nothing will look the invented name up again, so stop holding it. @@ -198,12 +212,11 @@ object VectorDaemon { // restarts, our proxy is already there for the Zygisk module to find. SystemServerService.registerProxyService(proxyServiceName) ManagerService.guard = null // Remove dead guard - Handler(Looper.getMainLooper()).post { - sendToBridge(binder, true, restartRetry - 1) - } + Handler(Looper.getMainLooper()).post { sendToBridge(binder, true) } } } bridgeService.linkToDeath(deathRecipient, 0) + bridgeBinder = bridgeService // see [bridgeBinder]: the link is only as alive as this // Try sending the Binder payload (up to 3 times) var success = false @@ -226,6 +239,9 @@ object VectorDaemon { if (success) { Log.i(TAG, "Successfully injected Vector IPC binder for applications.") + // The budget is for a run of failures, not for a long life: a daemon that keeps + // injecting across soft reboot after soft reboot should not run out of them. + systemServerRestartBudget = systemServerMaxRetry // Only the name we invented for a late injection: `serial` is a real service name, // and system_server registering it is what takes our proxy out of servicemanager. if (startedAfterSystemServer) SystemServerService.unregisterProxyService() @@ -244,7 +260,11 @@ object VectorDaemon { when { !SystemServerService.systemServerRequested -> Log.e(TAG, "No Vector module reached system_server; leaving it running") - restartRetry > 0 -> restartSystemServer() + systemServerRestartBudget > 0 -> { + systemServerRestartBudget-- + restartSystemServer() + } + else -> Log.e(TAG, "Out of system_server restarts; leaving it running") } } } From 9ab397052e3a0d94d8ae35911fea1791c4b1b057 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 25 Oct 2026 02:42:14 +0100 Subject: [PATCH 05/13] Ask servicemanager to drop the proxy the only way it accepts 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. --- .../vector/daemon/ipc/SystemServerService.kt | 86 +++++++++---------- .../main/java/android/os/IClientCallback.java | 14 +++ .../main/java/android/os/IServiceManager.java | 2 + 3 files changed, 56 insertions(+), 46 deletions(-) create mode 100644 hiddenapi/stubs/src/main/java/android/os/IClientCallback.java diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index 7f16022901..999b936ad3 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -3,30 +3,17 @@ package org.matrix.vector.daemon.ipc import android.os.Binder import android.os.Build import android.os.IBinder +import android.os.IClientCallback import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager import android.util.Log -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" -/** - * How long to leave between attempts at dropping the proxy, and how many to make. - * - * servicemanager marks a service the moment it hands the binder out and refuses to unregister it - * until that mark is cleared, which it does from a five second interval of its own. Asking once, - * straight after the handshake, therefore always lands inside the mark; these space the asking out - * across a few of those intervals instead. - */ -private const val UNREGISTER_RETRY_MILLIS = 6_000L -private const val UNREGISTER_ATTEMPTS = 5 - /** * The Android R half of [SystemServerService.registerProxyService], in a class of its own on * purpose. @@ -41,7 +28,9 @@ private const val UNREGISTER_ATTEMPTS = 5 */ private object ServiceRegistrationWatcher { - private var watchedName: String? = null + // Written from the main thread at startup and from a binder thread when system_server dies. + @Volatile private var watchedName: String? = null + @Volatile private var unusedName: String? = null fun watch(serviceName: String) { // servicemanager keeps every callback it is handed, and a system_server restart brings the @@ -65,8 +54,35 @@ private object ServiceRegistrationWatcher { .onFailure { Log.e(TAG, "Failed to register IServiceCallback", it) } } - fun unregister(serviceName: String, binder: IBinder) { - getSystemServiceManager().tryUnregisterService(serviceName, binder) + /** + * Asks servicemanager to drop [serviceName] once nothing but itself is holding [binder]. + * + * It will not drop a service on request alone. `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 code that clears that mark returns early for any name without a client + * callback — so for a service registered the ordinary way the mark is permanent and the + * refusal is forever. Asking repeatedly changes nothing. + * + * Registering a callback is what puts the name on servicemanager's interval, which then clears + * the mark and, once the last client is gone, says so. Unregistering from inside that is how a + * lazy service shuts itself down, and it is the only sequence servicemanager accepts. + */ + fun unregisterWhenUnused(serviceName: String, binder: IBinder) { + if (unusedName == serviceName) return + val callback = + object : IClientCallback.Stub() { + override fun onClients(registered: IBinder, hasClients: Boolean) { + if (hasClients) return + runCatching { getSystemServiceManager().tryUnregisterService(serviceName, registered) } + .onSuccess { Log.d(TAG, "Dropped the proxy service `$serviceName`") } + .onFailure { Log.d(TAG, "Proxy service `$serviceName` stays registered: $it") } + } + + override fun asBinder(): IBinder = this + } + runCatching { getSystemServiceManager().registerClientCallback(serviceName, binder, callback) } + .onSuccess { unusedName = serviceName } + .onFailure { Log.d(TAG, "Proxy service `$serviceName` stays registered: $it") } } } @@ -83,7 +99,6 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null - private var unregisterJob: Job? = null var systemServerRequested = false @@ -107,34 +122,17 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { } /** - * Drops our proxy from servicemanager, once the module it was there for has been served. + * Arranges for our proxy to leave servicemanager once the module it was there for is done with + * it; see [ServiceRegistrationWatcher.unregisterWhenUnused] for why it cannot simply be asked. * - * Best effort, and asked more than once: `tryUnregisterService` arrived in Android R, and - * servicemanager refuses while the service is still marked as having a client on the way, which - * the handshake we have just finished is exactly what put there. See [UNREGISTER_RETRY_MILLIS]. - * - * Worth the trouble because the name invented for a late injection is ours alone, so unlike - * `serial` nothing will ever come along and replace it. A refusal costs nothing: the name stays - * registered, as it did before any of this. + * Worth arranging because the name invented for a late injection is ours alone, so unlike + * `serial` nothing will ever come along and replace it. Android R and later; before that the + * name stays registered, as it did before any of this. */ fun unregisterProxyService() { - val name = proxyServiceName ?: return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return - unregisterJob?.cancel() - unregisterJob = - VectorDaemon.scope.launch { - repeat(UNREGISTER_ATTEMPTS) { attempt -> - if (attempt > 0) delay(UNREGISTER_RETRY_MILLIS) - val failure = - runCatching { ServiceRegistrationWatcher.unregister(name, SystemServerService) } - .exceptionOrNull() - if (failure == null) { - Log.d(TAG, "Dropped the proxy service `$name`") - return@launch - } - Log.d(TAG, "Proxy service `$name` stays registered: $failure") - } - } + val name = proxyServiceName ?: return + ServiceRegistrationWatcher.unregisterWhenUnused(name, this) } /** @@ -205,10 +203,6 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { } override fun binderDied() { - // A pending attempt belongs to the handshake of the system_server that has just died. The - // caller re-claims the name for its replacement right after this, and dropping that - // registration would leave the replacement with nowhere to meet us. - unregisterJob?.cancel() originService?.unlinkToDeath(this, 0) originService = null } diff --git a/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java b/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java new file mode 100644 index 0000000000..5993c200b3 --- /dev/null +++ b/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java @@ -0,0 +1,14 @@ +package android.os; +public interface IClientCallback extends IInterface +{ + public static abstract class Stub extends android.os.Binder implements android.os.IClientCallback { + } + /** + * Called when the number of clients of a registered service crosses zero in either + * direction, as servicemanager sees it on its own interval. + * + * @param registered the binder the callback was registered with + * @param hasClients whether anything other than servicemanager holds it + */ + public void onClients(android.os.IBinder registered, boolean hasClients) throws android.os.RemoteException; +} diff --git a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java index 099d12623b..fa8ac1e307 100644 --- a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java @@ -8,6 +8,8 @@ public interface IServiceManager extends IInterface { public void tryUnregisterService(String name, IBinder service); + public void registerClientCallback(String name, IBinder service, IClientCallback cb); + abstract class Stub extends Binder implements IServiceManager { public static IServiceManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); From 92bb928932930dec3db22a4b24e3700b6a305805 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 25 Oct 2026 03:10:59 +0100 Subject: [PATCH 06/13] Stop feeding the counter that ends at a factory reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 88 +++++++++---------- zygisk/module/service.sh | 2 +- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 67156cb628..8371936c98 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -79,21 +79,10 @@ object VectorDaemon { */ private var bridgeBinder: IBinder? = null - /** How many times [sendToBridge] may answer a failed handshake by restarting system_server. */ - private var systemServerMaxRetry = 1 - private var systemServerRestartBudget = 0 - @JvmStatic fun main(args: Array) { if (!FileSystem.tryLock()) kotlin.system.exitProcess(0) - for (arg in args) { - if (arg.startsWith("--system-server-max-retry=")) { - systemServerMaxRetry = arg.substringAfter('=').toIntOrNull() ?: 1 - } - } - systemServerRestartBudget = systemServerMaxRetry - // `activity` belongs to system_server, and at boot we are started beside a zygote that has yet // to fork one. checkService, not getService: the latter blocks for seconds on an absent name // before Android R, and absent is the boot path, where the module is already polling for the @@ -239,32 +228,27 @@ object VectorDaemon { if (success) { Log.i(TAG, "Successfully injected Vector IPC binder for applications.") - // The budget is for a run of failures, not for a long life: a daemon that keeps - // injecting across soft reboot after soft reboot should not run out of them. - systemServerRestartBudget = systemServerMaxRetry // Only the name we invented for a late injection: `serial` is a real service name, // and system_server registering it is what takes our proxy out of servicemanager. if (startedAfterSystemServer) SystemServerService.unregisterProxyService() } else { - Log.e(TAG, "Failed to inject VectorService into system_server") - // Restarting is a recovery only when our module is in system_server and the - // handshake went wrong, because the replacement is specialized by the same hooked - // zygote and gets another try. When no module reached system_server in the first - // place the hooks are not there, so every restart takes the user's screen down and - // arrives back here; the retry budget then spends itself on a row of them. + // Nothing is restarted over this. By the time the loop above transacts, `activity` + // has answered a ping, and the module's fate was settled well before that: its + // hooks go in during specialization, before system_server registers a service. A + // bridge that does not answer is not a race that another boot would win. // - // `systemServerRequested` separates the two: the module latches it from inside - // `postServerSpecialize`, which runs before system_server starts a single service, - // while the loop above only transacts once `activity` answers a ping. So a live - // `activity` with no handshake behind it means no module, not a race. - when { - !SystemServerService.systemServerRequested -> - Log.e(TAG, "No Vector module reached system_server; leaving it running") - systemServerRestartBudget > 0 -> { - systemServerRestartBudget-- - restartSystemServer() - } - else -> Log.e(TAG, "Out of system_server restarts; leaving it running") + // It would also be the wrong price to pay for one. Every system_server start is a + // boot to PackageWatchdog, which escalates a mitigation count that lives on disk + // and never goes down, 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. + // + // The two cases are still worth telling apart for whoever reads the log afterwards. + // `systemServerRequested` is latched from inside `postServerSpecialize`, so a live + // `activity` with nothing behind it means no module arrived at all. + if (!SystemServerService.systemServerRequested) { + Log.e(TAG, "No Vector module reached system_server; leaving it running") + } else { + Log.e(TAG, "Vector reached system_server but its bridge never answered") } } } @@ -307,25 +291,39 @@ object VectorDaemon { * * `system_server` is forked from the *primary* zygote, so restarting that is what restarts the * framework — on a 64/32 device the primary init service is still called `zygote` (it runs - * app_process64) and `zygote_secondary` is the 32-bit one. Restarting the secondary leaves - * system_server running, which is right for [restartSystemServer]'s own purpose and wrong for - * this one; they are separate functions for that reason. + * app_process64) and `zygote_secondary` is the 32-bit one. * - * Everything on screen dies with it. The caller is expected to have said so first. + * Everything on screen dies with it. The caller is expected to have said so first, which is + * also what tells this apart from a boot loop — see [forgetDeliberateBoot]. */ fun softReboot() { Log.w(TAG, "Soft reboot: restarting the primary zygote") + forgetDeliberateBoot() SystemProperties.set("ctl.restart", "zygote") } - fun restartSystemServer() { - Log.w(TAG, "Restarting system_server...") - val restartTarget = - if (Build.SUPPORTED_64_BIT_ABIS.isNotEmpty() && Build.SUPPORTED_32_BIT_ABIS.isNotEmpty()) { - "zygote_secondary" - } else { - "zygote" - } - SystemProperties.set("ctl.restart", restartTarget) + /** + * Takes the boot we are about to cause back out of PackageWatchdog's count. + * + * It counts every system_server start alike, having no way to tell one that was asked for from + * one a crash loop produced, and each time enough of them land close together it raises a + * mitigation count that is kept on disk and never lowered. Far enough along that ladder the + * mitigation stops being a reboot: the device goes to recovery to be asked whether to erase + * itself. A soft reboot does not clear the window count either, because init and its + * properties live through one, so deliberate restarts pile up exactly as failures would. + * + * Zeroing the window count immediately before restarting cancels the single tick we are about + * to add and nothing else. A real loop around us starts counting again from here and still + * trips, which is the point: we only vouch for the boot we asked for ourselves. + */ + private fun forgetDeliberateBoot() { + // Two names for one counter: RescueParty read `sys.rescue_boot_count` directly until the + // CrashRecovery mainline module moved it behind generated accessors and onto the + // `crashrecovery.` prefix. Both are cleared because the daemon supports both eras, and + // writing the name a platform does not use costs nothing. + for (property in arrayOf("crashrecovery.rescue_boot_count", "sys.rescue_boot_count")) { + runCatching { SystemProperties.set(property, "0") } + .onFailure { Log.w(TAG, "Could not clear `$property` before restarting", it) } + } } } diff --git a/zygisk/module/service.sh b/zygisk/module/service.sh index e49f8859b2..ed3d55319a 100644 --- a/zygisk/module/service.sh +++ b/zygisk/module/service.sh @@ -3,4 +3,4 @@ MODDIR="${0%/*}" cd "$MODDIR" || exit 1 # Start the daemon directly in the background within a private mount namespace -unshare --propagation slave -m "$MODDIR/daemon" --system-server-max-retry=3 "$@" & +unshare --propagation slave -m "$MODDIR/daemon" "$@" & From 6d937e6d6f9665fc0d90c334490daafa892a4c2b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 17:35:28 +0100 Subject: [PATCH 07/13] Make the daemon's namespace private with the unshare Android ships `--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. --- zygisk/module/service.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/zygisk/module/service.sh b/zygisk/module/service.sh index ed3d55319a..da490ac21b 100644 --- a/zygisk/module/service.sh +++ b/zygisk/module/service.sh @@ -2,5 +2,10 @@ MODDIR="${0%/*}" cd "$MODDIR" || exit 1 -# Start the daemon directly in the background within a private mount namespace -unshare --propagation slave -m "$MODDIR/daemon" "$@" & +# Keep the daemon's dex2oat bind-mounts inside a private mount namespace. Toybox unshare +# creates one with -m but takes no propagation flag, so the namespace marks its own root +# slave before handing over to the daemon. +unshare -m /system/bin/sh -c ' + mount -o rslave none / || echo "vector: this mount namespace stays shared" >&2 + exec "$0" "$@" +' "$MODDIR/daemon" "$@" & From 7ddb4f1b156183ea175b49f90f53efdd96291411 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 17:45:51 +0100 Subject: [PATCH 08/13] Publish the three fields the death notification now writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 26 +++++++++---------- .../vector/daemon/ipc/SystemServerService.kt | 8 +++--- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 8371936c98..1541fa5a3c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -49,10 +49,10 @@ object VectorDaemon { * Zygisk injecting into a booted system rather than into a fresh fork shows up on our side. The * boot it missed then has to be replayed, and it has to be met under another name. * - * Latched once during [main] and never revisited: by the time anything reads it a system_server - * is running either way, and what matters is which of the two we were started into. + * Set on the main thread during [main] and cleared by the death recipient on a binder thread, + * which is what the `@Volatile` is for. */ - var startedAfterSystemServer = false + @Volatile var startedAfterSystemServer = false /** * The name the module looks the daemon up under while system_server is specialized. `serial` is @@ -60,8 +60,8 @@ object VectorDaemon { * only be ours. On a booted system the real service holds it, and a lookup returning that is * indistinguishable from a successful rendezvous, so a name of our own is used instead. * - * Derived rather than stored: the two were separate fields until a restart moved one and left - * the other behind, and the daemon then waited under a name nothing would ever ask for. + * Computed from [startedAfterSystemServer] rather than stored beside it, so that the single + * place a restart changes its mind about the rendezvous is the place the name is read from. */ val proxyServiceName: String get() = if (startedAfterSystemServer) "serial_vector" else "serial" @@ -73,11 +73,11 @@ object VectorDaemon { * [sendToBridge] returns nothing else refers to that proxy, and when ART collects it the kernel * registration goes with it, silently: the daemon then sleeps through the next system_server * restart, never re-claims the rendezvous, and the module that comes back finds nothing to meet. - * Holding the proxy here is what makes a daemon outlive the system_server it was started with, - * and dropping the link goes through this field rather than through the local it was taken - * from, so that a field only ever written cannot be optimised away along with the guarantee. + * Holding the proxy here is what lets a daemon outlive the system_server it was started with. + * The link is dropped through this field too, so the reference that keeps the notification + * alive is the same one that releases it. */ - private var bridgeBinder: IBinder? = null + @Volatile private var bridgeBinder: IBinder? = null @JvmStatic fun main(args: Array) { @@ -232,10 +232,10 @@ object VectorDaemon { // and system_server registering it is what takes our proxy out of servicemanager. if (startedAfterSystemServer) SystemServerService.unregisterProxyService() } else { - // Nothing is restarted over this. By the time the loop above transacts, `activity` - // has answered a ping, and the module's fate was settled well before that: its - // hooks go in during specialization, before system_server registers a service. A - // bridge that does not answer is not a race that another boot would win. + // A bridge that does not answer is no reason to restart system_server. By the time + // the loop above transacts, `activity` has answered a ping, and the module's fate was + // settled well before that: its hooks go in during specialization, before + // system_server registers a service. This is not a race that another boot would win. // // It would also be the wrong price to pay for one. Every system_server start is a // boot to PackageWatchdog, which escalates a mitigation count that lives on disk diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index 999b936ad3..725a6f2a95 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -100,7 +100,9 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null - var systemServerRequested = false + // Latched from a binder thread when system_server attaches, cleared from another when it dies, + // and read on the daemon's main thread. + @Volatile var systemServerRequested = false fun registerProxyService(serviceName: String) { // Register as the service name early to setup an IPC for `system_server`. @@ -126,8 +128,8 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { * it; see [ServiceRegistrationWatcher.unregisterWhenUnused] for why it cannot simply be asked. * * Worth arranging because the name invented for a late injection is ours alone, so unlike - * `serial` nothing will ever come along and replace it. Android R and later; before that the - * name stays registered, as it did before any of this. + * `serial` nothing will ever come along and replace it. Android R and later; older + * servicemanagers offer no way to take a name back, and there it stays registered. */ fun unregisterProxyService() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return From b8a9d29bff33bd1a5b5b577b22ad0050b94786a6 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 17:56:32 +0100 Subject: [PATCH 09/13] Name the late load the rendezvous is for 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. --- zygisk/README.md | 4 +++- zygisk/module/service.sh | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/zygisk/README.md b/zygisk/README.md index f29a45c5d3..f519ded72f 100644 --- a/zygisk/README.md +++ b/zygisk/README.md @@ -27,7 +27,9 @@ The `system_server` acts as the primary proxy router for the framework. During t #### Choosing the rendezvous name `serial` is a real `system_server` service, registered long after specialization. At boot it is therefore free, and a binder found under it can only be the daemon's proxy. When Zygisk instead attaches to a `system_server` that booted without Vector, the real service is already registered, and a lookup returning it would be indistinguishable from a successful rendezvous; `serial_vector`, a name no platform service uses, is claimed for that case. -Neither side is told which case it is in. Both ask the same question independently: does `activity` already answer? Nothing has registered it during a boot, and a running `system_server` always has. The module also honours Zygisk's own `LATE_INJECT` runtime flag where the implementation sets it, and falls back to the probe where it does not. +The late case is NeoZygisk's late load — `ksud late-load`, a manual trigger, or a root session re-applied to a system that booted without one — which attaches to a `system_server` already serving and drives `postServerSpecialize` on a thread of its own ([NeoZygisk#174](https://github.com/JingMatrix/NeoZygisk/pull/174)). It says so through the `LATE_INJECT` runtime flag, and the module reads that first. + +Nothing tells the daemon, which the same `service.sh` starts either way, so it asks the one question both sides can answer alike: does `activity` already answer? Nothing has registered it during a boot, and a `system_server` that is serving has. The module falls back on the same probe wherever the flag is absent. At boot the two cannot disagree: the module's poll for the rendezvous runs on `system_server`'s own thread during specialization, so `activity` cannot appear while a daemon is still starting up behind it. `serial_vector` is the daemon's alone, so unlike `serial` nothing will ever come along and replace it. Once the module it existed for has let go of the binder, the daemon unregisters it through `registerClientCallback`, which is the only sequence servicemanager accepts — `tryUnregisterService` on its own is refused for as long as the service is marked as having a client, and for a name without a client callback that mark is never cleared. diff --git a/zygisk/module/service.sh b/zygisk/module/service.sh index da490ac21b..c895eb52d4 100644 --- a/zygisk/module/service.sh +++ b/zygisk/module/service.sh @@ -2,9 +2,11 @@ MODDIR="${0%/*}" cd "$MODDIR" || exit 1 -# Keep the daemon's dex2oat bind-mounts inside a private mount namespace. Toybox unshare -# creates one with -m but takes no propagation flag, so the namespace marks its own root -# slave before handing over to the daemon. +# A root manager runs this script with `busybox sh`, and NeoZygisk's tracer runs it with +# /system/bin/sh when no framework fans the boot stages out, so only toybox's unshare can be +# counted on: it makes a mount namespace with -m and takes no propagation flag. The namespace +# therefore marks its own root slave before handing over, which is what keeps the daemon's +# dex2oat bind-mounts out of the global one. unshare -m /system/bin/sh -c ' mount -o rslave none / || echo "vector: this mount namespace stays shared" >&2 exec "$0" "$@" From 5d26c294a4a332d14ca6764c8c7e9e3d35adeb98 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 18:24:38 +0100 Subject: [PATCH 10/13] Say why the restart is gone and who the namespace is 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. --- .../org/matrix/vector/daemon/VectorDaemon.kt | 15 ++++++++------- zygisk/module/service.sh | 10 +++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 1541fa5a3c..3c595592ce 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -232,14 +232,15 @@ object VectorDaemon { // and system_server registering it is what takes our proxy out of servicemanager. if (startedAfterSystemServer) SystemServerService.unregisterProxyService() } else { - // A bridge that does not answer is no reason to restart system_server. By the time - // the loop above transacts, `activity` has answered a ping, and the module's fate was - // settled well before that: its hooks go in during specialization, before - // system_server registers a service. This is not a race that another boot would win. + // system_server is not restarted here. Restarting it has never been shown to + // recover an injection: the module's hooks go in during specialization, long + // before the bridge that would be restarted over, so a handshake failing this + // late is a bug in the injection or the bootstrap. A restart hides that bug + // until someone fixes it, which is the argument against keeping one. // - // It would also be the wrong price to pay for one. Every system_server start is a - // boot to PackageWatchdog, which escalates a mitigation count that lives on disk - // and never goes down, and far enough along that ladder the mitigation stops being + // It is not free to keep around either. Every system_server start is a boot to + // PackageWatchdog, which escalates a mitigation count that lives on disk and + // never goes down, 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. // // The two cases are still worth telling apart for whoever reads the log afterwards. diff --git a/zygisk/module/service.sh b/zygisk/module/service.sh index c895eb52d4..cc00edf414 100644 --- a/zygisk/module/service.sh +++ b/zygisk/module/service.sh @@ -2,11 +2,11 @@ MODDIR="${0%/*}" cd "$MODDIR" || exit 1 -# A root manager runs this script with `busybox sh`, and NeoZygisk's tracer runs it with -# /system/bin/sh when no framework fans the boot stages out, so only toybox's unshare can be -# counted on: it makes a mount namespace with -m and takes no propagation flag. The namespace -# therefore marks its own root slave before handing over, which is what keeps the daemon's -# dex2oat bind-mounts out of the global one. +# A root manager runs this script with its own busybox, but a manual bootstrap has nothing in +# front of it except Android's utilities, so this is written for toybox's unshare: it makes a +# mount namespace with -m and takes no propagation flag. The namespace therefore marks its own +# root slave before handing over, which is what keeps the daemon's dex2oat bind-mounts out of +# the global one. unshare -m /system/bin/sh -c ' mount -o rslave none / || echo "vector: this mount namespace stays shared" >&2 exec "$0" "$@" From 624650555cc23c1eed921166fe4c8f4c4b170989 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 18:36:41 +0100 Subject: [PATCH 11/13] Say what the probe is worth when it cannot run 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. --- zygisk/src/main/cpp/ipc_bridge.cpp | 3 ++- zygisk/src/main/cpp/module.cpp | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index c0ffb2a50f..5de162cb90 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -158,7 +158,8 @@ void IPCBridge::Initialize(JNIEnv *env) { return; } // IsSystemServerRunning needs it: getService blocks for seconds on an absent name before - // Android R, which is precisely the case it asks about. Optional, the probe copes without it. + // Android R, which is precisely the case it asks about. Its absence costs only the probe, + // leaving the LATE_INJECT flag to answer on its own. check_service_method_ = lsplant::JNI_GetStaticMethodID( env, service_manager_class_, "checkService", "(Ljava/lang/String;)Landroid/os/IBinder;"); if (!check_service_method_) { diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 7bae492523..001e85c3da 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -415,9 +415,9 @@ void VectorModule::postServerSpecialize(const zygisk::ServerSpecializeArgs *args // --- Framework Injection for System Server --- auto &ipc_bridge = IPCBridge::GetInstance(); - // Zygisk says so itself when it attached to a running system_server. Implementations that do - // not set the flag are caught by the fallback, an already serving system_server being the one - // thing a late injection always leaves behind, and the same thing the daemon tests. + // NeoZygisk sets LATE_INJECT when it drove this specialization on a system_server that was + // already serving. An implementation that says nothing is caught by the probe: a serving + // system_server is what a late load always leaves behind, and what the daemon tests too. bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0 || ipc_bridge.IsSystemServerRunning(env_); // The real `serial` service is registered by then, and a lookup returning it would be From a4cbbdd7b9f723f1d583f3a510127c95058d13c9 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 18:41:19 +0100 Subject: [PATCH 12/13] Hold the comments to what can be shown 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. --- .../main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt | 8 ++++---- .../stubs/src/main/java/android/os/ServiceManager.java | 3 ++- zygisk/src/main/cpp/ipc_bridge.cpp | 6 +++--- zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt | 3 ++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 3c595592ce..d9b1ac9045 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -83,10 +83,10 @@ object VectorDaemon { fun main(args: Array) { if (!FileSystem.tryLock()) kotlin.system.exitProcess(0) - // `activity` belongs to system_server, and at boot we are started beside a zygote that has yet - // to fork one. checkService, not getService: the latter blocks for seconds on an absent name - // before Android R, and absent is the boot path, where the module is already polling for the - // proxy we have not registered yet. + // `activity` is system_server's own, registered long after the specialization in which the + // module comes looking for us, so at boot nothing answers it and a binder there means someone + // booted without us. checkService, not getService: it reports what servicemanager already + // holds, where getService may also try to start the service behind the name. startedAfterSystemServer = ServiceManager.checkService(bridgeServiceName) != null Log.i( diff --git a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java index 443d625cc1..d66c7758d4 100644 --- a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java @@ -16,7 +16,8 @@ public static IBinder getService(String name) { } /** - * Returns a reference to a service with the given name, without blocking when it is absent. + * Returns the service with the given name if servicemanager already holds one, without + * asking for it to be started. * * @param name the name of the service to get * @return a reference to the service, or null if the service doesn't exist diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index 5de162cb90..258201ac32 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -157,9 +157,9 @@ void IPCBridge::Initialize(JNIEnv *env) { LOGE("IPCBridge: ServiceManager.getService method not found!"); return; } - // IsSystemServerRunning needs it: getService blocks for seconds on an absent name before - // Android R, which is precisely the case it asks about. Its absence costs only the probe, - // leaving the LATE_INJECT flag to answer on its own. + // IsSystemServerRunning needs it: checkService reports what servicemanager already holds, + // where getService may also try to start the service behind the name. Its absence costs only + // the probe, leaving the LATE_INJECT flag to answer on its own. check_service_method_ = lsplant::JNI_GetStaticMethodID( env, service_manager_class_, "checkService", "(Ljava/lang/String;)Landroid/os/IBinder;"); if (!check_service_method_) { diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index 782d5cb825..69629a7bff 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -20,7 +20,8 @@ object Main { * * @param isSystem True if this is the system_server process. * @param isLateInject True if Zygisk injected us into a system_server that was already - * running, so the boot it missed has to be replayed rather than awaited. + * running, which is past the points the hooks would have caught, so the startup they drive + * is run by hand instead. * @param niceName The process name (e.g., package name or "system"). * @param appDir The application's data directory. * @param binder The Binder token associated with the application service. From 1dab91cdef254797758fb4a718d01f4e7258c425 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 26 Oct 2026 18:47:04 +0100 Subject: [PATCH 13/13] [skip ci] Stop justifying checkService with a difference it does not 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. --- .../src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt | 4 ++-- hiddenapi/stubs/src/main/java/android/os/ServiceManager.java | 3 +-- zygisk/src/main/cpp/ipc_bridge.cpp | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index d9b1ac9045..3444e1c41c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -85,8 +85,8 @@ object VectorDaemon { // `activity` is system_server's own, registered long after the specialization in which the // module comes looking for us, so at boot nothing answers it and a binder there means someone - // booted without us. checkService, not getService: it reports what servicemanager already - // holds, where getService may also try to start the service behind the name. + // booted without us. checkService is that question by its own name: does servicemanager hold + // this one now. startedAfterSystemServer = ServiceManager.checkService(bridgeServiceName) != null Log.i( diff --git a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java index d66c7758d4..59b95e5650 100644 --- a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java @@ -16,8 +16,7 @@ public static IBinder getService(String name) { } /** - * Returns the service with the given name if servicemanager already holds one, without - * asking for it to be started. + * Returns a reference to a service with the given name if one is already registered. * * @param name the name of the service to get * @return a reference to the service, or null if the service doesn't exist diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index 258201ac32..2cd6bf8089 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -157,9 +157,8 @@ void IPCBridge::Initialize(JNIEnv *env) { LOGE("IPCBridge: ServiceManager.getService method not found!"); return; } - // IsSystemServerRunning needs it: checkService reports what servicemanager already holds, - // where getService may also try to start the service behind the name. Its absence costs only - // the probe, leaving the LATE_INJECT flag to answer on its own. + // IsSystemServerRunning needs it: checkService asks servicemanager whether it holds the + // name now. Its absence costs only the probe, leaving LATE_INJECT to answer on its own. check_service_method_ = lsplant::JNI_GetStaticMethodID( env, service_manager_class_, "checkService", "(Ljava/lang/String;)Landroid/os/IBinder;"); if (!check_service_method_) {