Impose max running time for apptests.

Fixes domokit/devtools#11.

R=qsr@chromium.org

Review URL: https://codereview.chromium.org/1266623002 .

Cr-Mirrored-From: https://github.com/domokit/mojo
Cr-Mirrored-Commit: 2b0afa8009aeb16304edff99d41a279f748aba54
diff --git a/devtoolslib/android_shell.py b/devtoolslib/android_shell.py
index d02ea0c..7a7436f 100644
--- a/devtoolslib/android_shell.py
+++ b/devtoolslib/android_shell.py
@@ -421,20 +421,40 @@
     p.wait()
     return None
 
-  def RunAndGetOutput(self, arguments):
-    """Runs the shell with given arguments until shell exits.
+  def RunAndGetOutput(self, arguments, timeout=None):
+    """Runs the shell with given arguments until shell exits and returns the
+    output.
 
     Args:
       arguments: list of arguments for the shell
+      timeout: maximum running time in seconds, after which the shell will be
+          terminated
 
     Returns:
-      A tuple of (return_code, output). |return_code| is the exit code returned
-      by the shell or None if the exit code cannot be retrieved. |output| is the
-      stdout mingled with the stderr produced by the shell.
+      A tuple of (return_code, output, did_time_out). |return_code| is the exit
+      code returned by the shell or None if the exit code cannot be retrieved.
+      |output| is the stdout mingled with the stderr produced by the shell.
+      |did_time_out| is True iff the shell was terminated because it exceeded
+      the |timeout| and False otherwise.
     """
-    (r, w) = os.pipe()
-    with os.fdopen(r, "r") as rf:
-      with os.fdopen(w, "w") as wf:
-        self.StartShell(arguments, wf, wf.close)
-        output = rf.read()
-        return None, output
+    class Results:
+      """Workaround for Python scoping rules that prevent assigning to variables
+      from the outer scope.
+      """
+      output = None
+
+    def do_run():
+      (r, w) = os.pipe()
+      with os.fdopen(r, "r") as rf:
+        with os.fdopen(w, "w") as wf:
+          self.StartShell(arguments, wf, wf.close)
+          Results.output = rf.read()
+
+    run_thread = threading.Thread(target=do_run)
+    run_thread.start()
+    run_thread.join(timeout)
+
+    if run_thread.is_alive():
+      self.StopShell()
+      return None, Results.output, True
+    return None, Results.output, False
diff --git a/devtoolslib/apptest.py b/devtoolslib/apptest.py
index 463c687..44c6043 100644
--- a/devtoolslib/apptest.py
+++ b/devtoolslib/apptest.py
@@ -31,7 +31,8 @@
   return result
 
 
-def run_apptest(shell, shell_args, apptest_url, apptest_args, output_test):
+def run_apptest(shell, shell_args, apptest_url, apptest_args, timeout,
+                output_test):
   """Runs shell with the given arguments, retrieves the output and applies
   |output_test| to determine if the run was successful.
 
@@ -52,7 +53,7 @@
 
   _logger.debug("Starting: " + command_line)
   start_time = time.time()
-  (exit_code, output) = shell.RunAndGetOutput(arguments)
+  (exit_code, output, did_time_out) = shell.RunAndGetOutput(arguments, timeout)
   run_time = time.time() - start_time
   _logger.debug("Completed: " + command_line)
 
@@ -60,10 +61,12 @@
   if run_time >= 3:
     _logger.info("Test took %.3f seconds: %s" % (run_time, command_line))
 
-  if exit_code or not output_test(output):
+  if exit_code or did_time_out or not output_test(output):
     print 'Failed test: %r' % command_line
     if exit_code:
       print '  due to shell exit code %d' % exit_code
+    elif did_time_out:
+      print '  due to exceeded timeout of %fs' % timeout
     else:
       print '  due to test results'
     print 72 * '-'
diff --git a/devtoolslib/apptest_dart.py b/devtoolslib/apptest_dart.py
index 06c329e..fe6131f 100644
--- a/devtoolslib/apptest_dart.py
+++ b/devtoolslib/apptest_dart.py
@@ -14,12 +14,13 @@
 
 SUCCESS_PATTERN = re.compile('^.+ .+: All tests passed!', re.MULTILINE)
 
+
 def _dart_apptest_output_test(output):
-  return SUCCESS_PATTERN.search(output) != None
+  return SUCCESS_PATTERN.search(output) is not None
 
 
 # TODO(erg): Support android, launched services and fixture isolation.
-def run_dart_apptest(shell, shell_args, apptest_url, apptest_args):
+def run_dart_apptest(shell, shell_args, apptest_url, apptest_args, timeout):
   """Runs a dart apptest.
 
   Args:
@@ -30,5 +31,5 @@
   Returns:
     True iff the test succeeded, False otherwise.
   """
-  return run_apptest(shell, shell_args, apptest_url, apptest_args,
+  return run_apptest(shell, shell_args, apptest_url, apptest_args, timeout,
                      _dart_apptest_output_test)
diff --git a/devtoolslib/apptest_gtest.py b/devtoolslib/apptest_gtest.py
index 5308f09..4515e89 100644
--- a/devtoolslib/apptest_gtest.py
+++ b/devtoolslib/apptest_gtest.py
@@ -25,7 +25,8 @@
   return True
 
 
-def run_gtest_apptest(shell, shell_args, apptest_url, apptest_args, isolate):
+def run_gtest_apptest(shell, shell_args, apptest_url, apptest_args, timeout,
+                      isolate):
   """Runs a gtest apptest.
 
   Args:
@@ -41,7 +42,7 @@
   """
 
   if not isolate:
-    return run_apptest(shell, shell_args, apptest_url, apptest_args,
+    return run_apptest(shell, shell_args, apptest_url, apptest_args, timeout,
                        _gtest_apptest_output_test)
 
   # List the apptest fixtures so they can be run independently for isolation.
@@ -54,7 +55,7 @@
   for fixture in fixtures:
     isolated_apptest_args = apptest_args + ["--gtest_filter=%s" % fixture]
     success = run_apptest(shell, shell_args, apptest_url, isolated_apptest_args,
-                          _gtest_apptest_output_test)
+                          timeout, _gtest_apptest_output_test)
 
     if not success:
       apptest_result = False
@@ -78,8 +79,8 @@
   arguments.append("--args-for=%s %s" % (apptest, "--gtest_list_tests"))
   arguments.append(apptest)
 
-  (exit_code, output) = shell.RunAndGetOutput(arguments)
-  if exit_code:
+  (exit_code, output, did_time_out) = shell.RunAndGetOutput(arguments)
+  if exit_code or did_time_out:
     command_line = "mojo_shell " + " ".join(["%r" % x for x in arguments])
     print "Failed to get test fixtures: %r" % command_line
     print 72 * '-'
diff --git a/devtoolslib/linux_shell.py b/devtoolslib/linux_shell.py
index 6e03e6f..a8c25df 100644
--- a/devtoolslib/linux_shell.py
+++ b/devtoolslib/linux_shell.py
@@ -3,6 +3,7 @@
 # found in the LICENSE file.
 
 import subprocess
+import threading
 
 from devtoolslib.shell import Shell
 from devtoolslib import http_server
@@ -61,19 +62,40 @@
     command = self.command_prefix + [self.executable_path] + arguments
     return subprocess.call(command, stderr=subprocess.STDOUT)
 
-  def RunAndGetOutput(self, arguments):
-    """Runs the shell with given arguments until shell exits.
+  def RunAndGetOutput(self, arguments, timeout=None):
+    """Runs the shell with given arguments until shell exits and returns the
+    output.
 
     Args:
       arguments: list of arguments for the shell
+      timeout: maximum running time in seconds, after which the shell will be
+          terminated
 
     Returns:
-      A tuple of (return_code, output). |return_code| is the exit code returned
-      by the shell or None if the exit code cannot be retrieved. |output| is the
-      stdout mingled with the stderr produced by the shell.
+      A tuple of (return_code, output, did_time_out). |return_code| is the exit
+      code returned by the shell or None if the exit code cannot be retrieved.
+      |output| is the stdout mingled with the stderr produced by the shell.
+      |did_time_out| is True iff the shell was terminated because it exceeded
+      the |timeout| and False otherwise.
     """
     command = self.command_prefix + [self.executable_path] + arguments
     p = subprocess.Popen(command, stdout=subprocess.PIPE,
                          stderr=subprocess.STDOUT)
-    (output, _) = p.communicate()
-    return p.returncode, output
+
+    class Results:
+      """Workaround for Python scoping rules that prevent assigning to variables
+      from the outer scope.
+      """
+      output = None
+
+    def do_run():
+      (Results.output, _) = p.communicate()
+
+    run_thread = threading.Thread(target=do_run)
+    run_thread.start()
+    run_thread.join(timeout)
+
+    if run_thread.is_alive():
+      p.terminate()
+      return p.returncode, Results.output, True
+    return p.returncode, Results.output, False
diff --git a/devtoolslib/shell.py b/devtoolslib/shell.py
index bb22ea4..16f4669 100644
--- a/devtoolslib/shell.py
+++ b/devtoolslib/shell.py
@@ -44,16 +44,20 @@
     """
     raise NotImplementedError()
 
-  def RunAndGetOutput(self, arguments):
+  def RunAndGetOutput(self, arguments, timeout=None):
     """Runs the shell with given arguments until shell exits and returns the
     output.
 
     Args:
       arguments: list of arguments for the shell
+      timeout: maximum running time in seconds, after which the shell will be
+          terminated
 
     Returns:
-      A tuple of (return_code, output). |return_code| is the exit code returned
-      by the shell or None if the exit code cannot be retrieved. |output| is the
-      stdout mingled with the stderr produced by the shell.
+      A tuple of (return_code, output, did_time_out). |return_code| is the exit
+      code returned by the shell or None if the exit code cannot be retrieved.
+      |output| is the stdout mingled with the stderr produced by the shell.
+      |did_time_out| is True iff the shell was terminated because it exceeded
+      the |timeout| and False otherwise.
     """
     raise NotImplementedError()
diff --git a/mojo_test b/mojo_test
index c824f10..d59bacc 100755
--- a/mojo_test
+++ b/mojo_test
@@ -36,6 +36,8 @@
    "test-args": ["--an_arg", "another_arg"],
    # Optional shell arguments.
    "shell-args": ["--some-flag-for-the-shell", "--another-flag"],
+   # Optional timeout in seconds, 60 by default.
+   "timeout": 120,
  }
 
 |test_list_file| may reference the |target_os| global that will be any of
@@ -77,6 +79,7 @@
     test_type = test_dict.get("type", "gtest")
     test_args = test_dict.get("test-args", [])
     shell_args = test_dict.get("shell-args", []) + common_shell_args
+    timeout = test_dict.get("timeout", 60)
 
     _logger.info("Will start: %s" % test_name)
     print "Running %s...." % test_name,
@@ -84,13 +87,15 @@
 
     if test_type == "dart":
       apptest_result = apptest_dart.run_dart_apptest(shell, shell_args, test,
-                                                     test_args)
+                                                     test_args, timeout)
     elif test_type == "gtest":
       apptest_result = apptest_gtest.run_gtest_apptest(shell, shell_args, test,
-                                                       test_args, False)
+                                                       test_args, timeout,
+                                                       False)
     elif test_type == "gtest_isolated":
       apptest_result = apptest_gtest.run_gtest_apptest(shell, shell_args, test,
-                                                       test_args, True)
+                                                       test_args, timeout,
+                                                       True)
     else:
       apptest_result = False
       print "Unrecognized test type in %r" % test_dict