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