1# Copyright 2017 the V8 project authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5# for py2/py3 compatibility
6from __future__ import print_function
7
8from contextlib import contextmanager
9import os
10import re
11import signal
12import subprocess
13import sys
14import threading
15import time
16
17from ..local.android import (
18    android_driver, CommandFailedException, TimeoutException)
19from ..local import utils
20from ..objects import output
21
22
23BASE_DIR = os.path.normpath(
24    os.path.join(os.path.dirname(os.path.abspath(__file__)), '..' , '..', '..'))
25
26SEM_INVALID_VALUE = -1
27SEM_NOGPFAULTERRORBOX = 0x0002  # Microsoft Platform SDK WinBase.h
28
29
30def setup_testing():
31  """For testing only: We use threading under the hood instead of
32  multiprocessing to make coverage work. Signal handling is only supported
33  in the main thread, so we disable it for testing.
34  """
35  signal.signal = lambda *_: None
36
37
38class AbortException(Exception):
39  """Indicates early abort on SIGINT, SIGTERM or internal hard timeout."""
40  pass
41
42
43@contextmanager
44def handle_sigterm(process, abort_fun, enabled):
45  """Call`abort_fun` on sigterm and restore previous handler to prevent
46  erroneous termination of an already terminated process.
47
48  Args:
49    process: The process to terminate.
50    abort_fun: Function taking two parameters: the process to terminate and
51        an array with a boolean for storing if an abort occured.
52    enabled: If False, this wrapper will be a no-op.
53  """
54  # Variable to communicate with the signal handler.
55  abort_occured = [False]
56  def handler(signum, frame):
57    abort_fun(process, abort_occured)
58
59  if enabled:
60    previous = signal.signal(signal.SIGTERM, handler)
61  try:
62    yield
63  finally:
64    if enabled:
65      signal.signal(signal.SIGTERM, previous)
66
67  if abort_occured[0]:
68    raise AbortException()
69
70
71class BaseCommand(object):
72  def __init__(self, shell, args=None, cmd_prefix=None, timeout=60, env=None,
73               verbose=False, resources_func=None, handle_sigterm=False):
74    """Initialize the command.
75
76    Args:
77      shell: The name of the executable (e.g. d8).
78      args: List of args to pass to the executable.
79      cmd_prefix: Prefix of command (e.g. a wrapper script).
80      timeout: Timeout in seconds.
81      env: Environment dict for execution.
82      verbose: Print additional output.
83      resources_func: Callable, returning all test files needed by this command.
84      handle_sigterm: Flag indicating if SIGTERM will be used to terminate the
85          underlying process. Should not be used from the main thread, e.g. when
86          using a command to list tests.
87    """
88    assert(timeout > 0)
89
90    self.shell = shell
91    self.args = args or []
92    self.cmd_prefix = cmd_prefix or []
93    self.timeout = timeout
94    self.env = env or {}
95    self.verbose = verbose
96    self.handle_sigterm = handle_sigterm
97
98  def execute(self):
99    if self.verbose:
100      print('# %s' % self)
101
102    process = self._start_process()
103
104    with handle_sigterm(process, self._abort, self.handle_sigterm):
105      # Variable to communicate with the timer.
106      timeout_occured = [False]
107      timer = threading.Timer(
108          self.timeout, self._abort, [process, timeout_occured])
109      timer.start()
110
111      start_time = time.time()
112      stdout, stderr = process.communicate()
113      duration = time.time() - start_time
114
115      timer.cancel()
116
117    return output.Output(
118      process.returncode,
119      timeout_occured[0],
120      stdout.decode('utf-8', 'replace').encode('utf-8'),
121      stderr.decode('utf-8', 'replace').encode('utf-8'),
122      process.pid,
123      duration
124    )
125
126  def _start_process(self):
127    try:
128      return subprocess.Popen(
129        args=self._get_popen_args(),
130        stdout=subprocess.PIPE,
131        stderr=subprocess.PIPE,
132        env=self._get_env(),
133      )
134    except Exception as e:
135      sys.stderr.write('Error executing: %s\n' % self)
136      raise e
137
138  def _get_popen_args(self):
139    return self._to_args_list()
140
141  def _get_env(self):
142    env = os.environ.copy()
143    env.update(self.env)
144    # GTest shard information is read by the V8 tests runner. Make sure it
145    # doesn't leak into the execution of gtests we're wrapping. Those might
146    # otherwise apply a second level of sharding and as a result skip tests.
147    env.pop('GTEST_TOTAL_SHARDS', None)
148    env.pop('GTEST_SHARD_INDEX', None)
149    return env
150
151  def _kill_process(self, process):
152    raise NotImplementedError()
153
154  def _abort(self, process, abort_called):
155    abort_called[0] = True
156    started_as = self.to_string(relative=True)
157    process_text = 'process %d started as:\n  %s\n' % (process.pid, started_as)
158    try:
159      print('Attempting to kill ' + process_text)
160      sys.stdout.flush()
161      self._kill_process(process)
162    except OSError as e:
163      print(e)
164      print('Unruly ' + process_text)
165      sys.stdout.flush()
166
167  def __str__(self):
168    return self.to_string()
169
170  def to_string(self, relative=False):
171    def escape(part):
172      # Escape spaces. We may need to escape more characters for this to work
173      # properly.
174      if ' ' in part:
175        return '"%s"' % part
176      return part
177
178    parts = map(escape, self._to_args_list())
179    cmd = ' '.join(parts)
180    if relative:
181      cmd = cmd.replace(os.getcwd() + os.sep, '')
182    return cmd
183
184  def _to_args_list(self):
185    return self.cmd_prefix + [self.shell] + self.args
186
187
188class PosixCommand(BaseCommand):
189  # TODO(machenbach): Use base process start without shell once
190  # https://crbug.com/v8/8889 is resolved.
191  def _start_process(self):
192    def wrapped(arg):
193      if set('() \'"') & set(arg):
194        return "'%s'" % arg.replace("'", "'\"'\"'")
195      return arg
196    try:
197      return subprocess.Popen(
198        args=' '.join(map(wrapped, self._get_popen_args())),
199        stdout=subprocess.PIPE,
200        stderr=subprocess.PIPE,
201        env=self._get_env(),
202        shell=True,
203        # Make the new shell create its own process group. This allows to kill
204        # all spawned processes reliably (https://crbug.com/v8/8292).
205        preexec_fn=os.setsid,
206      )
207    except Exception as e:
208      sys.stderr.write('Error executing: %s\n' % self)
209      raise e
210
211  def _kill_process(self, process):
212    # Kill the whole process group (PID == GPID after setsid).
213    os.killpg(process.pid, signal.SIGKILL)
214
215
216def taskkill_windows(process, verbose=False, force=True):
217  force_flag = ' /F' if force else ''
218  tk = subprocess.Popen(
219      'taskkill /T%s /PID %d' % (force_flag, process.pid),
220      stdout=subprocess.PIPE,
221      stderr=subprocess.PIPE,
222  )
223  stdout, stderr = tk.communicate()
224  if verbose:
225    print('Taskkill results for %d' % process.pid)
226    print(stdout)
227    print(stderr)
228    print('Return code: %d' % tk.returncode)
229    sys.stdout.flush()
230
231
232class WindowsCommand(BaseCommand):
233  def _start_process(self, **kwargs):
234    # Try to change the error mode to avoid dialogs on fatal errors. Don't
235    # touch any existing error mode flags by merging the existing error mode.
236    # See http://blogs.msdn.com/oldnewthing/archive/2004/07/27/198410.aspx.
237    def set_error_mode(mode):
238      prev_error_mode = SEM_INVALID_VALUE
239      try:
240        import ctypes
241        prev_error_mode = (
242            ctypes.windll.kernel32.SetErrorMode(mode))  #@UndefinedVariable
243      except ImportError:
244        pass
245      return prev_error_mode
246
247    error_mode = SEM_NOGPFAULTERRORBOX
248    prev_error_mode = set_error_mode(error_mode)
249    set_error_mode(error_mode | prev_error_mode)
250
251    try:
252      return super(WindowsCommand, self)._start_process(**kwargs)
253    finally:
254      if prev_error_mode != SEM_INVALID_VALUE:
255        set_error_mode(prev_error_mode)
256
257  def _get_popen_args(self):
258    return subprocess.list2cmdline(self._to_args_list())
259
260  def _kill_process(self, process):
261    taskkill_windows(process, self.verbose)
262
263
264class AndroidCommand(BaseCommand):
265  # This must be initialized before creating any instances of this class.
266  driver = None
267
268  def __init__(self, shell, args=None, cmd_prefix=None, timeout=60, env=None,
269               verbose=False, resources_func=None, handle_sigterm=False):
270    """Initialize the command and all files that need to be pushed to the
271    Android device.
272    """
273    self.shell_name = os.path.basename(shell)
274    self.shell_dir = os.path.dirname(shell)
275    self.files_to_push = (resources_func or (lambda: []))()
276
277    # Make all paths in arguments relative and also prepare files from arguments
278    # for pushing to the device.
279    rel_args = []
280    find_path_re = re.compile(r'.*(%s/[^\'"]+).*' % re.escape(BASE_DIR))
281    for arg in (args or []):
282      match = find_path_re.match(arg)
283      if match:
284        self.files_to_push.append(match.group(1))
285      rel_args.append(
286          re.sub(r'(.*)%s/(.*)' % re.escape(BASE_DIR), r'\1\2', arg))
287
288    super(AndroidCommand, self).__init__(
289        shell, args=rel_args, cmd_prefix=cmd_prefix, timeout=timeout, env=env,
290        verbose=verbose, handle_sigterm=handle_sigterm)
291
292  def execute(self, **additional_popen_kwargs):
293    """Execute the command on the device.
294
295    This pushes all required files to the device and then runs the command.
296    """
297    if self.verbose:
298      print('# %s' % self)
299
300    self.driver.push_executable(self.shell_dir, 'bin', self.shell_name)
301
302    for abs_file in self.files_to_push:
303      abs_dir = os.path.dirname(abs_file)
304      file_name = os.path.basename(abs_file)
305      rel_dir = os.path.relpath(abs_dir, BASE_DIR)
306      self.driver.push_file(abs_dir, file_name, rel_dir)
307
308    start_time = time.time()
309    return_code = 0
310    timed_out = False
311    try:
312      stdout = self.driver.run(
313          'bin', self.shell_name, self.args, '.', self.timeout, self.env)
314    except CommandFailedException as e:
315      return_code = e.status
316      stdout = e.output
317    except TimeoutException as e:
318      return_code = 1
319      timed_out = True
320      # Sadly the Android driver doesn't provide output on timeout.
321      stdout = ''
322
323    duration = time.time() - start_time
324    return output.Output(
325        return_code,
326        timed_out,
327        stdout,
328        '',  # No stderr available.
329        -1,  # No pid available.
330        duration,
331    )
332
333
334Command = None
335def setup(target_os, device):
336  """Set the Command class to the OS-specific version."""
337  global Command
338  if target_os == 'android':
339    AndroidCommand.driver = android_driver(device)
340    Command = AndroidCommand
341  elif target_os == 'windows':
342    Command = WindowsCommand
343  else:
344    Command = PosixCommand
345
346def tear_down():
347  """Clean up after using commands."""
348  if Command == AndroidCommand:
349    AndroidCommand.driver.tear_down()
350