1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3# You can obtain one at http://mozilla.org/MPL/2.0/.
4from __future__ import absolute_import
5
6import pprint
7import signal
8import sys
9import time
10import traceback
11import subprocess
12from threading import Event
13
14import mozcrash
15import psutil
16from mozlog import get_proxy_logger
17from mozprocess import ProcessHandler
18from talos.utils import TalosError
19
20LOG = get_proxy_logger()
21
22
23class ProcessContext(object):
24    """
25    Store useful results of the browser execution.
26    """
27    def __init__(self, is_launcher=False):
28        self.output = None
29        self.process = None
30        self.is_launcher = is_launcher
31
32    @property
33    def pid(self):
34        return self.process and self.process.pid
35
36    def kill_process(self):
37        """
38        Kill the process, returning the exit code or None if the process
39        is already finished.
40        """
41        parentProc = self.process
42        # If we're using a launcher process, terminate that instead of us:
43        kids = parentProc and parentProc.is_running() and parentProc.children()
44        if self.is_launcher and kids and len(kids) == 1 and kids[0].is_running():
45            LOG.debug(("Launcher process {} detected. Terminating parent"
46                       " process {} instead.").format(parentProc, kids[0]))
47            parentProc = kids[0]
48
49        if parentProc and parentProc.is_running():
50            LOG.debug("Terminating %s" % parentProc)
51            try:
52                parentProc.terminate()
53            except psutil.NoSuchProcess:
54                procs = parentProc.children()
55                for p in procs:
56                    c = ProcessContext()
57                    c.process = p
58                    c.kill_process()
59                return parentProc.returncode
60            try:
61                return parentProc.wait(3)
62            except psutil.TimeoutExpired:
63                LOG.debug("Killing %s" % parentProc)
64                parentProc.kill()
65                # will raise TimeoutExpired if unable to kill
66                return parentProc.wait(3)
67
68
69class Reader(object):
70    def __init__(self, event):
71        self.output = []
72        self.got_end_timestamp = False
73        self.got_timeout = False
74        self.timeout_message = ''
75        self.got_error = False
76        self.event = event
77        self.proc = None
78
79    def __call__(self, line):
80        if line.find('__endTimestamp') != -1:
81            self.got_end_timestamp = True
82            self.event.set()
83        elif line == 'TART: TIMEOUT':
84            self.got_timeout = True
85            self.timeout_message = 'TART'
86            self.event.set()
87        elif line.startswith('TEST-UNEXPECTED-FAIL | '):
88            self.got_error = True
89            self.event.set()
90
91        if not (line.startswith('JavaScript error:') or
92                line.startswith('JavaScript warning:')):
93            LOG.process_output(self.proc.pid, line)
94            self.output.append(line)
95
96
97def run_browser(command, minidump_dir, timeout=None, on_started=None,
98                debug=None, debugger=None, debugger_args=None, **kwargs):
99    """
100    Run the browser using the given `command`.
101
102    After the browser prints __endTimestamp, we give it 5
103    seconds to quit and kill it if it's still alive at that point.
104
105    Note that this method ensure that the process is killed at
106    the end. If this is not possible, an exception will be raised.
107
108    :param command: the commad (as a string list) to run the browser
109    :param minidump_dir: a path where to extract minidumps in case the
110                         browser hang. This have to be the same value
111                         used in `mozcrash.check_for_crashes`.
112    :param timeout: if specified, timeout to wait for the browser before
113                    we raise a :class:`TalosError`
114    :param on_started: a callback that can be used to do things just after
115                       the browser has been started. The callback must takes
116                       an argument, which is the psutil.Process instance
117    :param kwargs: additional keyword arguments for the :class:`ProcessHandler`
118                   instance
119
120    Returns a ProcessContext instance, with available output and pid used.
121    """
122
123    debugger_info = find_debugger_info(debug, debugger, debugger_args)
124    if debugger_info is not None:
125        return run_in_debug_mode(command, debugger_info,
126                                 on_started=on_started, env=kwargs.get('env'))
127
128    is_launcher = sys.platform.startswith('win') and '-wait-for-browser' in command
129    context = ProcessContext(is_launcher)
130    first_time = int(time.time()) * 1000
131    wait_for_quit_timeout = 5
132    event = Event()
133    reader = Reader(event)
134
135    LOG.info("Using env: %s" % pprint.pformat(kwargs['env']))
136
137    kwargs['storeOutput'] = False
138    kwargs['processOutputLine'] = reader
139    kwargs['onFinish'] = event.set
140    proc = ProcessHandler(command, **kwargs)
141    reader.proc = proc
142    proc.run()
143
144    LOG.process_start(proc.pid, ' '.join(command))
145    try:
146        context.process = psutil.Process(proc.pid)
147        if on_started:
148            on_started(context.process)
149        # wait until we saw __endTimestamp in the proc output,
150        # or the browser just terminated - or we have a timeout
151        if not event.wait(timeout):
152            LOG.info("Timeout waiting for test completion; killing browser...")
153            # try to extract the minidump stack if the browser hangs
154            kill_and_get_minidump(context, minidump_dir)
155            raise TalosError("timeout")
156        if reader.got_end_timestamp:
157            for i in range(1, wait_for_quit_timeout):
158                if proc.wait(1) is not None:
159                    break
160            if proc.poll() is None:
161                LOG.info(
162                    "Browser shutdown timed out after {0} seconds, killing"
163                    " process.".format(wait_for_quit_timeout)
164                )
165                kill_and_get_minidump(context, minidump_dir)
166                raise TalosError(
167                    "Browser shutdown timed out after {0} seconds, killed"
168                    " process.".format(wait_for_quit_timeout)
169                )
170        elif reader.got_timeout:
171            raise TalosError('TIMEOUT: %s' % reader.timeout_message)
172        elif reader.got_error:
173            raise TalosError("unexpected error")
174    finally:
175        # this also handle KeyboardInterrupt
176        # ensure early the process is really terminated
177        return_code = None
178        try:
179            return_code = context.kill_process()
180            if return_code is None:
181                return_code = proc.wait(1)
182        except Exception:
183            # Maybe killed by kill_and_get_minidump(), maybe ended?
184            LOG.info("Unable to kill process")
185            LOG.info(traceback.format_exc())
186
187    reader.output.append(
188        "__startBeforeLaunchTimestamp%d__endBeforeLaunchTimestamp"
189        % first_time)
190    reader.output.append(
191        "__startAfterTerminationTimestamp%d__endAfterTerminationTimestamp"
192        % (int(time.time()) * 1000))
193
194    if return_code is not None:
195        LOG.process_exit(proc.pid, return_code)
196    else:
197        LOG.debug("Unable to detect exit code of the process %s." % proc.pid)
198    context.output = reader.output
199    return context
200
201
202def find_debugger_info(debug, debugger, debugger_args):
203    debuggerInfo = None
204    if debug or debugger or debugger_args:
205        import mozdebug
206
207        if not debugger:
208            # No debugger name was provided. Look for the default ones on
209            # current OS.
210            debugger = mozdebug.get_default_debugger_name(mozdebug.DebuggerSearch.KeepLooking)
211
212        debuggerInfo = None
213        if debugger:
214            debuggerInfo = mozdebug.get_debugger_info(debugger, debugger_args)
215
216        if debuggerInfo is None:
217            raise TalosError('Could not find a suitable debugger in your PATH.')
218
219    return debuggerInfo
220
221
222def run_in_debug_mode(command, debugger_info, on_started=None, env=None):
223    signal.signal(signal.SIGINT, lambda sigid, frame: None)
224    context = ProcessContext()
225    command_under_dbg = [debugger_info.path] + debugger_info.args + command
226
227    ttest_process = subprocess.Popen(command_under_dbg, env=env)
228
229    context.process = psutil.Process(ttest_process.pid)
230    if on_started:
231        on_started(context.process)
232
233    return_code = ttest_process.wait()
234
235    if return_code is not None:
236        LOG.process_exit(ttest_process.pid, return_code)
237    else:
238        LOG.debug("Unable to detect exit code of the process %s." % ttest_process.pid)
239
240    return context
241
242
243def kill_and_get_minidump(context, minidump_dir):
244    proc = context.process
245    if context.is_launcher:
246        kids = context.process.children()
247        if len(kids) == 1:
248            LOG.debug(("Launcher process {} detected. Killing parent"
249                       " process {} instead.").format(proc, kids[0]))
250            proc = kids[0]
251    LOG.debug("Killing %s and writing a minidump file" % proc)
252    mozcrash.kill_and_get_minidump(proc.pid, minidump_dir)
253