1#!/usr/bin/env python
2
3# This Source Code Form is subject to the terms of the Mozilla Public
4# License, v. 2.0. If a copy of the MPL was not distributed with this
5# file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7# control server for raptor performance framework
8# communicates with the raptor browser webextension
9from __future__ import absolute_import
10
11import datetime
12import json
13import os
14import shutil
15import six
16import socket
17import threading
18import time
19
20try:
21    from http import server  # py3
22except ImportError:
23    import BaseHTTPServer as server  # py2
24
25from logger.logger import RaptorLogger
26
27LOG = RaptorLogger(component="raptor-control-server")
28
29here = os.path.abspath(os.path.dirname(__file__))
30
31
32def MakeCustomHandlerClass(
33    results_handler,
34    error_handler,
35    startup_handler,
36    shutdown_browser,
37    handle_gecko_profile,
38    background_app,
39    foreground_app,
40):
41    class MyHandler(server.BaseHTTPRequestHandler, object):
42        """
43        Control server expects messages of the form
44        {'type': 'messagetype', 'data':...}
45
46        Each message is given a key which is calculated as
47
48           If type is 'webext_status', then
49              the key is data['type']/data['data']
50           otherwise
51              the key is data['type'].
52
53        The control server can be forced to wait before performing an
54        action requested via POST by sending a special message
55
56        {'type': 'wait-set', 'data': key}
57
58        where key is the key of the message for which the control server should
59        perform a wait before processing. The handler will store
60        this key in the wait_after_messages dict as a True value.
61
62        wait_after_messages[key] = True
63
64        For subsequent requests, the handler will check the key of
65        the incoming message against wait_for_messages; if found
66        and its value is True, the handler will assign the key
67        to waiting_in_state and will loop until the key is removed
68        or until its value is changed to False.
69
70        The control server will stop waiting for a state to be continued
71        or cleared after wait_timeout seconds, after which the wait
72        will be removed and the control server will finish processing
73        the current POST request. wait_timeout defaults to 60 seconds
74        but can be set globally for all wait states by sending the
75        message
76
77        {'type': 'wait-timeout', 'data': timeout}
78
79        The value of waiting_in_state can be retrieved by sending the
80        message
81
82        {'type': 'wait-get', 'data': ''}
83
84        which will return the value of waiting_in_state in the
85        content of the response. If the value returned is not
86        'None', then the control server has received a message whose
87        key is recorded in wait_after_messages and is waiting before
88        completing the request.
89
90        The control server can be told to stop waiting and finish
91        processing the current request while keeping the wait for
92        subsequent requests by sending
93
94        {'type': 'wait-continue', 'data': ''}
95
96        The control server can be told to stop waiting and finish
97        processing the current request while removing the wait for
98        subsequent requests by sending
99
100        {'type': 'wait-clear', 'data': key}
101
102            if key is the empty string ''
103                the key in waiting_in_state is removed from wait_after_messages
104                waiting_in_state is set to None
105            else if key is 'all'
106                 all keys in wait_after_messages are removed
107            else key is not in wait_after_messages
108                 the message is ignored
109            else
110                 the key is removed from wait_after messages
111                 if the key matches the value in waiting_in_state,
112                 then waiting_in_state is set to None
113        """
114
115        wait_after_messages = {}
116        waiting_in_state = None
117        wait_timeout = 60
118
119        def __init__(self, *args, **kwargs):
120            self.results_handler = results_handler
121            self.error_handler = error_handler
122            self.startup_handler = startup_handler
123            self.shutdown_browser = shutdown_browser
124            self.handle_gecko_profile = handle_gecko_profile
125            self.background_app = background_app
126            self.foreground_app = foreground_app
127            try:
128                super(MyHandler, self).__init__(*args, **kwargs)
129            except ValueError:
130                pass
131
132        def log_request(self, code="-", size="-"):
133            if code != 200:
134                super(MyHandler, self).log_request(code, size)
135
136        def do_GET(self):
137            # get handler, received request for test settings from webext runner
138            self.send_response(200)
139            head, tail = os.path.split(self.path)
140
141            if tail.startswith("raptor") and tail.endswith(".json"):
142                LOG.info("reading test settings from json/" + tail)
143                try:
144                    with open("json/{}".format(tail)) as json_settings:
145                        self.send_header("Access-Control-Allow-Origin", "*")
146                        self.send_header("Content-type", "application/json")
147                        self.end_headers()
148                        self.wfile.write(
149                            json.dumps(json.load(json_settings)).encode("utf-8")
150                        )
151                        self.wfile.close()
152                        LOG.info("sent test settings to webext runner")
153                except Exception as ex:
154                    LOG.info("control server exception")
155                    LOG.info(ex)
156            else:
157                LOG.info("received request for unknown file: " + self.path)
158
159        def do_POST(self):
160            # post handler, received something from webext
161            self.send_response(200)
162            self.send_header("Access-Control-Allow-Origin", "*")
163            self.send_header("Content-type", "text/html")
164            self.end_headers()
165
166            if six.PY2:
167                content_len = int(self.headers.getheader("content-length"))
168            elif six.PY3:
169                content_len = int(self.headers.get("content-length"))
170
171            post_body = self.rfile.read(content_len)
172            # could have received a status update or test results
173            if isinstance(post_body, six.binary_type):
174                post_body = post_body.decode("utf-8")
175            data = json.loads(post_body)
176
177            if data["type"] == "webext_status":
178                wait_key = "%s/%s" % (data["type"], data["data"])
179            else:
180                wait_key = data["type"]
181
182            if MyHandler.wait_after_messages.get(wait_key, None):
183                LOG.info("Waiting in %s" % wait_key)
184                MyHandler.waiting_in_state = wait_key
185                start_time = datetime.datetime.now()
186
187            while MyHandler.wait_after_messages.get(wait_key, None):
188                time.sleep(1)
189                elapsed_time = datetime.datetime.now() - start_time
190                if elapsed_time > datetime.timedelta(seconds=MyHandler.wait_timeout):
191                    del MyHandler.wait_after_messages[wait_key]
192                    MyHandler.waiting_in_state = None
193                    LOG.error(
194                        "TEST-UNEXPECTED-ERROR | "
195                        "control server wait %s exceeded %s seconds"
196                        % (wait_key, MyHandler.wait_timeout)
197                    )
198
199            if MyHandler.wait_after_messages.get(wait_key, None) is not None:
200                # If the wait is False, it was continued, so we set it back
201                # to True for the next time. If removed by clear, we
202                # leave it alone so it will not cause further waits.
203                MyHandler.wait_after_messages[wait_key] = True
204
205            if data["type"] == "webext_error":
206                error, stack = data["data"]
207                LOG.info("received " + data["type"] + ": " + str(error))
208                self.error_handler(error, stack)
209
210            elif data["type"] == "webext_gecko_profile":
211                # received file name of the saved gecko profile
212                filename = str(data["data"])
213                LOG.info("received gecko profile filename: {}".format(filename))
214                self.handle_gecko_profile(filename)
215
216            elif data["type"] == "webext_results":
217                LOG.info("received " + data["type"] + ": " + str(data["data"]))
218                self.results_handler.add(data["data"])
219            elif data["type"] == "webext_raptor-page-timeout":
220                LOG.info("received " + data["type"] + ": " + str(data["data"]))
221
222                if len(data["data"]) == 3:
223                    data["data"].append("")
224                # pageload test has timed out; record it as a failure
225                self.results_handler.add_page_timeout(
226                    str(data["data"][0]),
227                    str(data["data"][1]),
228                    str(data["data"][2]),
229                    dict(data["data"][3]),
230                )
231            elif data["type"] == "webext_shutdownBrowser":
232                LOG.info("received request to shutdown the browser")
233                self.shutdown_browser()
234            elif data["type"] == "webext_start_background":
235                LOG.info("received request to background app")
236                self.background_app()
237            elif data["type"] == "webext_end_background":
238                LOG.info("received request to foreground app")
239                self.foreground_app()
240            elif data["type"] == "webext_screenshot":
241                LOG.info("received " + data["type"])
242                self.results_handler.add_image(
243                    str(data["data"][0]), str(data["data"][1]), str(data["data"][2])
244                )
245            elif data["type"] == "webext_status":
246                LOG.info("received " + data["type"] + ": " + str(data["data"]))
247            elif data["type"] == "webext_loaded":
248                LOG.info("received " + data["type"] + ": raptor runner.js is loaded!")
249                self.startup_handler(True)
250            elif data["type"] == "wait-set":
251                LOG.info("received " + data["type"] + ": " + str(data["data"]))
252                MyHandler.wait_after_messages[str(data["data"])] = True
253            elif data["type"] == "wait-timeout":
254                LOG.info("received " + data["type"] + ": " + str(data["data"]))
255                MyHandler.wait_timeout = data["data"]
256            elif data["type"] == "wait-get":
257                state = MyHandler.waiting_in_state
258                if state is None:
259                    state = "None"
260                if isinstance(state, six.text_type):
261                    state = state.encode("utf-8")
262                self.wfile.write(state)
263            elif data["type"] == "wait-continue":
264                LOG.info("received " + data["type"] + ": " + str(data["data"]))
265                if MyHandler.waiting_in_state:
266                    MyHandler.wait_after_messages[MyHandler.waiting_in_state] = False
267                    MyHandler.waiting_in_state = None
268            elif data["type"] == "wait-clear":
269                LOG.info("received " + data["type"] + ": " + str(data["data"]))
270                clear_key = str(data["data"])
271                if clear_key == "":
272                    if MyHandler.waiting_in_state:
273                        del MyHandler.wait_after_messages[MyHandler.waiting_in_state]
274                        MyHandler.waiting_in_state = None
275                    else:
276                        pass
277                elif clear_key == "all":
278                    MyHandler.wait_after_messages = {}
279                    MyHandler.waiting_in_state = None
280                elif clear_key not in MyHandler.wait_after_messages:
281                    pass
282                else:
283                    del MyHandler.wait_after_messages[clear_key]
284                    if MyHandler.waiting_in_state == clear_key:
285                        MyHandler.waiting_in_state = None
286            else:
287                LOG.info("received " + data["type"] + ": " + str(data["data"]))
288
289        def do_OPTIONS(self):
290            self.send_response(200, "ok")
291            self.send_header("Access-Control-Allow-Origin", "*")
292            self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
293            self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
294            self.send_header("Access-Control-Allow-Headers", "Content-Type")
295            self.end_headers()
296
297    return MyHandler
298
299
300class ThreadedHTTPServer(server.HTTPServer):
301    # See
302    # https://stackoverflow.com/questions/19537132/threaded-basehttpserver-one-thread-per-request#30312766
303    def process_request(self, request, client_address):
304        thread = threading.Thread(
305            target=self.__new_request,
306            args=(self.RequestHandlerClass, request, client_address, self),
307        )
308        thread.start()
309
310    def __new_request(self, handlerClass, request, address, server):
311        handlerClass(request, address, server)
312        self.shutdown_request(request)
313
314
315class RaptorControlServer:
316    """Container class for Raptor Control Server"""
317
318    def __init__(self, results_handler, debug_mode=False):
319        self.raptor_venv = os.path.join(os.getcwd(), "raptor-venv")
320        self.server = None
321        self._server_thread = None
322        self.port = None
323        self.results_handler = results_handler
324        self.browser_proc = None
325        self._finished = False
326        self._is_shutting_down = False
327        self._runtime_error = None
328        self.device = None
329        self.app_name = None
330        self.gecko_profile_dir = None
331        self.debug_mode = debug_mode
332        self.user_profile = None
333        self.is_webextension_loaded = False
334
335    def start(self):
336        config_dir = os.path.join(here, "tests")
337        os.chdir(config_dir)
338
339        # pick a free port
340        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
341        sock.bind(("", 0))
342        self.port = sock.getsockname()[1]
343        sock.close()
344        server_address = ("", self.port)
345
346        server_class = ThreadedHTTPServer
347        handler_class = MakeCustomHandlerClass(
348            self.results_handler,
349            self.error_handler,
350            self.startup_handler,
351            self.shutdown_browser,
352            self.handle_gecko_profile,
353            self.background_app,
354            self.foreground_app,
355        )
356
357        httpd = server_class(server_address, handler_class)
358
359        self._server_thread = threading.Thread(target=httpd.serve_forever)
360        self._server_thread.setDaemon(True)  # don't hang on exit
361        self._server_thread.start()
362        LOG.info("raptor control server running on port %d..." % self.port)
363        self.server = httpd
364
365    def error_handler(self, error, stack):
366        self._runtime_error = {"error": error, "stack": stack}
367
368    def startup_handler(self, value):
369        self.is_webextension_loaded = value
370
371    def shutdown_browser(self):
372        # if debug-mode enabled, leave the browser running - require manual shutdown
373        # this way the browser console remains open, so we can review the logs etc.
374        if self.debug_mode:
375            LOG.info("debug-mode enabled, so NOT shutting down the browser")
376            self._finished = True
377            return
378
379        if self.device is not None:
380            LOG.info("shutting down android app %s" % self.app_name)
381        else:
382            LOG.info("shutting down browser (pid: %d)" % self.browser_proc.pid)
383        self.kill_thread = threading.Thread(
384            target=self.wait_for_quit, kwargs={"timeout": 0}
385        )
386        self.kill_thread.daemon = True
387        self.kill_thread.start()
388
389    def handle_gecko_profile(self, filename):
390        # Move the stored profile to a location outside the Firefox profile
391        source_path = os.path.join(self.user_profile.profile, "profiler", filename)
392        target_path = os.path.join(self.gecko_profile_dir, filename)
393        shutil.move(source_path, target_path)
394        LOG.info("moved gecko profile to {}".format(target_path))
395
396    def is_app_in_background(self):
397        # Get the app view state: foreground->False, background->True
398        current_focus = self.device.shell_output(
399            "dumpsys window windows | grep mCurrentFocus"
400        ).strip()
401        return self.app_name not in current_focus
402
403    def background_app(self):
404        # Disable Doze, background the app, then disable App Standby
405        self.device.shell_output("dumpsys deviceidle whitelist +%s" % self.app_name)
406        self.device.shell_output("input keyevent 3")
407        if not self.is_app_in_background():
408            LOG.critical(
409                "%s is still in foreground after background request" % self.app_name
410            )
411        else:
412            LOG.info("%s was successfully backgrounded" % self.app_name)
413
414    def foreground_app(self):
415        self.device.shell_output("am start --activity-single-top %s" % self.app_name)
416        self.device.shell_output("dumpsys deviceidle enable")
417        if self.is_app_in_background():
418            LOG.critical(
419                "%s is still in background after foreground request" % self.app_name
420            )
421        else:
422            LOG.info("%s was successfully foregrounded" % self.app_name)
423
424    def wait_for_quit(self, timeout=15):
425        """Wait timeout seconds for the process to exit. If it hasn't
426        exited by then, kill it.
427
428        The sleep calls are required to give those new values enough time
429        to sync-up between threads. It would be better to maybe use signals
430        for synchronization (bug 1633975)
431        """
432        self._is_shutting_down = True
433        time.sleep(0.25)
434
435        if self.device is not None:
436            self.device.stop_application(self.app_name)
437        else:
438            try:
439                self.browser_proc.wait(timeout)
440            except OSError:
441                LOG.warning("OSError while shutting down browser", exc_info=True)
442            finally:
443                if self.browser_proc.poll() is None:
444                    self.browser_proc.kill()
445
446        self._finished = True
447        time.sleep(0.25)
448        self._is_shutting_down = False
449
450    def submit_supporting_data(self, supporting_data):
451        """
452        Allow the submission of supporting data i.e. power data.
453        This type of data is measured outside of the webext; so
454        we can submit directly to the control server instead of
455        doing an http post.
456        """
457        self.results_handler.add_supporting_data(supporting_data)
458
459    def stop(self):
460        LOG.info("shutting down control server")
461        self.server.shutdown()
462        self._server_thread.join()
463