Add API to selectively redirect VM logs to apps
Apps now should call setVmOutputsCaptured() to receive console and log
streams.
Bug: 238593451
Test: atest MicrodroidTestApp MicrodroidHostTestCases
Test: build, install, and run MicrodroidDemoApp
Change-Id: Ieb0719b2c000a661edf7ad7663d030104a153e29
diff --git a/demo/java/com/android/microdroid/demo/MainActivity.java b/demo/java/com/android/microdroid/demo/MainActivity.java
index c3fa55f..aab4148 100644
--- a/demo/java/com/android/microdroid/demo/MainActivity.java
+++ b/demo/java/com/android/microdroid/demo/MainActivity.java
@@ -246,6 +246,7 @@
builder.setProtectedVm(true);
if (debug) {
builder.setDebugLevel(VirtualMachineConfig.DEBUG_LEVEL_FULL);
+ builder.setVmOutputCaptured(true);
}
VirtualMachineConfig config = builder.build();
VirtualMachineManager vmm =
@@ -261,10 +262,12 @@
mVirtualMachine.setCallback(Executors.newSingleThreadExecutor(), callback);
mStatus.postValue(mVirtualMachine.getStatus());
- InputStream console = mVirtualMachine.getConsoleOutput();
- InputStream log = mVirtualMachine.getLogOutput();
- mExecutorService.execute(new Reader("console", mConsoleOutput, console));
- mExecutorService.execute(new Reader("log", mLogOutput, log));
+ if (debug) {
+ InputStream console = mVirtualMachine.getConsoleOutput();
+ InputStream log = mVirtualMachine.getLogOutput();
+ mExecutorService.execute(new Reader("console", mConsoleOutput, console));
+ mExecutorService.execute(new Reader("log", mLogOutput, log));
+ }
} catch (VirtualMachineException e) {
throw new RuntimeException(e);
}
diff --git a/javalib/api/system-current.txt b/javalib/api/system-current.txt
index 71bdf13..1977321 100644
--- a/javalib/api/system-current.txt
+++ b/javalib/api/system-current.txt
@@ -65,6 +65,7 @@
method public boolean isCompatibleWith(@NonNull android.system.virtualmachine.VirtualMachineConfig);
method public boolean isEncryptedStorageEnabled();
method public boolean isProtectedVm();
+ method public boolean isVmOutputCaptured();
field public static final int DEBUG_LEVEL_FULL = 1; // 0x1
field public static final int DEBUG_LEVEL_NONE = 0; // 0x0
}
@@ -79,6 +80,7 @@
method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setNumCpus(@IntRange(from=1) int);
method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setPayloadBinaryName(@NonNull String);
method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setProtectedVm(boolean);
+ method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setVmOutputCaptured(boolean);
}
public final class VirtualMachineDescriptor implements android.os.Parcelable {
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachine.java b/javalib/src/android/system/virtualmachine/VirtualMachine.java
index b57cb5e..1f0c8ea 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachine.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachine.java
@@ -293,6 +293,7 @@
/** Lock protecting callbacks. */
private final Object mCallbackLock = new Object();
+ private final boolean mVmOutputCaptured;
/** The configuration that is currently associated with this VM. */
@GuardedBy("mLock")
@@ -371,6 +372,7 @@
? new File(thisVmDir, ENCRYPTED_STORE_FILE)
: null;
+ mVmOutputCaptured = config.isVmOutputCaptured();
}
/**
@@ -772,7 +774,9 @@
IVirtualizationService service = mVirtualizationService.connect();
try {
- createVmPipes();
+ if (mVmOutputCaptured) {
+ createVmPipes();
+ }
VirtualMachineAppConfig appConfig = getConfig().toVsConfig();
appConfig.name = mName;
@@ -890,17 +894,26 @@
}
/**
- * Returns the stream object representing the console output from the virtual machine.
+ * Returns the stream object representing the console output from the virtual machine. The
+ * console output is only available if the {@link VirtualMachineConfig} specifies that it should
+ * be {@linkplain VirtualMachineConfig#isVmOutputCaptured captured}.
+ *
+ * <p>If you turn on output capture, you must consume data from {@code getConsoleOutput} -
+ * because otherwise the code in the VM may get blocked when the pipe buffer fills up.
*
* <p>NOTE: This method may block and should not be called on the main thread.
*
- * @throws VirtualMachineException if the stream could not be created.
+ * @throws VirtualMachineException if the stream could not be created, or capturing is turned
+ * off.
* @hide
*/
@SystemApi
@WorkerThread
@NonNull
public InputStream getConsoleOutput() throws VirtualMachineException {
+ if (!mVmOutputCaptured) {
+ throw new VirtualMachineException("Capturing vm outputs is turned off");
+ }
synchronized (mLock) {
createVmPipes();
return new FileInputStream(mConsoleReader.getFileDescriptor());
@@ -908,17 +921,26 @@
}
/**
- * Returns the stream object representing the log output from the virtual machine.
+ * Returns the stream object representing the log output from the virtual machine. The log
+ * output is only available if the VirtualMachineConfig specifies that it should be {@linkplain
+ * VirtualMachineConfig#isVmOutputCaptured captured}.
+ *
+ * <p>If you turn on output capture, you must consume data from {@code getLogOutput} - because
+ * otherwise the code in the VM may get blocked when the pipe buffer fills up.
*
* <p>NOTE: This method may block and should not be called on the main thread.
*
- * @throws VirtualMachineException if the stream could not be created.
+ * @throws VirtualMachineException if the stream could not be created, or capturing is turned
+ * off.
* @hide
*/
@SystemApi
@WorkerThread
@NonNull
public InputStream getLogOutput() throws VirtualMachineException {
+ if (!mVmOutputCaptured) {
+ throw new VirtualMachineException("Capturing vm outputs is turned off");
+ }
synchronized (mLock) {
createVmPipes();
return new FileInputStream(mLogReader.getFileDescriptor());
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
index d9fc70c..f5c3cd2 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
@@ -58,7 +58,7 @@
private static final String[] EMPTY_STRING_ARRAY = {};
// These define the schema of the config file persisted on disk.
- private static final int VERSION = 2;
+ private static final int VERSION = 3;
private static final String KEY_VERSION = "version";
private static final String KEY_APKPATH = "apkPath";
private static final String KEY_PAYLOADCONFIGPATH = "payloadConfigPath";
@@ -68,6 +68,7 @@
private static final String KEY_MEMORY_MIB = "memoryMib";
private static final String KEY_NUM_CPUS = "numCpus";
private static final String KEY_ENCRYPTED_STORAGE_KIB = "encryptedStorageKib";
+ private static final String KEY_VM_OUTPUT_CAPTURED = "vmOutputCaptured";
/** @hide */
@Retention(RetentionPolicy.SOURCE)
@@ -124,6 +125,9 @@
/** The size of storage in KiB. 0 indicates that encryptedStorage is not required */
private final long mEncryptedStorageKib;
+ /** Whether the app can read console and log output. */
+ private final boolean mVmOutputCaptured;
+
private VirtualMachineConfig(
@NonNull String apkPath,
@Nullable String payloadConfigPath,
@@ -132,7 +136,8 @@
boolean protectedVm,
int memoryMib,
int numCpus,
- long encryptedStorageKib) {
+ long encryptedStorageKib,
+ boolean vmOutputCaptured) {
// This is only called from Builder.build(); the builder handles parameter validation.
mApkPath = apkPath;
mPayloadConfigPath = payloadConfigPath;
@@ -142,6 +147,7 @@
mMemoryMib = memoryMib;
mNumCpus = numCpus;
mEncryptedStorageKib = encryptedStorageKib;
+ mVmOutputCaptured = vmOutputCaptured;
}
/** Loads a config from a file. */
@@ -210,6 +216,7 @@
if (encryptedStorageKib != 0) {
builder.setEncryptedStorageKib(encryptedStorageKib);
}
+ builder.setVmOutputCaptured(b.getBoolean(KEY_VM_OUTPUT_CAPTURED));
return builder.build();
}
@@ -239,6 +246,7 @@
if (mEncryptedStorageKib > 0) {
b.putLong(KEY_ENCRYPTED_STORAGE_KIB, mEncryptedStorageKib);
}
+ b.putBoolean(KEY_VM_OUTPUT_CAPTURED, mVmOutputCaptured);
b.writeToStream(output);
}
@@ -346,6 +354,18 @@
}
/**
+ * Returns whether the app can read the VM console or log output. If not, the VM output is
+ * automatically forwarded to the host logcat.
+ *
+ * @see #setVmOutputCaptured
+ * @hide
+ */
+ @SystemApi
+ public boolean isVmOutputCaptured() {
+ return mVmOutputCaptured;
+ }
+
+ /**
* Tests if this config is compatible with other config. Being compatible means that the configs
* can be interchangeably used for the same virtual machine; they do not change the VM identity
* or secrets. Such changes include varying the number of CPUs or the size of the RAM. Changes
@@ -360,6 +380,7 @@
return this.mDebugLevel == other.mDebugLevel
&& this.mProtectedVm == other.mProtectedVm
&& this.mEncryptedStorageKib == other.mEncryptedStorageKib
+ && this.mVmOutputCaptured == other.mVmOutputCaptured
&& Objects.equals(this.mPayloadConfigPath, other.mPayloadConfigPath)
&& Objects.equals(this.mPayloadBinaryName, other.mPayloadBinaryName)
&& this.mApkPath.equals(other.mApkPath);
@@ -417,6 +438,7 @@
private int mMemoryMib;
private int mNumCpus = 1;
private long mEncryptedStorageKib;
+ private boolean mVmOutputCaptured = false;
/**
* Creates a builder for the given context.
@@ -469,6 +491,10 @@
throw new IllegalStateException("setProtectedVm must be called explicitly");
}
+ if (mVmOutputCaptured && mDebugLevel != DEBUG_LEVEL_FULL) {
+ throw new IllegalStateException("debug level must be FULL to capture output");
+ }
+
return new VirtualMachineConfig(
apkPath,
mPayloadConfigPath,
@@ -477,7 +503,8 @@
mProtectedVm,
mMemoryMib,
mNumCpus,
- mEncryptedStorageKib);
+ mEncryptedStorageKib,
+ mVmOutputCaptured);
}
/**
@@ -654,5 +681,31 @@
mEncryptedStorageKib = encryptedStorageKib;
return this;
}
+
+ /**
+ * Sets whether to allow the app to read the VM outputs (console / log). Default is {@code
+ * false}.
+ *
+ * <p>By default, console and log outputs of a {@linkplain #setDebugLevel debuggable} VM are
+ * automatically forwarded to the host logcat. Setting this as {@code true} will allow the
+ * app to directly read {@linkplain VirtualMachine#getConsoleOutput console output} and
+ * {@linkplain VirtualMachine#getLogOutput log output}, instead of forwarding them to the
+ * host logcat.
+ *
+ * <p>If you turn on output capture, you must consume data from {@link
+ * VirtualMachine#getConsoleOutput} and {@link VirtualMachine#getLogOutput} - because
+ * otherwise the code in the VM may get blocked when the pipe buffer fills up.
+ *
+ * <p>The {@linkplain #setDebugLevel debug level} must be {@link #DEBUG_LEVEL_FULL} to be
+ * set as true.
+ *
+ * @hide
+ */
+ @SystemApi
+ @NonNull
+ public Builder setVmOutputCaptured(boolean captured) {
+ mVmOutputCaptured = captured;
+ return this;
+ }
}
}
diff --git a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
index 9e4b228..40114fd 100644
--- a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
+++ b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
@@ -211,6 +211,7 @@
newVmConfigBuilder()
.setPayloadBinaryName("MicrodroidIdleNativeLib.so")
.setDebugLevel(DEBUG_LEVEL_FULL)
+ .setVmOutputCaptured(true)
.setMemoryMib(256)
.build();
forceCreateNewVirtualMachine("test_vm_boot_time_debug", normalConfig);
diff --git a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
index d762310..e8a36ce 100644
--- a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
+++ b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
@@ -212,9 +212,11 @@
throws VirtualMachineException, InterruptedException {
vm.setCallback(mExecutorService, this);
vm.run();
- logVmOutputAndMonitorBootEvents(
- logTag, vm.getConsoleOutput(), "Console", mConsoleOutput);
- logVmOutput(logTag, vm.getLogOutput(), "Log", mLogOutput);
+ if (vm.getConfig().isVmOutputCaptured()) {
+ logVmOutputAndMonitorBootEvents(
+ logTag, vm.getConsoleOutput(), "Console", mConsoleOutput);
+ logVmOutput(logTag, vm.getLogOutput(), "Log", mLogOutput);
+ }
mExecutorService.awaitTermination(300, TimeUnit.SECONDS);
}
diff --git a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
index d8e74f7..5c3be5f 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
@@ -153,6 +153,7 @@
.setPayloadBinaryName("MicrodroidTestNativeLib.so")
.setMemoryMib(minMemoryRequired())
.setDebugLevel(DEBUG_LEVEL_NONE)
+ .setVmOutputCaptured(false)
.build();
VirtualMachine vm = forceCreateNewVirtualMachine("test_vm", config);
@@ -332,18 +333,21 @@
assertThat(minimal.isProtectedVm()).isEqualTo(isProtectedVm());
assertThat(minimal.isEncryptedStorageEnabled()).isFalse();
assertThat(minimal.getEncryptedStorageKib()).isEqualTo(0);
+ assertThat(minimal.isVmOutputCaptured()).isEqualTo(false);
// Maximal has everything that can be set to some non-default value. (And has different
// values than minimal for the required fields.)
int maxCpus = Runtime.getRuntime().availableProcessors();
VirtualMachineConfig.Builder maximalBuilder =
- newVmConfigBuilder()
+ new VirtualMachineConfig.Builder(getContext())
+ .setProtectedVm(mProtectedVm)
.setPayloadConfigPath("config/path")
.setApkPath("/apk/path")
.setNumCpus(maxCpus)
.setDebugLevel(DEBUG_LEVEL_FULL)
.setMemoryMib(42)
- .setEncryptedStorageKib(1024);
+ .setEncryptedStorageKib(1024)
+ .setVmOutputCaptured(true);
VirtualMachineConfig maximal = maximalBuilder.build();
assertThat(maximal.getApkPath()).isEqualTo("/apk/path");
@@ -355,6 +359,7 @@
assertThat(maximal.isProtectedVm()).isEqualTo(isProtectedVm());
assertThat(maximal.isEncryptedStorageEnabled()).isTrue();
assertThat(maximal.getEncryptedStorageKib()).isEqualTo(1024);
+ assertThat(maximal.isVmOutputCaptured()).isEqualTo(true);
assertThat(minimal.isCompatibleWith(maximal)).isFalse();
assertThat(minimal.isCompatibleWith(minimal)).isTrue();
@@ -392,6 +397,14 @@
new VirtualMachineConfig.Builder(getContext()).setPayloadBinaryName("binary.so");
e = assertThrows(IllegalStateException.class, () -> protectedNotSet.build());
assertThat(e).hasMessageThat().contains("setProtectedVm must be called");
+
+ VirtualMachineConfig.Builder captureOutputOnNonDebuggable =
+ newVmConfigBuilder()
+ .setPayloadBinaryName("binary.so")
+ .setDebugLevel(VirtualMachineConfig.DEBUG_LEVEL_NONE)
+ .setVmOutputCaptured(true);
+ e = assertThrows(IllegalStateException.class, () -> captureOutputOnNonDebuggable.build());
+ assertThat(e).hasMessageThat().contains("debug level must be FULL to capture output");
}
private VirtualMachineConfig.Builder newBaselineBuilder() {
@@ -431,6 +444,11 @@
assertConfigCompatible(baseline, newBaselineBuilder().setApkPath("/different")).isFalse();
assertConfigCompatible(baseline, newBaselineBuilder().setEncryptedStorageKib(100))
.isFalse();
+
+ VirtualMachineConfig.Builder debuggableBuilder =
+ newBaselineBuilder().setDebugLevel(DEBUG_LEVEL_FULL);
+ VirtualMachineConfig debuggable = debuggableBuilder.build();
+ assertConfigCompatible(debuggable, debuggableBuilder.setVmOutputCaptured(true)).isFalse();
}
private BooleanSubject assertConfigCompatible(
@@ -662,6 +680,7 @@
.setPayloadBinaryName("MicrodroidTestNativeLib.so")
.setMemoryMib(memMib)
.setDebugLevel(DEBUG_LEVEL_NONE)
+ .setVmOutputCaptured(false)
.build();
VirtualMachine vm = forceCreateNewVirtualMachine("low_mem", lowMemConfig);
final CompletableFuture<Boolean> onPayloadReadyExecuted = new CompletableFuture<>();
@@ -704,7 +723,8 @@
VirtualMachineConfig.Builder builder =
newVmConfigBuilder()
.setPayloadBinaryName("MicrodroidTestNativeLib.so")
- .setDebugLevel(fromLevel);
+ .setDebugLevel(fromLevel)
+ .setVmOutputCaptured(false);
VirtualMachineConfig normalConfig = builder.build();
forceCreateNewVirtualMachine("test_vm", normalConfig);
assertThat(tryBootVm(TAG, "test_vm").payloadStarted).isTrue();
@@ -1031,9 +1051,12 @@
@Test
public void bootFailsWhenBinaryIsMissingEntryFunction() throws Exception {
- VirtualMachineConfig.Builder builder =
- newVmConfigBuilder().setPayloadBinaryName("MicrodroidEmptyNativeLib.so");
- VirtualMachineConfig normalConfig = builder.setDebugLevel(DEBUG_LEVEL_FULL).build();
+ VirtualMachineConfig normalConfig =
+ newVmConfigBuilder()
+ .setPayloadBinaryName("MicrodroidEmptyNativeLib.so")
+ .setDebugLevel(DEBUG_LEVEL_FULL)
+ .setVmOutputCaptured(true)
+ .build();
VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_missing_entry", normalConfig);
assertThatPayloadFailsDueTo(vm, "Failed to find entrypoint");
@@ -1041,9 +1064,12 @@
@Test
public void bootFailsWhenBinaryTriesToLinkAgainstPrivateLibs() throws Exception {
- VirtualMachineConfig.Builder builder =
- newVmConfigBuilder().setPayloadBinaryName("MicrodroidPrivateLinkingNativeLib.so");
- VirtualMachineConfig normalConfig = builder.setDebugLevel(DEBUG_LEVEL_FULL).build();
+ VirtualMachineConfig normalConfig =
+ newVmConfigBuilder()
+ .setPayloadBinaryName("MicrodroidPrivateLinkingNativeLib.so")
+ .setDebugLevel(DEBUG_LEVEL_FULL)
+ .setVmOutputCaptured(true)
+ .build();
VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_private_linking", normalConfig);
assertThatPayloadFailsDueTo(vm, "Failed to dlopen");
@@ -1233,6 +1259,29 @@
assertThat(testResults.mFileContent).isEqualTo("Hello, I am a file!");
}
+ @Test
+ public void outputsShouldBeExplicitlyForwarded() throws Exception {
+ assumeSupportedKernel();
+
+ final VirtualMachineConfig vmConfig =
+ new VirtualMachineConfig.Builder(ApplicationProvider.getApplicationContext())
+ .setProtectedVm(mProtectedVm)
+ .setPayloadBinaryName("MicrodroidTestNativeLib.so")
+ .setDebugLevel(DEBUG_LEVEL_FULL)
+ .build();
+ final VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_forward_log", vmConfig);
+ vm.run();
+
+ try {
+ assertThrowsVmExceptionContaining(
+ () -> vm.getConsoleOutput(), "Capturing vm outputs is turned off");
+ assertThrowsVmExceptionContaining(
+ () -> vm.getLogOutput(), "Capturing vm outputs is turned off");
+ } finally {
+ vm.stop();
+ }
+ }
+
private void assertFileContentsAreEqualInTwoVms(String fileName, String vmName1, String vmName2)
throws IOException {
File file1 = getVmFile(vmName1, fileName);