From ec1bfc1ab826c8704fac19a580892c8ec19b77cc Mon Sep 17 00:00:00 2001 From: Brett Chabot Date: Fri, 4 Sep 2026 16:00:55 -0700 Subject: [PATCH] Bind to androidx.test.services.SpeakEasyService to mitigate LMK kills. When physical devices experience high memory pressure, `androidx.test.services` is at risk of being killed by the system's lowmemorykiller due to running as an unbound background process, causing shell command execution to fail. This commit raises androidx.test.services by establishing an active binding from the calling test process, thus inheriting its top-level foreground priority (`oom_score_adj = 0`). Shell binder connections are also cached across invocations to reduce unnecessary queries across processes. This change only effects ShellExecutor when used in the legacy non-localsocket mode. PiperOrigin-RevId: 976536567 --- services/CHANGELOG.md | 3 ++ .../shellexecutor/ShellCommandClient.java | 31 +++++++++++-------- .../speakeasy/client/AppConnection.java | 31 +++++++++++++++++++ .../speakeasy/server/SpeakEasyService.java | 13 ++++++-- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/services/CHANGELOG.md b/services/CHANGELOG.md index b52180a8e..8a4604b11 100644 --- a/services/CHANGELOG.md +++ b/services/CHANGELOG.md @@ -6,6 +6,9 @@ **Bug Fixes** +* Make ShellExecutor more resilient to low memory conditions by binding to + androidx.test.services.SpeakEasyService + **New Features** **Breaking Changes** diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandClient.java b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandClient.java index 45a788c31..9f74bd09c 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandClient.java +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandClient.java @@ -30,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Client for the ShellCommandExecutor service, to allow an instrumentation to executes a shell @@ -44,6 +45,7 @@ final class ShellCommandClient { private static final String TAG = "ShellCommandClient"; + private static final ConcurrentHashMap stubCache = new ConcurrentHashMap<>(); private ShellCommandClient() { // Should not be initialized @@ -88,25 +90,28 @@ public static synchronized InputStream execOnServer( shellEnv = new HashMap<>(); } - FindResult result; - - try { - result = BlockingFind.getResult(Looper.getMainLooper(), context, secret); - if (!result.found) { - Log.e(TAG, "Couldn't find a published binder"); + Command commandStub = stubCache.get(secret); + if (commandStub == null || !commandStub.asBinder().isBinderAlive()) { + FindResult result; + try { + result = BlockingFind.getResult(Looper.getMainLooper(), context, secret); + if (!result.found) { + Log.e(TAG, "Couldn't find a published binder"); + throw new ClientNotConnected( + "Couldn't find a binder published by androidx.test.services. It is very likely that" + + " androidx.test.services was killed, which is usually due to low memory" + + " conditions. Consider switching to a device with more memory."); + } + } catch (InterruptedException e) { throw new ClientNotConnected( - "Couldn't find a binder published by androidx.test.services. It is very likely that" - + " androidx.test.services was killed, which is usually due to low memory" - + " conditions. Consider switching to a device with more memory."); + "Search for an androidx.test.services binder was interrupted", e); } - } catch (InterruptedException e) { - throw new ClientNotConnected( - "Search for an androidx.test.services binder was interrupted", e); + commandStub = Command.Stub.asInterface(result.binder); + stubCache.put(secret, commandStub); } ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); - Command commandStub = Command.Stub.asInterface(result.binder); // Only use timeout version if timeout is greater than 0 if (timeoutMs > 0L) { // NOTICE: this is not be supported on older versions of the Command server. diff --git a/services/speakeasy/java/androidx/test/services/speakeasy/client/AppConnection.java b/services/speakeasy/java/androidx/test/services/speakeasy/client/AppConnection.java index ef7755a59..fedb21b7f 100644 --- a/services/speakeasy/java/androidx/test/services/speakeasy/client/AppConnection.java +++ b/services/speakeasy/java/androidx/test/services/speakeasy/client/AppConnection.java @@ -16,16 +16,20 @@ package androidx.test.services.speakeasy.client; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; import android.os.Build; import android.os.IBinder; +import android.util.Log; import androidx.test.services.speakeasy.SpeakEasyProtocol; import java.security.SecureRandom; import java.util.Random; /** Allows callers to access the speakeasy binder registry when they have a Context. */ public final class AppConnection implements Connection { + private static final String TAG = "AppConnection"; private static final String PACKAGE_NAME = "androidx.test.services"; static final String SERVICE = "androidx.test.services.speakeasy.server.SpeakEasyService"; @@ -45,11 +49,36 @@ public AppConnection(Context context) { this.random = checkNotNull(random); } + private static final ServiceConnection serviceConnection = + new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) {} + + @Override + public void onServiceDisconnected(ComponentName name) {} + }; + private static boolean isBound = false; + + private synchronized void ensureBound() { + if (!isBound) { + try { + isBound = + context.bindService( + makeIntent(), + serviceConnection, + Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT | Context.BIND_ABOVE_CLIENT); + } catch (SecurityException | IllegalArgumentException e) { + Log.w(TAG, "Failed to bind to SpeakEasyService", e); + } + } + } + @Override public void publish(IBinder binder, PublishResultReceiver rr) { checkNotNull(binder); checkNotNull(rr); + ensureBound(); String key = Long.toHexString(random.nextLong()); Intent intent = makeIntent(); intent.putExtras(SpeakEasyProtocol.Publish.asBundle(key, binder, rr)); @@ -60,6 +89,8 @@ public void publish(IBinder binder, PublishResultReceiver rr) { public void find(String key, FindResultReceiver rr) { checkNotNull(key); checkNotNull(rr); + + ensureBound(); Intent intent = makeIntent(); intent.putExtras(SpeakEasyProtocol.Find.asBundle(key, rr)); startForegroundService(context, intent); diff --git a/services/speakeasy/java/androidx/test/services/speakeasy/server/SpeakEasyService.java b/services/speakeasy/java/androidx/test/services/speakeasy/server/SpeakEasyService.java index bf8d9fd91..b2c260d8e 100644 --- a/services/speakeasy/java/androidx/test/services/speakeasy/server/SpeakEasyService.java +++ b/services/speakeasy/java/androidx/test/services/speakeasy/server/SpeakEasyService.java @@ -25,6 +25,7 @@ import android.app.Service; import android.content.Context; import android.content.Intent; +import android.os.Binder; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; @@ -72,7 +73,7 @@ public void onStart(Intent intent, int id) { @Override public int onStartCommand(Intent intent, int flags, int startId) { onStart(intent, startId); - return START_NOT_STICKY; + return START_STICKY; } @Override @@ -108,6 +109,9 @@ public void run() { } private void serveIntent(Intent in, int startId) { + if (in == null || in.getExtras() == null) { + return; + } SpeakEasyProtocol sep = SpeakEasyProtocol.fromBundle(in.getExtras()); if (null == sep) { return; @@ -148,9 +152,14 @@ private void serveIntent(Intent in, int startId) { } } + private final IBinder binder = new Binder(); + @Override public IBinder onBind(Intent i) { - return null; + if (i != null && i.getExtras() != null) { + serveIntent(i, -1); + } + return binder; } private static class DeathCallback implements SpeakEasy.BinderDeathCallback {