blob: c7ebd52c49c555d1928ad57a10836355c905b575 [file] [log] [blame]
/*
* Copyright (C) 2019 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 android.jdwptunnel.cts;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
import com.android.tradefed.util.AbiUtils;
import com.android.tradefed.util.RunUtil;
import com.sun.jdi.Bootstrap;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.ThreadReference;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.VirtualMachineManager;
import com.sun.jdi.connect.AttachingConnector;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.event.ClassPrepareEvent;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.ClassPrepareRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.EventRequestManager;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.time.Instant;
import java.util.Map;
/**
* Host-side tests for setting up a JDWP connection to an app.
*
* <p>This test ensures that it is possible to attach a debugger to an app using 'adb' and perform
* at least some basic debugging actions.
*
* <p>The {@link DebuggableSampleDeviceActivity} is the activity we are debugging.
*
* <p>We will start that activity with 'wait-for-debugger', set a breakpoint on the first line of
* the {@code onCreate} method and wait for the breakpoint to be hit.
*
* <p>Run with: atest CtsJdwpTunnelHostTestCases
*/
@RunWith(DeviceJUnit4ClassRunner.class)
public class JdwpTunnelTest extends BaseHostJUnit4Test {
private static final String DEBUGGABLE_TEST_APP_PACKAGE_NAME =
"android.jdwptunnel.sampleapp.debuggable";
private static final String DEBUGGABLE_TEST_APP_ACTIVITY_CLASS_NAME =
"DebuggableSampleDeviceActivity";
private static final String PROFILEABLE_TEST_APP_PACKAGE_NAME =
"android.jdwptunnel.sampleapp.profileable";
private static final String PROFILEABLE_TEST_APP_ACTIVITY_CLASS_NAME =
"ProfileableSampleDeviceActivity";
private static final String DDMS_TEST_APP_PACKAGE_NAME = "android.jdwptunnel.sampleapp.ddms";
private static final String DDMS_TEST_APP_ACTIVITY_CLASS_NAME = "DdmsSampleDeviceActivity";
private ITestDevice mDevice;
@Before
public void setUp() throws Exception {
installPackage("CtsJdwpTunnelDebuggableSampleApp.apk");
installPackage("CtsJdwpTunnelProfileableSampleApp.apk");
installPackage("CtsJdwpTunnelDdmsSampleApp.apk");
mDevice = getDevice();
}
private void moveToHomeScreen() throws Exception {
// Wakeup the device if it is on the lockscreen and move it to the home screen.
mDevice.executeShellCommand("input keyevent KEYCODE_WAKEUP");
mDevice.executeShellCommand("wm dismiss-keyguard");
mDevice.executeShellCommand("input keyevent KEYCODE_HOME");
}
private VirtualMachine getDebuggerConnection(String port) throws Exception {
VirtualMachineManager vmm = Bootstrap.virtualMachineManager();
AttachingConnector conn =
vmm.attachingConnectors()
.stream()
.filter((x) -> x.transport().name().equals("dt_socket"))
.findFirst()
.orElseThrow(() -> new Error("Could not find dt_socket connector!"));
Map<String, Connector.Argument> params = conn.defaultArguments();
params.get("port").setValue(port);
params.get("hostname").setValue("localhost");
// Timeout after 1 minute
params.get("timeout").setValue("60000");
return conn.attach(params);
}
private String forwardJdwp(String pid) throws Exception {
// Try to have adb figure out the port number.
String result = mDevice.executeAdbCommand("forward", "tcp:0", "jdwp:" + pid);
if (result != null) {
return result.trim();
}
// We might be using an ancient adb. Try using a static port number instead. Number chosen
// arbitrarially. '15002' does not appear in any file as anything resembling a port number
// as far as I can tell.
final String port = "15002";
result = mDevice.executeAdbCommand("forward", "tcp:" + port, "jdwp:" + pid);
assertTrue(result != null);
return port;
}
private String startupForwarding(String packageName, String shortClassName, boolean debug)
throws Exception {
return startupForwarding(packageName, shortClassName, debug, false);
}
private String startupForwarding(String packageName, String shortClassName, boolean debug,
boolean startSuspended) throws Exception {
moveToHomeScreen();
new Thread(() -> {
try {
String cmd = "cmd activity start-activity " + (debug ? "-D" : "")
+ (startSuspended ? " --suspend" : "") + " -W -n " + packageName + "/."
+ shortClassName;
CLog.i(cmd);
mDevice.executeShellCommand(cmd);
} catch (DeviceNotAvailableException e) {
CLog.i("Failed to start activity for package: " + packageName, e);
}
}).start();
// Don't keep trying after a minute.
final Instant deadline = Instant.now().plusSeconds(60);
String pid = "";
while ((pid = mDevice.executeShellCommand("pidof " + packageName).trim()).equals("")) {
if (Instant.now().isAfter(deadline)) {
fail("Unable to find PID of " + packageName + " process!");
}
// Wait 1 second and try again.
RunUtil.getDefault().sleep(1000);
}
String port = forwardJdwp(pid);
assertTrue(!"".equals(port));
return port;
}
private VirtualMachine startupTest(String packageName, String shortClassName) throws Exception {
return getDebuggerConnection(startupForwarding(packageName, shortClassName, true, false));
}
private VirtualMachine startupTest(String packageName, String shortClassName, boolean debug,
boolean startSuspended) throws Exception {
return getDebuggerConnection(
startupForwarding(packageName, shortClassName, debug, startSuspended));
}
/**
* Tests that we can attach a debugger and perform basic debugging functions.
*
* We start the app with Wait-for-debugger. Wait for the ClassPrepare of the activity class and
* put and wait for a breakpoint on the onCreate function.
*
* TODO: We should expand this to more functions.
*/
private void testAttachDebugger(String packageName, String shortClassName)
throws DeviceNotAvailableException, Exception {
String fullClassName = packageName + "." + shortClassName;
VirtualMachine vm = startupTest(packageName, shortClassName);
EventRequestManager erm = vm.eventRequestManager();
try {
// Just pause the runtime so it won't get ahead of us while we setup everything.
vm.suspend();
// Overall timeout for this whole test. 2-minutes
final Instant deadline = Instant.now().plusSeconds(120);
// Check the test-activity class is not already loaded.
assertTrue(shortClassName + " is not yet loaded!",
vm.allClasses().stream().noneMatch(x -> x.name().equals(fullClassName)));
// Wait for the class to load.
ClassPrepareRequest cpr = erm.createClassPrepareRequest();
cpr.addClassFilter(fullClassName);
cpr.setSuspendPolicy(EventRequest.SUSPEND_ALL);
cpr.enable();
vm.resume();
ReferenceType activityType = null;
while (activityType == null) {
if (Instant.now().isAfter(deadline)) {
fail(fullClassName + " did not load within timeout!");
}
activityType = vm.eventQueue()
.remove()
.stream()
.filter(e -> cpr == e.request())
.findFirst()
.map(e -> ((ClassPrepareEvent) e).referenceType())
.orElse(null);
}
cpr.disable();
// Set a breakpoint on the onCreate method at the first line.
BreakpointRequest bpr = erm.createBreakpointRequest(
activityType.methodsByName("onCreate").get(0).allLineLocations().get(0));
bpr.setSuspendPolicy(EventRequest.SUSPEND_ALL);
bpr.enable();
vm.resume();
// Wait for the event.
while (!vm.eventQueue().remove().stream().anyMatch(e -> e.request() == bpr)) {
if (Instant.now().isAfter(deadline)) {
fail(fullClassName + " did hit onCreate breakpoint within timeout!");
}
}
bpr.disable();
vm.resume();
} finally {
// Always cleanup.
vm.dispose();
}
}
/**
* Tests that we can attach a debugger and perform basic debugging functions to a
* debuggable app.
*
* We start the app with Wait-for-debugger. Wait for the ClassPrepare of the activity
* class and put and wait for a breakpoint on the onCreate function.
*/
@Test
public void testAttachDebuggerToDebuggableApp() throws DeviceNotAvailableException, Exception {
testAttachDebugger(
DEBUGGABLE_TEST_APP_PACKAGE_NAME, DEBUGGABLE_TEST_APP_ACTIVITY_CLASS_NAME);
}
/**
* Tests that we CANNOT attach a debugger to a non-debuggable-but-profileable app.
*
* We test the attempt to attach the debuggable should fail on a user build device at the
* expected API call.
*/
@Test
public void testAttachDebuggerToProfileableApp() throws DeviceNotAvailableException, Exception {
java.io.IOException thrownException = null;
try {
testAttachDebugger(
PROFILEABLE_TEST_APP_PACKAGE_NAME, PROFILEABLE_TEST_APP_ACTIVITY_CLASS_NAME);
} catch (java.io.IOException e) {
thrownException = e;
}
// Jdwp is only enabled on eng builds or when persist.debug.dalvik.vm.jdwp.enabled is set.
// Check that we are able to attach a debugger in these cases.
String buildType = mDevice.getProperty("ro.build.type");
String enableJdwp = mDevice.getProperty("persist.debug.dalvik.vm.jdwp.enabled");
if (buildType.equals("eng") || (enableJdwp != null && enableJdwp.equals("1"))) {
assertNull(thrownException);
return;
}
// We are on a device that doesn't enable jdwp.
assertNotNull(thrownException);
if (thrownException != null) {
// Verify the exception is thrown from the "getDebuggerConnection" method in this test
// when it calls the "attach" method from class AttachingConnector or its subclass.
// In other words, the callstack is expected to look like
//
// at
// jdk.jdi/com.sun.tools.jdi.SocketAttachingConnector.attach
// (SocketAttachingConnector.java:83)
// at
// android.jdwptunnel.cts.JdwpTunnelTest.getDebuggerConnection
// (JdwpTunnelTest.java:96)
boolean thrownByGetDebuggerConnection = false;
StackTraceElement[] stack = thrownException.getStackTrace();
for (int i = 0; i < stack.length; i++) {
if (stack[i].getClassName().equals("android.jdwptunnel.cts.JdwpTunnelTest")
&& stack[i].getMethodName().equals("getDebuggerConnection")) {
thrownByGetDebuggerConnection = true;
assertTrue(i > 0);
assertEquals("attach", stack[i - 1].getMethodName());
break;
}
}
assertTrue(thrownByGetDebuggerConnection);
}
}
private String getDeviceBaseArch() throws Exception {
String abi = mDevice.executeShellCommand("getprop ro.product.cpu.abi").replace("\n", "");
return AbiUtils.getBaseArchForAbi(abi);
}
/**
* Tests that we don't get any DDMS messages before the handshake.
*
* <p>Since DDMS can send asynchronous replies it could race with the JDWP handshake. This could
* confuse clients. See bug: 178655046
*/
@Test
public void testDdmsWaitsForHandshake() throws DeviceNotAvailableException, Exception {
// Skip this test if not running on the device's native abi.
String testingArch = AbiUtils.getBaseArchForAbi(getAbi().getName());
String deviceArch = getDeviceBaseArch();
Assume.assumeTrue(testingArch.equals(deviceArch));
String port = startupForwarding(
DDMS_TEST_APP_PACKAGE_NAME, DDMS_TEST_APP_ACTIVITY_CLASS_NAME, false);
Socket sock = new Socket("localhost", Integer.decode(port).intValue());
OutputStream os = sock.getOutputStream();
// Let the test spin a bit. Try to lose any race with the app.
RunUtil.getDefault().sleep(1000);
String handshake = "JDWP-Handshake";
byte[] handshake_bytes = handshake.getBytes("US-ASCII");
os.write(handshake_bytes);
os.flush();
InputStream is = sock.getInputStream();
// Make sure we get the handshake first.
for (byte b : handshake_bytes) {
assertEquals(b, is.read());
}
// Don't require anything in particular next since lots of things can send
// DDMS packets. Since there is no debugger connection we can assert that
// it is a DDMS packet at least by looking for a negative id.
// Skip the length
is.skip(4);
// Data sent big-endian so first byte has sign bit.
assertTrue((is.read() & 0x80) == 0x80);
}
private boolean testThreadSuspensionState(VirtualMachine vm, boolean expected) {
for (ThreadReference tr : vm.allThreads()) {
boolean isSuspended = tr.isSuspended();
if (isSuspended != expected) {
return false;
}
}
return true;
}
private String dumpThreads(VirtualMachine vm) {
StringBuilder result = new StringBuilder();
for (ThreadReference tr : vm.allThreads()) {
result.append("Thread: '");
result.append(tr.name());
result.append("' isSuspended=");
result.append(tr.isSuspended());
result.append("\n");
}
return result.toString();
}
private void assertThreadSuspensionState(VirtualMachine vm, boolean expected)
throws InterruptedException {
// If the debugger connects too fast, the VM may not have had time to hit the
// suspension point. We try several times to remedy to this problem.
for (int i = 0; i < 4; i++) {
if (testThreadSuspensionState(vm, expected)) {
return;
}
Thread.sleep(1000);
}
fail("Threads are in unexpected state (expected=" + expected + ")\n" + dumpThreads(vm));
}
// App can be started "suspended" which means all its threads will be suspended shorty after
// zygote specializes.
@Test
public void testSuspendStartup() throws DeviceNotAvailableException, Exception {
VirtualMachine vm = startupTest(DEBUGGABLE_TEST_APP_PACKAGE_NAME,
DEBUGGABLE_TEST_APP_ACTIVITY_CLASS_NAME, true, true);
try {
// The VM was started in suspended mode.
assertThreadSuspensionState(vm, true);
// Let's go!
vm.resume();
assertThreadSuspensionState(vm, false);
} finally {
vm.dispose();
}
}
}