Add a leaked thread checker

Add a checker for unexpected leaked threads. This could
help identify tests that do not clean their threads.

Test: unit tests
run cts -m CtsSecurityHostTestCases --log-level-display verbose
Bug: 113863482

Change-Id: I9dea36c8d21cb8ccb173d48637d97251bed4bfa8
diff --git a/src/com/android/tradefed/device/BackgroundDeviceAction.java b/src/com/android/tradefed/device/BackgroundDeviceAction.java
index 5fe79af..0c54267 100644
--- a/src/com/android/tradefed/device/BackgroundDeviceAction.java
+++ b/src/com/android/tradefed/device/BackgroundDeviceAction.java
@@ -41,6 +41,7 @@
  * </ul>
  */
 public class BackgroundDeviceAction extends Thread {
+    public static final String BACKGROUND_DEVICE_ACTION = "BackgroundDeviceAction";
     private static final long ONLINE_POLL_INTERVAL_MS = 10 * 1000;
     private IShellOutputReceiver mReceiver;
     private ITestDevice mTestDevice;
@@ -60,7 +61,7 @@
      */
     public BackgroundDeviceAction(String command, String descriptor, ITestDevice device,
             IShellOutputReceiver receiver, int startDelay) {
-        super("BackgroundDeviceAction-" + command);
+        super(BACKGROUND_DEVICE_ACTION + "-" + command);
         mCommand = command;
         mDescriptor = descriptor;
         mTestDevice = device;
diff --git a/src/com/android/tradefed/suite/checker/DeviceSettingChecker.java b/src/com/android/tradefed/suite/checker/DeviceSettingChecker.java
index 5dbce36..4cc8961 100644
--- a/src/com/android/tradefed/suite/checker/DeviceSettingChecker.java
+++ b/src/com/android/tradefed/suite/checker/DeviceSettingChecker.java
@@ -165,7 +165,7 @@
      *
      * @param device The {@link ITestDevice}
      * @param namespace namespace of the setting.
-     * @return
+     * @return a Map of the settings for a namespace
      * @throws DeviceNotAvailableException
      */
     private Map<String, String> getSettingsHelper(ITestDevice device, String namespace)
diff --git a/src/com/android/tradefed/suite/checker/LeakedThreadStatusChecker.java b/src/com/android/tradefed/suite/checker/LeakedThreadStatusChecker.java
new file mode 100644
index 0000000..88f948f
--- /dev/null
+++ b/src/com/android/tradefed/suite/checker/LeakedThreadStatusChecker.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2018 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.tradefed.suite.checker;
+
+import com.android.tradefed.device.BackgroundDeviceAction;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.suite.checker.StatusCheckerResult.CheckStatus;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Status checker to ensure a module does not leak a running Thread.
+ *
+ * <p>TODO: Consider filtering the expected threads: main invocation and background threads and only
+ * return the list of unexpected threads.
+ */
+public class LeakedThreadStatusChecker implements ISystemStatusChecker {
+
+    private static final int EXPECTED_THREAD_COUNT = 1;
+
+    @Override
+    public StatusCheckerResult postExecutionCheck(ITestDevice device)
+            throws DeviceNotAvailableException {
+        StatusCheckerResult result = new StatusCheckerResult(CheckStatus.SUCCESS);
+
+        int numThread = Thread.currentThread().getThreadGroup().activeCount();
+        if (numThread == EXPECTED_THREAD_COUNT) {
+            // No stray thread detected at the end of invocation
+            return result;
+        }
+        Thread[] listThreads = new Thread[numThread];
+        Thread.currentThread().getThreadGroup().enumerate(listThreads);
+
+        List<Thread> arrayThread = new ArrayList<>(Arrays.asList(listThreads));
+        // We might have a device background action running for logcat, exclude it from the count
+        // if it's one of the two threads.
+        if (numThread == 2) {
+            for (Thread t : arrayThread) {
+                if (t.getName().contains(BackgroundDeviceAction.BACKGROUND_DEVICE_ACTION)) {
+                    return result;
+                }
+            }
+        }
+
+        result.setStatus(CheckStatus.FAILED);
+        String message =
+                String.format("We have %s threads instead of 1. List: %s", numThread, arrayThread);
+        result.setErrorMessage(message);
+        CLog.e(message);
+        return result;
+    }
+}
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index ded4214..c0946fb 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -137,6 +137,7 @@
 import com.android.tradefed.suite.checker.ActivityStatusCheckerTest;
 import com.android.tradefed.suite.checker.DeviceSettingCheckerTest;
 import com.android.tradefed.suite.checker.KeyguardStatusCheckerTest;
+import com.android.tradefed.suite.checker.LeakedThreadStatusCheckerTest;
 import com.android.tradefed.suite.checker.SystemServerFileDescriptorCheckerTest;
 import com.android.tradefed.suite.checker.SystemServerStatusCheckerTest;
 import com.android.tradefed.suite.checker.TimeStatusCheckerTest;
@@ -503,6 +504,7 @@
     ActivityStatusCheckerTest.class,
     DeviceSettingCheckerTest.class,
     KeyguardStatusCheckerTest.class,
+    LeakedThreadStatusCheckerTest.class,
     SystemServerFileDescriptorCheckerTest.class,
     SystemServerStatusCheckerTest.class,
     TimeStatusCheckerTest.class,
diff --git a/tests/src/com/android/tradefed/suite/checker/LeakedThreadStatusCheckerTest.java b/tests/src/com/android/tradefed/suite/checker/LeakedThreadStatusCheckerTest.java
new file mode 100644
index 0000000..d629371
--- /dev/null
+++ b/tests/src/com/android/tradefed/suite/checker/LeakedThreadStatusCheckerTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2018 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.tradefed.suite.checker;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.device.BackgroundDeviceAction;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.suite.checker.StatusCheckerResult.CheckStatus;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link LeakedThreadStatusChecker} */
+@RunWith(JUnit4.class)
+public class LeakedThreadStatusCheckerTest {
+
+    private LeakedThreadStatusChecker mChecker;
+    private ThreadGroup mGroup;
+
+    @Before
+    public void setUp() {
+        mChecker = new LeakedThreadStatusChecker();
+        mGroup = new ThreadGroup("LeakedThreadStatusCheckerTest");
+    }
+
+    @Test
+    public void testNoLeakedThread() throws Exception {
+        TestInThread thread = new TestInThread();
+        thread.start();
+        thread.join();
+        StatusCheckerResult result = thread.mResult;
+        assertEquals(CheckStatus.SUCCESS, result.getStatus());
+        assertNull(result.getErrorMessage());
+    }
+
+    @Test
+    public void testLeakedThread() throws Exception {
+        LeakedTestThread leakThread =
+                new LeakedTestThread("LeakedTestThread#LeakedThreadStatusCheckerTest");
+        leakThread.start();
+        TestInThread thread = new TestInThread();
+        thread.start();
+        thread.join();
+        leakThread.mIsCancelled = true;
+        try {
+            StatusCheckerResult result = thread.mResult;
+            assertEquals(CheckStatus.FAILED, result.getStatus());
+            assertTrue(result.getErrorMessage().contains("We have 2 threads instead of 1."));
+        } finally {
+            leakThread.join();
+        }
+    }
+
+    /** Test that background action threads are ignored. */
+    @Test
+    public void testLeakedThread_background() throws Exception {
+        LeakedTestThread leakThread =
+                new LeakedTestThread(BackgroundDeviceAction.BACKGROUND_DEVICE_ACTION + "-logcat");
+        leakThread.start();
+        TestInThread thread = new TestInThread();
+        thread.start();
+        thread.join();
+        leakThread.mIsCancelled = true;
+        try {
+            StatusCheckerResult result = thread.mResult;
+            assertEquals(CheckStatus.SUCCESS, result.getStatus());
+            assertNull(result.getErrorMessage());
+        } finally {
+            leakThread.join();
+        }
+    }
+
+    /** Run the check in a thread in its own group to avoid interference from other tests or IDE. */
+    private class TestInThread extends Thread {
+
+        public StatusCheckerResult mResult = null;
+
+        public TestInThread() {
+            super(mGroup, "TestInThread#LeakedThreadStatusCheckerTest");
+        }
+
+        @Override
+        public void run() {
+            try {
+                mResult = mChecker.postExecutionCheck(null);
+            } catch (DeviceNotAvailableException e) {
+                // Ignore
+            }
+        }
+    }
+
+    /** A thread that runs until cancelled. */
+    private class LeakedTestThread extends Thread {
+
+        public boolean mIsCancelled = false;
+
+        public LeakedTestThread(String name) {
+            super(mGroup, name);
+        }
+
+        @Override
+        public void run() {
+            while (!mIsCancelled) {
+                try {
+                    Thread.sleep(50);
+                } catch (InterruptedException e) {
+                    // Ignore
+                }
+            }
+        }
+    }
+}