| /* |
| * 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(); |
| } |
| } |
| } |