diff options
4 files changed, 571 insertions, 43 deletions
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java index 93100084a6..e202c4fecc 100644 --- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java +++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java @@ -190,7 +190,7 @@ public final class ArtManagerLocal { public int handleShellCommand(@NonNull Binder target, @NonNull ParcelFileDescriptor in, @NonNull ParcelFileDescriptor out, @NonNull ParcelFileDescriptor err, @NonNull String[] args) { - return new ArtShellCommand(this, mInjector.getPackageManagerLocal(), mInjector.getContext()) + return new ArtShellCommand(this, mInjector.getPackageManagerLocal()) .exec(target, in.getFileDescriptor(), out.getFileDescriptor(), err.getFileDescriptor(), args); } diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java index 21b24c7205..d21d5d7068 100644 --- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java +++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java @@ -31,7 +31,6 @@ import static com.android.server.art.model.DexoptStatus.DexContainerFileDexoptSt import android.annotation.NonNull; import android.annotation.Nullable; -import android.content.Context; import android.os.Binder; import android.os.Build; import android.os.CancellationSignal; @@ -44,6 +43,7 @@ import android.system.StructStat; import androidx.annotation.RequiresApi; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; import com.android.modules.utils.BasicShellCommandHandler; import com.android.modules.utils.build.SdkLevel; import com.android.server.art.model.ArtFlags; @@ -93,19 +93,20 @@ public final class ArtShellCommand extends BasicShellCommandHandler { /** The default location for profile dumps. */ private final static String PROFILE_DEBUG_LOCATION = "/data/misc/profman"; - private final ArtManagerLocal mArtManagerLocal; - private final PackageManagerLocal mPackageManagerLocal; - private final Context mContext; + @NonNull private final Injector mInjector; @GuardedBy("sCancellationSignalMap") @NonNull private static final Map<String, CancellationSignal> sCancellationSignalMap = new HashMap<>(); public ArtShellCommand(@NonNull ArtManagerLocal artManagerLocal, - @NonNull PackageManagerLocal packageManagerLocal, @NonNull Context context) { - mArtManagerLocal = artManagerLocal; - mPackageManagerLocal = packageManagerLocal; - mContext = context; + @NonNull PackageManagerLocal packageManagerLocal) { + this(new Injector(artManagerLocal, packageManagerLocal)); + } + + @VisibleForTesting + public ArtShellCommand(@NonNull Injector injector) { + mInjector = injector; } @Override @@ -113,7 +114,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // Apps shouldn't call ART Service shell commands, not even for dexopting themselves. enforceRootOrShell(); PrintWriter pw = getOutPrintWriter(); - try (var snapshot = mPackageManagerLocal.withFilteredSnapshot()) { + try (var snapshot = mInjector.getPackageManagerLocal().withFilteredSnapshot()) { switch (cmd) { case "compile": return handleCompile(pw, snapshot); @@ -183,9 +184,10 @@ public final class ArtShellCommand extends BasicShellCommandHandler { String packageName = getNextArg(); if (packageName != null) { - mArtManagerLocal.dumpPackage(pw, snapshot, packageName, verifySdmSignatures); + mInjector.getArtManagerLocal().dumpPackage( + pw, snapshot, packageName, verifySdmSignatures); } else { - mArtManagerLocal.dump(pw, snapshot, verifySdmSignatures); + mInjector.getArtManagerLocal().dump(pw, snapshot, verifySdmSignatures); } return 0; } @@ -193,7 +195,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { return handleCleanup(pw, snapshot); } case "clear-app-profiles": { - mArtManagerLocal.clearAppProfiles(snapshot, getNextArgRequired()); + mInjector.getArtManagerLocal().clearAppProfiles(snapshot, getNextArgRequired()); pw.println("Profiles cleared"); return 0; } @@ -371,7 +373,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // For compat only. Combining this with dexopt usually produces in undesired // results. for (String packageName : packageNames) { - mArtManagerLocal.clearAppProfiles(snapshot, packageName); + mInjector.getArtManagerLocal().clearAppProfiles(snapshot, packageName); } } return dexoptPackages(pw, snapshot, packageNames, paramsBuilder.build(), verbose); @@ -411,7 +413,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { } CompletableFuture<BackgroundDexoptJob.Result> runningJob = - mArtManagerLocal.getRunningBackgroundDexoptJob(); + mInjector.getArtManagerLocal().getRunningBackgroundDexoptJob(); if (runningJob != null) { pw.println("Another job already running. Waiting for it to finish... To cancel it, " + "run 'pm bg-dexopt-job --cancel'. in a separate shell."); @@ -419,7 +421,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { Utils.getFuture(runningJob); } CompletableFuture<BackgroundDexoptJob.Result> future = - mArtManagerLocal.startBackgroundDexoptJobAndReturnFuture(); + mInjector.getArtManagerLocal().startBackgroundDexoptJobAndReturnFuture(); pw.println("Job running... To cancel it, run 'pm bg-dexopt-job --cancel'. in a " + "separate shell."); pw.flush(); @@ -445,7 +447,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // This operation requires the uid to be "system" (1000). long identityToken = Binder.clearCallingIdentity(); try { - mArtManagerLocal.scheduleBackgroundDexoptJob(); + mInjector.getArtManagerLocal().scheduleBackgroundDexoptJob(); } finally { Binder.restoreCallingIdentity(identityToken); } @@ -456,7 +458,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // This operation requires the uid to be "system" (1000). long identityToken = Binder.clearCallingIdentity(); try { - mArtManagerLocal.unscheduleBackgroundDexoptJob(); + mInjector.getArtManagerLocal().unscheduleBackgroundDexoptJob(); } finally { Binder.restoreCallingIdentity(identityToken); } @@ -470,22 +472,22 @@ public final class ArtShellCommand extends BasicShellCommandHandler { } private int handleCancelBgDexoptJob(@NonNull PrintWriter pw) { - mArtManagerLocal.cancelBackgroundDexoptJob(); + mInjector.getArtManagerLocal().cancelBackgroundDexoptJob(); pw.println("Background dexopt job cancelled"); return 0; } private int handleCleanup( @NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot) { - long freedBytes = mArtManagerLocal.cleanup(snapshot); + long freedBytes = mInjector.getArtManagerLocal().cleanup(snapshot); pw.printf("Freed %d bytes\n", freedBytes); return 0; } private int handleDeleteDexopt( @NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot) { - DeleteResult result = - mArtManagerLocal.deleteDexoptArtifacts(snapshot, getNextArgRequired()); + DeleteResult result = mInjector.getArtManagerLocal().deleteDexoptArtifacts( + snapshot, getNextArgRequired()); pw.printf("Freed %d bytes\n", result.getFreedBytes()); return 0; } @@ -548,7 +550,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot) throws SnapshotProfileException { String outputRelativePath = "android.prof"; - ParcelFileDescriptor fd = mArtManagerLocal.snapshotBootImageProfile(snapshot); + ParcelFileDescriptor fd = mInjector.getArtManagerLocal().snapshotBootImageProfile(snapshot); writeProfileFdContentsToFile(pw, fd, outputRelativePath); return 0; } @@ -559,7 +561,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { String outputRelativePath = String.format("%s%s.prof", packageName, splitName != null ? String.format("-split_%s.apk", splitName) : ""); ParcelFileDescriptor fd = - mArtManagerLocal.snapshotAppProfile(snapshot, packageName, splitName); + mInjector.getArtManagerLocal().snapshotAppProfile(snapshot, packageName, splitName); writeProfileFdContentsToFile(pw, fd, outputRelativePath); return 0; } @@ -594,7 +596,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { pw.println("Waiting for app processes to flush profiles..."); pw.flush(); long startTimeMs = System.currentTimeMillis(); - if (mArtManagerLocal.flushProfiles(snapshot, packageName)) { + if (mInjector.getArtManagerLocal().flushProfiles(snapshot, packageName)) { pw.printf("App processes flushed profiles in %dms\n", System.currentTimeMillis() - startTimeMs); } else { @@ -607,7 +609,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // is to match the behavior of the legacy PM shell command. String outputRelativePath = String.format("%s-%s.prof.txt", packageName, profileName); - ParcelFileDescriptor fd = mArtManagerLocal.dumpAppProfile( + ParcelFileDescriptor fd = mInjector.getArtManagerLocal().dumpAppProfile( snapshot, packageName, dexInfo.splitName(), dumpClassesAndMethods); writeProfileFdContentsToFile(pw, fd, outputRelativePath); } @@ -654,8 +656,9 @@ public final class ArtShellCommand extends BasicShellCommandHandler { ExecutorService progressCallbackExecutor = Executors.newSingleThreadExecutor(); try (var signal = new WithCancellationSignal(pw, true /* verbose */)) { - Map<Integer, DexoptResult> results = mArtManagerLocal.dexoptPackages(snapshot, - finalReason, signal.get(), progressCallbackExecutor, progressCallbacks); + Map<Integer, DexoptResult> results = + mInjector.getArtManagerLocal().dexoptPackages(snapshot, finalReason, + signal.get(), progressCallbackExecutor, progressCallbacks); Utils.executeAndWait(progressCallbackExecutor, () -> { for (@BatchDexoptPass int pass : ArtFlags.BATCH_DEXOPT_PASSES) { @@ -682,7 +685,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { return 1; } - int uid = Binder.getCallingUid(); + int uid = mInjector.getCallingUid(); if (uid != Process.ROOT_UID) { throw new SecurityException("Only root can call 'on-ota-staged'"); } @@ -706,11 +709,11 @@ public final class ArtShellCommand extends BasicShellCommandHandler { return 1; } - if (mArtManagerLocal.getPreRebootDexoptJob().isAsyncForOta()) { + if (mInjector.getArtManagerLocal().getPreRebootDexoptJob().isAsyncForOta()) { return handleSchedulePrDexoptJob(pw, otaSlot); } else { - // Don't map snapshots when running synchronously. `update_engine` maps snapshots for - // us. + // Don't map snapshots when running synchronously. `update_engine` maps snapshots + // for us. return handleRunPrDexoptJob(pw, otaSlot, false /* mapSnapshotsForOta */); } } @@ -752,7 +755,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { return 1; } - if (otaSlot != null && Binder.getCallingUid() != Process.ROOT_UID) { + if (otaSlot != null && mInjector.getCallingUid() != Process.ROOT_UID) { throw new SecurityException("Only root can specify '--slot'"); } @@ -777,7 +780,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private int handleTestPrDexoptJob(@NonNull PrintWriter pw) { try { - mArtManagerLocal.getPreRebootDexoptJob().test(); + mInjector.getArtManagerLocal().getPreRebootDexoptJob().test(); pw.println("Success"); return 0; } catch (Exception e) { @@ -790,7 +793,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private int handleRunPrDexoptJob( @NonNull PrintWriter pw, @Nullable String otaSlot, boolean mapSnapshotsForOta) { - PreRebootDexoptJob job = mArtManagerLocal.getPreRebootDexoptJob(); + PreRebootDexoptJob job = mInjector.getArtManagerLocal().getPreRebootDexoptJob(); CompletableFuture<Void> future = job.onUpdateReadyStartNow(otaSlot, mapSnapshotsForOta); if (future == null) { @@ -844,7 +847,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private int handleSchedulePrDexoptJob(@NonNull PrintWriter pw, @Nullable String otaSlot) { - int code = mArtManagerLocal.getPreRebootDexoptJob().onUpdateReadyImpl(otaSlot); + int code = + mInjector.getArtManagerLocal().getPreRebootDexoptJob().onUpdateReadyImpl(otaSlot); switch (code) { case ArtFlags.SCHEDULE_SUCCESS: pw.println("Pre-reboot Dexopt job scheduled"); @@ -863,7 +867,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private int handleCancelPrDexoptJob(@NonNull PrintWriter pw) { - mArtManagerLocal.getPreRebootDexoptJob().cancelAny(); + mInjector.getArtManagerLocal().getPreRebootDexoptJob().cancelAny(); pw.println("Pre-reboot Dexopt job cancelled"); return 0; } @@ -889,7 +893,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { // Variables used in lambda needs to be effectively final. String finalInputReason = inputReason; - mArtManagerLocal.setBatchDexoptStartCallback( + mInjector.getArtManagerLocal().setBatchDexoptStartCallback( Runnable::run, (snapshot, reason, defaultPackages, builder, cancellationSignal) -> { if (reason.equals(finalInputReason)) { if (!packages.isEmpty()) { @@ -1109,7 +1113,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler { } private void enforceRootOrShell() { - final int uid = Binder.getCallingUid(); + final int uid = mInjector.getCallingUid(); if (uid != Process.ROOT_UID && uid != Process.SHELL_UID) { throw new SecurityException("ART service shell commands need root or shell access"); } @@ -1175,8 +1179,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @NonNull List<String> packageNames, boolean verbose) { try (var signal = new WithCancellationSignal(pw, verbose)) { for (String packageName : packageNames) { - DexoptResult result = - mArtManagerLocal.resetDexoptStatus(snapshot, packageName, signal.get()); + DexoptResult result = mInjector.getArtManagerLocal().resetDexoptStatus( + snapshot, packageName, signal.get()); printDexoptResult(pw, result, verbose, packageNames.size() > 1); } } @@ -1188,8 +1192,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler { @NonNull List<String> packageNames, @NonNull DexoptParams params, boolean verbose) { try (var signal = new WithCancellationSignal(pw, verbose)) { for (String packageName : packageNames) { - DexoptResult result = - mArtManagerLocal.dexoptPackage(snapshot, packageName, params, signal.get()); + DexoptResult result = mInjector.getArtManagerLocal().dexoptPackage( + snapshot, packageName, params, signal.get()); printDexoptResult(pw, result, verbose, packageNames.size() > 1); } } @@ -1302,4 +1306,31 @@ public final class ArtShellCommand extends BasicShellCommandHandler { } } } + + /** Injector pattern for testing purpose. */ + @VisibleForTesting + public static class Injector { + @NonNull private final ArtManagerLocal mArtManagerLocal; + @NonNull private final PackageManagerLocal mPackageManagerLocal; + + public Injector(@NonNull ArtManagerLocal artManagerLocal, + @NonNull PackageManagerLocal packageManagerLocal) { + mArtManagerLocal = artManagerLocal; + mPackageManagerLocal = packageManagerLocal; + } + + @NonNull + public ArtManagerLocal getArtManagerLocal() { + return mArtManagerLocal; + } + + @NonNull + public PackageManagerLocal getPackageManagerLocal() { + return mPackageManagerLocal; + } + + public int getCallingUid() { + return Binder.getCallingUid(); + } + } } diff --git a/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java b/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java new file mode 100644 index 0000000000..7b934bd7e4 --- /dev/null +++ b/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.art; + +import static com.android.server.art.PreRebootDexoptJob.JOB_ID; +import static com.android.server.art.prereboot.PreRebootDriver.PreRebootResult; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.os.CancellationSignal; +import android.os.Process; +import android.os.SystemProperties; + +import androidx.test.filters.SmallTest; + +import com.android.server.art.prereboot.PreRebootDriver; +import com.android.server.art.prereboot.PreRebootStatsReporter; +import com.android.server.art.testing.CommandExecution; +import com.android.server.art.testing.StaticMockitoRule; +import com.android.server.pm.PackageManagerLocal; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@SmallTest +@RunWith(MockitoJUnitRunner.StrictStubs.class) +public class ArtShellCommandTest { + private static final long TIMEOUT_SEC = 10; + + @Rule + public StaticMockitoRule mockitoRule = new StaticMockitoRule( + SystemProperties.class, BackgroundDexoptJobService.class, ArtJni.class); + + @Mock private BackgroundDexoptJobService mJobService; + @Mock private PreRebootDriver mPreRebootDriver; + @Mock private PreRebootStatsReporter mPreRebootStatsReporter; + @Mock private JobScheduler mJobScheduler; + @Mock private PreRebootDexoptJob.Injector mPreRebootDexoptJobInjector; + @Mock private ArtManagerLocal.Injector mArtManagerLocalInjector; + @Mock private PackageManagerLocal mPackageManagerLocal; + @Mock private ArtShellCommand.Injector mInjector; + + private PreRebootDexoptJob mPreRebootDexoptJob; + private ArtManagerLocal mArtManagerLocal; + private JobInfo mJobInfo; + private JobParameters mJobParameters; + + @Before + public void setUp() throws Exception { + lenient() + .when(SystemProperties.getBoolean(eq("dalvik.vm.enable_pr_dexopt"), anyBoolean())) + .thenReturn(true); + + lenient().when(mJobScheduler.schedule(any())).thenAnswer(invocation -> { + mJobInfo = invocation.<JobInfo>getArgument(0); + mJobParameters = mock(JobParameters.class); + assertThat(mJobInfo.getId()).isEqualTo(JOB_ID); + lenient().when(mJobParameters.getExtras()).thenReturn(mJobInfo.getExtras()); + return JobScheduler.RESULT_SUCCESS; + }); + + lenient() + .doAnswer(invocation -> { + mJobInfo = null; + mJobParameters = null; + return null; + }) + .when(mJobScheduler) + .cancel(JOB_ID); + + lenient().when(mJobScheduler.getPendingJob(JOB_ID)).thenAnswer(invocation -> { + return mJobInfo; + }); + + lenient() + .when(mPreRebootDexoptJobInjector.getPreRebootDriver()) + .thenReturn(mPreRebootDriver); + lenient() + .when(mPreRebootDexoptJobInjector.getStatsReporter()) + .thenReturn(mPreRebootStatsReporter); + lenient().when(mPreRebootDexoptJobInjector.getJobScheduler()).thenReturn(mJobScheduler); + mPreRebootDexoptJob = new PreRebootDexoptJob(mPreRebootDexoptJobInjector); + + lenient().when(BackgroundDexoptJobService.getJob(JOB_ID)).thenReturn(mPreRebootDexoptJob); + + lenient() + .when(mArtManagerLocalInjector.getPreRebootDexoptJob()) + .thenReturn(mPreRebootDexoptJob); + mArtManagerLocal = new ArtManagerLocal(mArtManagerLocalInjector); + + lenient().when(mInjector.getArtManagerLocal()).thenReturn(mArtManagerLocal); + lenient().when(mInjector.getPackageManagerLocal()).thenReturn(mPackageManagerLocal); + } + + @Test + public void testOnOtaStagedPermission() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(-1); + assertThat(outputs).contains("Only root can call 'on-ota-staged'"); + } + } + + @Test + public void testOnOtaStagedSync() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job finished. See logs for details"); + } + } + + @Test + public void testOnOtaStagedSyncFatalError() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any())) + .thenThrow(RuntimeException.class); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job encountered a fatal error"); + } + } + + @Test + public void testOnOtaStagedSyncCancelledByCommand() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any())) + .thenAnswer(invocation -> { + Semaphore dexoptCancelled = new Semaphore(0 /* permits */); + var cancellationSignal = invocation.<CancellationSignal>getArgument(2); + cancellationSignal.setOnCancelListener(() -> dexoptCancelled.release()); + assertThat(dexoptCancelled.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue(); + return new PreRebootResult(true /* success */); + }); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + assertThat(execution.getStdout().readLine()).contains("Job running..."); + + try (var execution2 = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--cancel")) { + int exitCode2 = execution2.waitAndGetExitCode(); + String outputs2 = getOutputs(execution2); + assertWithMessage(outputs2).that(exitCode2).isEqualTo(0); + assertThat(outputs2).contains("Pre-reboot Dexopt job cancelled"); + } + + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job finished. See logs for details"); + } + } + + @Test + public void testOnOtaStagedSyncCancelledByBrokenPipe() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any())) + .thenAnswer(invocation -> { + Semaphore dexoptCancelled = new Semaphore(0 /* permits */); + var cancellationSignal = invocation.<CancellationSignal>getArgument(2); + cancellationSignal.setOnCancelListener(() -> dexoptCancelled.release()); + assertThat(dexoptCancelled.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue(); + return new PreRebootResult(true /* success */); + }); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + assertThat(execution.getStdout().readLine()).contains("Job running..."); + + execution.closeStdin(); + + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job finished. See logs for details"); + } + } + + @Test + public void testOnOtaStagedAsyncLegacy() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(SystemProperties.getBoolean(eq("dalvik.vm.pr_dexopt_async_for_ota"), anyBoolean())) + .thenReturn(true); + + try (var execution = new CommandExecution( + createHandler(), "art", "on-ota-staged", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Pre-reboot Dexopt job scheduled"); + } + + when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters); + mPreRebootDexoptJob.waitForRunningJob(); + } + + @Test + public void testPrDexoptJobRunMainline() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID); + + when(mPreRebootDriver.run( + isNull() /* otaSlot */, anyBoolean() /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + try (var execution = + new CommandExecution(createHandler(), "art", "pr-dexopt-job", "--run")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job finished. See logs for details"); + } + } + + @Test + public void testPrDexoptJobRunOtaPermission() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID); + + try (var execution = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--run", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(-1); + assertThat(outputs).contains("Only root can specify '--slot'"); + } + } + + @Test + public void testPrDexoptJobRunOta() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + try (var execution = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--run", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Job finished. See logs for details"); + } + } + + @Test + public void testPrDexoptJobScheduleMainline() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID); + + try (var execution = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--schedule")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Pre-reboot Dexopt job scheduled"); + } + + when(mPreRebootDriver.run( + isNull() /* otaSlot */, anyBoolean() /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters); + mPreRebootDexoptJob.waitForRunningJob(); + } + + @Test + public void testPrDexoptJobScheduleOtaPermission() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID); + + try (var execution = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--schedule", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(-1); + assertThat(outputs).contains("Only root can specify '--slot'"); + } + } + + @Test + public void testPrDexoptJobScheduleOta() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + try (var execution = new CommandExecution( + createHandler(), "art", "pr-dexopt-job", "--schedule", "--slot", "_b")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Pre-reboot Dexopt job scheduled"); + } + + when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any())) + .thenReturn(new PreRebootResult(true /* success */)); + + mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters); + mPreRebootDexoptJob.waitForRunningJob(); + } + + @Test + public void testPrDexoptJobCancelJobNotFound() throws Exception { + when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID); + + try (var execution = + new CommandExecution(createHandler(), "art", "pr-dexopt-job", "--cancel")) { + int exitCode = execution.waitAndGetExitCode(); + String outputs = getOutputs(execution); + assertWithMessage(outputs).that(exitCode).isEqualTo(0); + assertThat(outputs).contains("Pre-reboot Dexopt job cancelled"); + } + } + + private ArtShellCommand createHandler() { + return new ArtShellCommand(mInjector); + } + + private String getOutputs(CommandExecution execution) { + return Stream.concat(execution.getStdout().lines(), execution.getStderr().lines()) + .collect(Collectors.joining("\n")); + } +} diff --git a/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java b/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java new file mode 100644 index 0000000000..7391145ecb --- /dev/null +++ b/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.art.testing; + +import static org.mockito.Mockito.mock; + +import android.os.Binder; +import android.os.ParcelFileDescriptor; + +import com.android.modules.utils.BasicShellCommandHandler; +import com.android.server.art.Utils; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.util.concurrent.CompletableFuture; + +/** A harness to test a {@link BasicShellCommandHandler}. */ +public class CommandExecution implements AutoCloseable { + private ParcelFileDescriptor[] mStdinPipe = null; + private ParcelFileDescriptor[] mStdoutPipe = null; + private ParcelFileDescriptor[] mStderrPipe = null; + private PrintWriter mStdinWriter = null; + private BufferedReader mStdoutReader = null; + private BufferedReader mStderrReader = null; + private CompletableFuture<Integer> mFuture = null; + + public CommandExecution(BasicShellCommandHandler commandHandler, String... args) + throws Exception { + try { + mStdinPipe = ParcelFileDescriptor.createPipe(); + mStdoutPipe = ParcelFileDescriptor.createPipe(); + mStderrPipe = ParcelFileDescriptor.createPipe(); + mStdinWriter = new PrintWriter(new FileOutputStream(mStdinPipe[1].getFileDescriptor())); + mStdoutReader = new BufferedReader( + new InputStreamReader(new FileInputStream(mStdoutPipe[0].getFileDescriptor()))); + mStderrReader = new BufferedReader( + new InputStreamReader(new FileInputStream(mStderrPipe[0].getFileDescriptor()))); + mFuture = CompletableFuture.supplyAsync(() -> exec(commandHandler, args)); + } catch (Exception e) { + close(); + throw e; + } + } + + private int exec(BasicShellCommandHandler commandHandler, String... args) { + int exitCode = commandHandler.exec(mock(Binder.class), mStdinPipe[0].getFileDescriptor(), + mStdoutPipe[1].getFileDescriptor(), mStderrPipe[1].getFileDescriptor(), args); + try { + mStdinPipe[0].close(); + mStdoutPipe[1].close(); + mStderrPipe[1].close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + return exitCode; + } + + public int waitAndGetExitCode() { + return Utils.getFuture(mFuture); + } + + public PrintWriter getStdin() { + return mStdinWriter; + } + + public void closeStdin() throws Exception { + mStdinWriter.close(); + mStdinPipe[1].close(); + } + + public BufferedReader getStdout() { + return mStdoutReader; + } + + public BufferedReader getStderr() { + return mStderrReader; + } + + @Override + public void close() throws Exception { + if (mStdinWriter != null) { + mStdinWriter.close(); + } + if (mStdoutReader != null) { + mStdoutReader.close(); + } + if (mStderrReader != null) { + mStderrReader.close(); + } + // Note that we still need to close the FDs after closing the streams. See the + // Android-specific warning at + // https://developer.android.com/reference/java/io/FileInputStream#FileInputStream(java.io.FileDescriptor) + if (mStdinPipe != null) { + mStdinPipe[0].close(); + mStdinPipe[1].close(); + } + if (mStdoutPipe != null) { + mStdoutPipe[0].close(); + mStdoutPipe[1].close(); + } + if (mStderrPipe != null) { + mStderrPipe[0].close(); + mStderrPipe[1].close(); + } + } +} |