1 #!/usr/bin/env python 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. 3 # Use of this source code is governed by a BSD-style license that can be 4 # found in the LICENSE file. 5 6 """Chrome remote inspector utility for pyauto tests. 7 8 This script provides a python interface that acts as a front-end for Chrome's 9 remote inspector module, communicating via sockets to interact with Chrome in 10 the same way that the Developer Tools does. This -- in theory -- should allow 11 a pyauto test to do anything that Chrome's Developer Tools does, as long as the 12 appropriate communication with the remote inspector is implemented in this 13 script. 14 15 This script assumes that Chrome is already running on the local machine with 16 flag '--remote-debugging-port=9222' to enable remote debugging on port 9222. 17 18 To use this module, first create an instance of class RemoteInspectorClient; 19 doing this sets up a connection to Chrome's remote inspector. Then call the 20 appropriate functions on that object to perform the desired actions with the 21 remote inspector. When done, call Stop() on the RemoteInspectorClient object 22 to stop communication with the remote inspector. 23 24 For example, to take v8 heap snapshots from a pyauto test: 25 26 import remote_inspector_client 27 my_client = remote_inspector_client.RemoteInspectorClient() 28 snapshot_info = my_client.HeapSnapshot(include_summary=True) 29 // Do some stuff... 30 new_snapshot_info = my_client.HeapSnapshot(include_summary=True) 31 my_client.Stop() 32 33 It is expected that a test will only use one instance of RemoteInspectorClient 34 at a time. If a second instance is instantiated, a RuntimeError will be raised. 35 RemoteInspectorClient could be made into a singleton in the future if the need 36 for it arises. 37 """ 38 39 import asyncore 40 import datetime 41 import logging 42 import optparse 43 import pprint 44 import re 45 import simplejson 46 import socket 47 import sys 48 import threading 49 import time 50 import urllib2 51 import urlparse 52 53 54 class _DevToolsSocketRequest(object): 55 """A representation of a single DevToolsSocket request. 56 57 A DevToolsSocket request is used for communication with a remote Chrome 58 instance when interacting with the renderer process of a given webpage. 59 Requests and results are passed as specially-formatted JSON messages, 60 according to a communication protocol defined in WebKit. The string 61 representation of this request will be a JSON message that is properly 62 formatted according to the communication protocol. 63 64 Public Attributes: 65 method: The string method name associated with this request. 66 id: A unique integer id associated with this request. 67 params: A dictionary of input parameters associated with this request. 68 results: A dictionary of relevant results obtained from the remote Chrome 69 instance that are associated with this request. 70 is_fulfilled: A boolean indicating whether or not this request has been sent 71 and all relevant results for it have been obtained (i.e., this value is 72 True only if all results for this request are known). 73 is_fulfilled_condition: A threading.Condition for waiting for the request to 74 be fulfilled. 75 """ 76 77 def __init__(self, method, params, message_id): 78 """Initialize. 79 80 Args: 81 method: The string method name for this request. 82 message_id: An integer id for this request, which is assumed to be unique 83 from among all requests. 84 """ 85 self.method = method 86 self.id = message_id 87 self.params = params 88 self.results = {} 89 self.is_fulfilled = False 90 self.is_fulfilled_condition = threading.Condition() 91 92 def __repr__(self): 93 json_dict = {} 94 json_dict['method'] = self.method 95 json_dict['id'] = self.id 96 if self.params: 97 json_dict['params'] = self.params 98 return simplejson.dumps(json_dict, separators=(',', ':')) 99 100 101 class _DevToolsSocketClient(asyncore.dispatcher): 102 """Client that communicates with a remote Chrome instance via sockets. 103 104 This class works in conjunction with the _RemoteInspectorThread class to 105 communicate with a remote Chrome instance following the remote debugging 106 communication protocol in WebKit. This class performs the lower-level work 107 of socket communication. 108 109 Public Attributes: 110 handshake_done: A boolean indicating whether or not the client has completed 111 the required protocol handshake with the remote Chrome instance. 112 inspector_thread: An instance of the _RemoteInspectorThread class that is 113 working together with this class to communicate with a remote Chrome 114 instance. 115 """ 116 117 def __init__(self, verbose, show_socket_messages, hostname, port, path): 118 """Initialize. 119 120 Args: 121 verbose: A boolean indicating whether or not to use verbose logging. 122 show_socket_messages: A boolean indicating whether or not to show the 123 socket messages sent/received when communicating with the remote 124 Chrome instance. 125 hostname: The string hostname of the DevToolsSocket to which to connect. 126 port: The integer port number of the DevToolsSocket to which to connect. 127 path: The string path of the DevToolsSocket to which to connect. 128 """ 129 asyncore.dispatcher.__init__(self) 130 131 self._logger = logging.getLogger('_DevToolsSocketClient') 132 self._logger.setLevel([logging.WARNING, logging.DEBUG][verbose]) 133 134 self._show_socket_messages = show_socket_messages 135 136 self._read_buffer = '' 137 self._write_buffer = '' 138 139 self._socket_buffer_lock = threading.Lock() 140 141 self.handshake_done = False 142 self.inspector_thread = None 143 144 # Connect to the remote Chrome instance and initiate the protocol handshake. 145 self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 146 self.connect((hostname, port)) 147 148 fields = [ 149 'Upgrade: WebSocket', 150 'Connection: Upgrade', 151 'Host: %s:%d' % (hostname, port), 152 'Origin: http://%s:%d' % (hostname, port), 153 'Sec-WebSocket-Key1: 4k0L66E ZU 8 5 <18 <TK 7 7', 154 'Sec-WebSocket-Key2: s2 20 `# 4| 3 9 U_ 1299', 155 ] 156 handshake_msg = ('GET %s HTTP/1.1\r\n%s\r\n\r\n\x47\x30\x22\x2D\x5A\x3F' 157 '\x47\x58' % (path, '\r\n'.join(fields))) 158 self._Write(handshake_msg.encode('utf-8')) 159 160 def SendMessage(self, msg): 161 """Causes a request message to be sent to the remote Chrome instance. 162 163 Args: 164 msg: A string message to be sent; assumed to be a JSON message in proper 165 format according to the remote debugging protocol in WebKit. 166 """ 167 # According to the communication protocol, each request message sent over 168 # the wire must begin with '\x00' and end with '\xff'. 169 self._Write('\x00' + msg.encode('utf-8') + '\xff') 170 171 def _Write(self, msg): 172 """Causes a raw message to be sent to the remote Chrome instance. 173 174 Args: 175 msg: A raw string message to be sent. 176 """ 177 self._write_buffer += msg 178 self.handle_write() 179 180 def handle_write(self): 181 """Called if a writable socket can be written; overridden from asyncore.""" 182 self._socket_buffer_lock.acquire() 183 if self._write_buffer: 184 sent = self.send(self._write_buffer) 185 if self._show_socket_messages: 186 msg_type = ['Handshake', 'Message'][self._write_buffer[0] == '\x00' and 187 self._write_buffer[-1] == '\xff'] 188 msg = ('========================\n' 189 'Sent %s:\n' 190 '========================\n' 191 '%s\n' 192 '========================') % (msg_type, 193 self._write_buffer[:sent-1]) 194 print msg 195 self._write_buffer = self._write_buffer[sent:] 196 self._socket_buffer_lock.release() 197 198 def handle_read(self): 199 """Called when a socket can be read; overridden from asyncore.""" 200 self._socket_buffer_lock.acquire() 201 if self.handshake_done: 202 # Process a message reply from the remote Chrome instance. 203 self._read_buffer += self.recv(4096) 204 pos = self._read_buffer.find('\xff') 205 while pos >= 0: 206 pos += len('\xff') 207 data = self._read_buffer[:pos-len('\xff')] 208 pos2 = data.find('\x00') 209 if pos2 >= 0: 210 data = data[pos2 + 1:] 211 self._read_buffer = self._read_buffer[pos:] 212 if self._show_socket_messages: 213 msg = ('========================\n' 214 'Received Message:\n' 215 '========================\n' 216 '%s\n' 217 '========================') % data 218 print msg 219 if self.inspector_thread: 220 self.inspector_thread.NotifyReply(data) 221 pos = self._read_buffer.find('\xff') 222 else: 223 # Process a handshake reply from the remote Chrome instance. 224 self._read_buffer += self.recv(4096) 225 pos = self._read_buffer.find('\r\n\r\n') 226 if pos >= 0: 227 pos += len('\r\n\r\n') 228 data = self._read_buffer[:pos] 229 self._read_buffer = self._read_buffer[pos:] 230 self.handshake_done = True 231 if self._show_socket_messages: 232 msg = ('=========================\n' 233 'Received Handshake Reply:\n' 234 '=========================\n' 235 '%s\n' 236 '=========================') % data 237 print msg 238 self._socket_buffer_lock.release() 239 240 def handle_close(self): 241 """Called when the socket is closed; overridden from asyncore.""" 242 if self._show_socket_messages: 243 msg = ('=========================\n' 244 'Socket closed.\n' 245 '=========================') 246 print msg 247 self.close() 248 249 def writable(self): 250 """Determines if writes can occur for this socket; overridden from asyncore. 251 252 Returns: 253 True, if there is something to write to the socket, or 254 False, otherwise. 255 """ 256 return len(self._write_buffer) > 0 257 258 def handle_expt(self): 259 """Called when out-of-band data exists; overridden from asyncore.""" 260 self.handle_error() 261 262 def handle_error(self): 263 """Called when an exception is raised; overridden from asyncore.""" 264 if self._show_socket_messages: 265 msg = ('=========================\n' 266 'Socket error.\n' 267 '=========================') 268 print msg 269 self.close() 270 self.inspector_thread.ClientSocketExceptionOccurred() 271 asyncore.dispatcher.handle_error(self) 272 273 274 class _RemoteInspectorThread(threading.Thread): 275 """Manages communication using Chrome's remote inspector protocol. 276 277 This class works in conjunction with the _DevToolsSocketClient class to 278 communicate with a remote Chrome instance following the remote inspector 279 communication protocol in WebKit. This class performs the higher-level work 280 of managing request and reply messages, whereas _DevToolsSocketClient handles 281 the lower-level work of socket communication. 282 """ 283 284 def __init__(self, url, tab_index, tab_filter, verbose, show_socket_messages, 285 agent_name): 286 """Initialize. 287 288 Args: 289 url: The base URL to connent to. 290 tab_index: The integer index of the tab in the remote Chrome instance to 291 use for snapshotting. 292 tab_filter: When specified, is run over tabs of the remote Chrome 293 instances to choose which one to connect to. 294 verbose: A boolean indicating whether or not to use verbose logging. 295 show_socket_messages: A boolean indicating whether or not to show the 296 socket messages sent/received when communicating with the remote 297 Chrome instance. 298 """ 299 threading.Thread.__init__(self) 300 self._logger = logging.getLogger('_RemoteInspectorThread') 301 self._logger.setLevel([logging.WARNING, logging.DEBUG][verbose]) 302 303 self._killed = False 304 self._requests = [] 305 self._action_queue = [] 306 self._action_queue_condition = threading.Condition() 307 self._action_specific_callback = None # Callback only for current action. 308 self._action_specific_callback_lock = threading.Lock() 309 self._general_callbacks = [] # General callbacks that can be long-lived. 310 self._general_callbacks_lock = threading.Lock() 311 self._condition_to_wait = None 312 self._agent_name = agent_name 313 314 # Create a DevToolsSocket client and wait for it to complete the remote 315 # debugging protocol handshake with the remote Chrome instance. 316 result = self._IdentifyDevToolsSocketConnectionInfo( 317 url, tab_index, tab_filter) 318 self._client = _DevToolsSocketClient( 319 verbose, show_socket_messages, result['host'], result['port'], 320 result['path']) 321 self._client.inspector_thread = self 322 while asyncore.socket_map: 323 if self._client.handshake_done or self._killed: 324 break 325 asyncore.loop(timeout=1, count=1, use_poll=True) 326 327 def ClientSocketExceptionOccurred(self): 328 """Notifies that the _DevToolsSocketClient encountered an exception.""" 329 self.Kill() 330 331 def NotifyReply(self, msg): 332 """Notifies of a reply message received from the remote Chrome instance. 333 334 Args: 335 msg: A string reply message received from the remote Chrome instance; 336 assumed to be a JSON message formatted according to the remote 337 debugging communication protocol in WebKit. 338 """ 339 reply_dict = simplejson.loads(msg) 340 341 # Notify callbacks of this message received from the remote inspector. 342 self._action_specific_callback_lock.acquire() 343 if self._action_specific_callback: 344 self._action_specific_callback(reply_dict) 345 self._action_specific_callback_lock.release() 346 347 self._general_callbacks_lock.acquire() 348 if self._general_callbacks: 349 for callback in self._general_callbacks: 350 callback(reply_dict) 351 self._general_callbacks_lock.release() 352 353 if 'result' in reply_dict: 354 # This is the result message associated with a previously-sent request. 355 request = self.GetRequestWithId(reply_dict['id']) 356 if request: 357 request.is_fulfilled_condition.acquire() 358 request.is_fulfilled_condition.notify() 359 request.is_fulfilled_condition.release() 360 361 def run(self): 362 """Start this thread; overridden from threading.Thread.""" 363 while not self._killed: 364 self._action_queue_condition.acquire() 365 if self._action_queue: 366 # There's a request to the remote inspector that needs to be processed. 367 messages, callback = self._action_queue.pop(0) 368 self._action_specific_callback_lock.acquire() 369 self._action_specific_callback = callback 370 self._action_specific_callback_lock.release() 371 372 # Prepare the request list. 373 for message_id, message in enumerate(messages): 374 self._requests.append( 375 _DevToolsSocketRequest(message[0], message[1], message_id)) 376 377 # Send out each request. Wait until each request is complete before 378 # sending the next request. 379 for request in self._requests: 380 self._FillInParams(request) 381 self._client.SendMessage(str(request)) 382 383 request.is_fulfilled_condition.acquire() 384 self._condition_to_wait = request.is_fulfilled_condition 385 request.is_fulfilled_condition.wait() 386 request.is_fulfilled_condition.release() 387 388 if self._killed: 389 self._client.close() 390 return 391 392 # Clean up so things are ready for the next request. 393 self._requests = [] 394 395 self._action_specific_callback_lock.acquire() 396 self._action_specific_callback = None 397 self._action_specific_callback_lock.release() 398 399 # Wait until there is something to process. 400 self._condition_to_wait = self._action_queue_condition 401 self._action_queue_condition.wait() 402 self._action_queue_condition.release() 403 self._client.close() 404 405 def Kill(self): 406 """Notify this thread that it should stop executing.""" 407 self._killed = True 408 # The thread might be waiting on a condition. 409 if self._condition_to_wait: 410 self._condition_to_wait.acquire() 411 self._condition_to_wait.notify() 412 self._condition_to_wait.release() 413 414 def PerformAction(self, request_messages, reply_message_callback): 415 """Notify this thread of an action to perform using the remote inspector. 416 417 Args: 418 request_messages: A list of strings representing the requests to make 419 using the remote inspector. 420 reply_message_callback: A callable to be invoked any time a message is 421 received from the remote inspector while the current action is 422 being performed. The callable should accept a single argument, 423 which is a dictionary representing a message received. 424 """ 425 self._action_queue_condition.acquire() 426 self._action_queue.append((request_messages, reply_message_callback)) 427 self._action_queue_condition.notify() 428 self._action_queue_condition.release() 429 430 def AddMessageCallback(self, callback): 431 """Add a callback to invoke for messages received from the remote inspector. 432 433 Args: 434 callback: A callable to be invoked any time a message is received from the 435 remote inspector. The callable should accept a single argument, which 436 is a dictionary representing a message received. 437 """ 438 self._general_callbacks_lock.acquire() 439 self._general_callbacks.append(callback) 440 self._general_callbacks_lock.release() 441 442 def RemoveMessageCallback(self, callback): 443 """Remove a callback from the set of those to invoke for messages received. 444 445 Args: 446 callback: A callable to remove from consideration. 447 """ 448 self._general_callbacks_lock.acquire() 449 self._general_callbacks.remove(callback) 450 self._general_callbacks_lock.release() 451 452 def GetRequestWithId(self, request_id): 453 """Identifies the request with the specified id. 454 455 Args: 456 request_id: An integer request id; should be unique for each request. 457 458 Returns: 459 A request object associated with the given id if found, or 460 None otherwise. 461 """ 462 found_request = [x for x in self._requests if x.id == request_id] 463 if found_request: 464 return found_request[0] 465 return None 466 467 def GetFirstUnfulfilledRequest(self, method): 468 """Identifies the first unfulfilled request with the given method name. 469 470 An unfulfilled request is one for which all relevant reply messages have 471 not yet been received from the remote inspector. 472 473 Args: 474 method: The string method name of the request for which to search. 475 476 Returns: 477 The first request object in the request list that is not yet fulfilled 478 and is also associated with the given method name, or 479 None if no such request object can be found. 480 """ 481 for request in self._requests: 482 if not request.is_fulfilled and request.method == method: 483 return request 484 return None 485 486 def _GetLatestRequestOfType(self, ref_req, method): 487 """Identifies the latest specified request before a reference request. 488 489 This function finds the latest request with the specified method that 490 occurs before the given reference request. 491 492 Args: 493 ref_req: A reference request from which to start looking. 494 method: The string method name of the request for which to search. 495 496 Returns: 497 The latest _DevToolsSocketRequest object with the specified method, 498 if found, or None otherwise. 499 """ 500 start_looking = False 501 for request in self._requests[::-1]: 502 if request.id == ref_req.id: 503 start_looking = True 504 elif start_looking: 505 if request.method == method: 506 return request 507 return None 508 509 def _FillInParams(self, request): 510 """Fills in parameters for requests as necessary before the request is sent. 511 512 Args: 513 request: The _DevToolsSocketRequest object associated with a request 514 message that is about to be sent. 515 """ 516 if request.method == self._agent_name +'.takeHeapSnapshot': 517 # We always want detailed v8 heap snapshot information. 518 request.params = {'detailed': True} 519 elif request.method == self._agent_name + '.getHeapSnapshot': 520 # To actually request the snapshot data from a previously-taken snapshot, 521 # we need to specify the unique uid of the snapshot we want. 522 # The relevant uid should be contained in the last 523 # 'Profiler.takeHeapSnapshot' request object. 524 last_req = self._GetLatestRequestOfType(request, 525 self._agent_name + '.takeHeapSnapshot') 526 if last_req and 'uid' in last_req.results: 527 request.params = {'uid': last_req.results['uid']} 528 elif request.method == self._agent_name + '.getProfile': 529 # TODO(eustas): Remove this case after M27 is released. 530 last_req = self._GetLatestRequestOfType(request, 531 self._agent_name + '.takeHeapSnapshot') 532 if last_req and 'uid' in last_req.results: 533 request.params = {'type': 'HEAP', 'uid': last_req.results['uid']} 534 535 @staticmethod 536 def _IdentifyDevToolsSocketConnectionInfo(url, tab_index, tab_filter): 537 """Identifies DevToolsSocket connection info from a remote Chrome instance. 538 539 Args: 540 url: The base URL to connent to. 541 tab_index: The integer index of the tab in the remote Chrome instance to 542 which to connect. 543 tab_filter: When specified, is run over tabs of the remote Chrome instance 544 to choose which one to connect to. 545 546 Returns: 547 A dictionary containing the DevToolsSocket connection info: 548 { 549 'host': string, 550 'port': integer, 551 'path': string, 552 } 553 554 Raises: 555 RuntimeError: When DevToolsSocket connection info cannot be identified. 556 """ 557 try: 558 f = urllib2.urlopen(url + '/json') 559 result = f.read() 560 logging.debug(result) 561 result = simplejson.loads(result) 562 except urllib2.URLError, e: 563 raise RuntimeError( 564 'Error accessing Chrome instance debugging port: ' + str(e)) 565 566 if tab_filter: 567 connect_to = filter(tab_filter, result)[0] 568 else: 569 if tab_index >= len(result): 570 raise RuntimeError( 571 'Specified tab index %d doesn\'t exist (%d tabs found)' % 572 (tab_index, len(result))) 573 connect_to = result[tab_index] 574 575 logging.debug(simplejson.dumps(connect_to)) 576 577 if 'webSocketDebuggerUrl' not in connect_to: 578 raise RuntimeError('No socket URL exists for the specified tab.') 579 580 socket_url = connect_to['webSocketDebuggerUrl'] 581 parsed = urlparse.urlparse(socket_url) 582 # On ChromeOS, the "ws://" scheme may not be recognized, leading to an 583 # incorrect netloc (and empty hostname and port attributes) in |parsed|. 584 # Change the scheme to "http://" to fix this. 585 if not parsed.hostname or not parsed.port: 586 socket_url = 'http' + socket_url[socket_url.find(':'):] 587 parsed = urlparse.urlparse(socket_url) 588 # Warning: |parsed.scheme| is incorrect after this point. 589 return ({'host': parsed.hostname, 590 'port': parsed.port, 591 'path': parsed.path}) 592 593 594 class _RemoteInspectorDriverThread(threading.Thread): 595 """Drives the communication service with the remote inspector.""" 596 597 def __init__(self): 598 """Initialize.""" 599 threading.Thread.__init__(self) 600 601 def run(self): 602 """Drives the communication service with the remote inspector.""" 603 try: 604 while asyncore.socket_map: 605 asyncore.loop(timeout=1, count=1, use_poll=True) 606 except KeyboardInterrupt: 607 pass 608 609 610 class _V8HeapSnapshotParser(object): 611 """Parses v8 heap snapshot data.""" 612 _CHILD_TYPES = ['context', 'element', 'property', 'internal', 'hidden', 613 'shortcut', 'weak'] 614 _NODE_TYPES = ['hidden', 'array', 'string', 'object', 'code', 'closure', 615 'regexp', 'number', 'native', 'synthetic'] 616 617 @staticmethod 618 def ParseSnapshotData(raw_data): 619 """Parses raw v8 heap snapshot data and returns the summarized results. 620 621 The raw heap snapshot data is represented as a JSON object with the 622 following keys: 'snapshot', 'nodes', and 'strings'. 623 624 The 'snapshot' value provides the 'title' and 'uid' attributes for the 625 snapshot. For example: 626 { u'title': u'org.webkit.profiles.user-initiated.1', u'uid': 1} 627 628 The 'nodes' value is a list of node information from the v8 heap, with a 629 special first element that describes the node serialization layout (see 630 HeapSnapshotJSONSerializer::SerializeNodes). All other list elements 631 contain information about nodes in the v8 heap, according to the 632 serialization layout. 633 634 The 'strings' value is a list of strings, indexed by values in the 'nodes' 635 list to associate nodes with strings. 636 637 Args: 638 raw_data: A string representing the raw v8 heap snapshot data. 639 640 Returns: 641 A dictionary containing the summarized v8 heap snapshot data: 642 { 643 'total_v8_node_count': integer, # Total number of nodes in the v8 heap. 644 'total_shallow_size': integer, # Total heap size, in bytes. 645 } 646 """ 647 total_node_count = 0 648 total_shallow_size = 0 649 constructors = {} 650 651 # TODO(dennisjeffrey): The following line might be slow, especially on 652 # ChromeOS. Investigate faster alternatives. 653 heap = simplejson.loads(raw_data) 654 655 index = 1 # Bypass the special first node list item. 656 node_list = heap['nodes'] 657 while index < len(node_list): 658 node_type = node_list[index] 659 node_name = node_list[index + 1] 660 node_id = node_list[index + 2] 661 node_self_size = node_list[index + 3] 662 node_retained_size = node_list[index + 4] 663 node_dominator = node_list[index + 5] 664 node_children_count = node_list[index + 6] 665 index += 7 666 667 node_children = [] 668 for i in xrange(node_children_count): 669 child_type = node_list[index] 670 child_type_string = _V8HeapSnapshotParser._CHILD_TYPES[int(child_type)] 671 child_name_index = node_list[index + 1] 672 child_to_node = node_list[index + 2] 673 index += 3 674 675 child_info = { 676 'type': child_type_string, 677 'name_or_index': child_name_index, 678 'to_node': child_to_node, 679 } 680 node_children.append(child_info) 681 682 # Get the constructor string for this node so nodes can be grouped by 683 # constructor. 684 # See HeapSnapshot.js: WebInspector.HeapSnapshotNode.prototype. 685 type_string = _V8HeapSnapshotParser._NODE_TYPES[int(node_type)] 686 constructor_name = None 687 if type_string == 'hidden': 688 constructor_name = '(system)' 689 elif type_string == 'object': 690 constructor_name = heap['strings'][int(node_name)] 691 elif type_string == 'native': 692 pos = heap['strings'][int(node_name)].find('/') 693 if pos >= 0: 694 constructor_name = heap['strings'][int(node_name)][:pos].rstrip() 695 else: 696 constructor_name = heap['strings'][int(node_name)] 697 elif type_string == 'code': 698 constructor_name = '(compiled code)' 699 else: 700 constructor_name = '(' + type_string + ')' 701 702 node_obj = { 703 'type': type_string, 704 'name': heap['strings'][int(node_name)], 705 'id': node_id, 706 'self_size': node_self_size, 707 'retained_size': node_retained_size, 708 'dominator': node_dominator, 709 'children_count': node_children_count, 710 'children': node_children, 711 } 712 713 if constructor_name not in constructors: 714 constructors[constructor_name] = [] 715 constructors[constructor_name].append(node_obj) 716 717 total_node_count += 1 718 total_shallow_size += node_self_size 719 720 # TODO(dennisjeffrey): Have this function also return more detailed v8 721 # heap snapshot data when a need for it arises (e.g., using |constructors|). 722 result = {} 723 result['total_v8_node_count'] = total_node_count 724 result['total_shallow_size'] = total_shallow_size 725 return result 726 727 728 # TODO(dennisjeffrey): The "verbose" option used in this file should re-use 729 # pyauto's verbose flag. 730 class RemoteInspectorClient(object): 731 """Main class for interacting with Chrome's remote inspector. 732 733 Upon initialization, a socket connection to Chrome's remote inspector will 734 be established. Users of this class should call Stop() to close the 735 connection when it's no longer needed. 736 737 Public Methods: 738 Stop: Close the connection to the remote inspector. Should be called when 739 a user is done using this module. 740 HeapSnapshot: Takes a v8 heap snapshot and returns the summarized data. 741 GetMemoryObjectCounts: Retrieves memory object count information. 742 CollectGarbage: Forces a garbage collection. 743 StartTimelineEventMonitoring: Starts monitoring for timeline events. 744 StopTimelineEventMonitoring: Stops monitoring for timeline events. 745 """ 746 747 # TODO(dennisjeffrey): Allow a user to specify a window index too (not just a 748 # tab index), when running through PyAuto. 749 def __init__(self, tab_index=0, tab_filter=None, 750 verbose=False, show_socket_messages=False, 751 url='http://localhost:9222'): 752 """Initialize. 753 754 Args: 755 tab_index: The integer index of the tab in the remote Chrome instance to 756 which to connect. Defaults to 0 (the first tab). 757 tab_filter: When specified, is run over tabs of the remote Chrome 758 instance to choose which one to connect to. 759 verbose: A boolean indicating whether or not to use verbose logging. 760 show_socket_messages: A boolean indicating whether or not to show the 761 socket messages sent/received when communicating with the remote 762 Chrome instance. 763 """ 764 self._tab_index = tab_index 765 self._tab_filter = tab_filter 766 self._verbose = verbose 767 self._show_socket_messages = show_socket_messages 768 769 self._timeline_started = False 770 771 logging.basicConfig() 772 self._logger = logging.getLogger('RemoteInspectorClient') 773 self._logger.setLevel([logging.WARNING, logging.DEBUG][verbose]) 774 775 # Creating _RemoteInspectorThread might raise an exception. This prevents an 776 # AttributeError in the destructor. 777 self._remote_inspector_thread = None 778 self._remote_inspector_driver_thread = None 779 780 self._version = self._GetVersion(url) 781 782 # TODO(loislo): Remove this hack after M28 is released. 783 self._agent_name = 'Profiler' 784 if self._IsBrowserDayNumberGreaterThan(1470): 785 self._agent_name = 'HeapProfiler' 786 787 # Start up a thread for long-term communication with the remote inspector. 788 self._remote_inspector_thread = _RemoteInspectorThread( 789 url, tab_index, tab_filter, verbose, show_socket_messages, 790 self._agent_name) 791 self._remote_inspector_thread.start() 792 # At this point, a connection has already been made to the remote inspector. 793 794 # This thread calls asyncore.loop, which activates the channel service. 795 self._remote_inspector_driver_thread = _RemoteInspectorDriverThread() 796 self._remote_inspector_driver_thread.start() 797 798 def __del__(self): 799 """Called on destruction of this object.""" 800 self.Stop() 801 802 def Stop(self): 803 """Stop/close communication with the remote inspector.""" 804 if self._remote_inspector_thread: 805 self._remote_inspector_thread.Kill() 806 self._remote_inspector_thread.join() 807 self._remote_inspector_thread = None 808 if self._remote_inspector_driver_thread: 809 self._remote_inspector_driver_thread.join() 810 self._remote_inspector_driver_thread = None 811 812 def HeapSnapshot(self, include_summary=False): 813 """Takes a v8 heap snapshot. 814 815 Returns: 816 A dictionary containing information for a single v8 heap 817 snapshot that was taken. 818 { 819 'url': string, # URL of the webpage that was snapshotted. 820 'raw_data': string, # The raw data as JSON string. 821 'total_v8_node_count': integer, # Total number of nodes in the v8 heap. 822 # Only if |include_summary| is True. 823 'total_heap_size': integer, # Total v8 heap size (number of bytes). 824 # Only if |include_summary| is True. 825 } 826 """ 827 HEAP_SNAPSHOT_MESSAGES = [ 828 ('Page.getResourceTree', {}), 829 ('Debugger.enable', {}), 830 (self._agent_name + '.clearProfiles', {}), 831 (self._agent_name + '.takeHeapSnapshot', {}), 832 (self._agent_name + '.getHeapSnapshot', {}), 833 ] 834 835 self._current_heap_snapshot = [] 836 self._url = '' 837 self._collected_heap_snapshot_data = {} 838 839 done_condition = threading.Condition() 840 841 def HandleReply(reply_dict): 842 """Processes a reply message received from the remote Chrome instance. 843 844 Args: 845 reply_dict: A dictionary object representing the reply message received 846 from the remote inspector. 847 """ 848 if 'result' in reply_dict: 849 # This is the result message associated with a previously-sent request. 850 request = self._remote_inspector_thread.GetRequestWithId( 851 reply_dict['id']) 852 if 'frameTree' in reply_dict['result']: 853 self._url = reply_dict['result']['frameTree']['frame']['url'] 854 elif request.method == self._agent_name + '.getHeapSnapshot': 855 # A heap snapshot has been completed. Analyze and output the data. 856 self._logger.debug('Heap snapshot taken: %s', self._url) 857 # TODO(dennisjeffrey): Parse the heap snapshot on-the-fly as the data 858 # is coming in over the wire, so we can avoid storing the entire 859 # snapshot string in memory. 860 raw_snapshot_data = ''.join(self._current_heap_snapshot) 861 self._collected_heap_snapshot_data = { 862 'url': self._url, 863 'raw_data': raw_snapshot_data} 864 if include_summary: 865 self._logger.debug('Now analyzing heap snapshot...') 866 parser = _V8HeapSnapshotParser() 867 time_start = time.time() 868 self._logger.debug('Raw snapshot data size: %.2f MB', 869 len(raw_snapshot_data) / (1024.0 * 1024.0)) 870 result = parser.ParseSnapshotData(raw_snapshot_data) 871 self._logger.debug('Time to parse data: %.2f sec', 872 time.time() - time_start) 873 count = result['total_v8_node_count'] 874 self._collected_heap_snapshot_data['total_v8_node_count'] = count 875 total_size = result['total_shallow_size'] 876 self._collected_heap_snapshot_data['total_heap_size'] = total_size 877 878 done_condition.acquire() 879 done_condition.notify() 880 done_condition.release() 881 elif 'method' in reply_dict: 882 # This is an auxiliary message sent from the remote Chrome instance. 883 if reply_dict['method'] == self._agent_name + '.addProfileHeader': 884 snapshot_req = ( 885 self._remote_inspector_thread.GetFirstUnfulfilledRequest( 886 self._agent_name + '.takeHeapSnapshot')) 887 if snapshot_req: 888 snapshot_req.results['uid'] = reply_dict['params']['header']['uid'] 889 elif reply_dict['method'] == self._agent_name + '.addHeapSnapshotChunk': 890 self._current_heap_snapshot.append(reply_dict['params']['chunk']) 891 892 # Tell the remote inspector to take a v8 heap snapshot, then wait until 893 # the snapshot information is available to return. 894 self._remote_inspector_thread.PerformAction(HEAP_SNAPSHOT_MESSAGES, 895 HandleReply) 896 897 done_condition.acquire() 898 done_condition.wait() 899 done_condition.release() 900 901 return self._collected_heap_snapshot_data 902 903 def EvaluateJavaScript(self, expression): 904 """Evaluates a JavaScript expression and returns the result. 905 906 Sends a message containing the expression to the remote Chrome instance we 907 are connected to, and evaluates it in the context of the tab we are 908 connected to. Blocks until the result is available and returns it. 909 910 Returns: 911 A dictionary representing the result. 912 """ 913 EVALUATE_MESSAGES = [ 914 ('Runtime.evaluate', { 'expression': expression, 915 'objectGroup': 'group', 916 'returnByValue': True }), 917 ('Runtime.releaseObjectGroup', { 'objectGroup': 'group' }) 918 ] 919 920 self._result = None 921 done_condition = threading.Condition() 922 923 def HandleReply(reply_dict): 924 """Processes a reply message received from the remote Chrome instance. 925 926 Args: 927 reply_dict: A dictionary object representing the reply message received 928 from the remote Chrome instance. 929 """ 930 if 'result' in reply_dict and 'result' in reply_dict['result']: 931 self._result = reply_dict['result']['result']['value'] 932 933 done_condition.acquire() 934 done_condition.notify() 935 done_condition.release() 936 937 # Tell the remote inspector to evaluate the given expression, then wait 938 # until that information is available to return. 939 self._remote_inspector_thread.PerformAction(EVALUATE_MESSAGES, 940 HandleReply) 941 942 done_condition.acquire() 943 done_condition.wait() 944 done_condition.release() 945 946 return self._result 947 948 def GetMemoryObjectCounts(self): 949 """Retrieves memory object count information. 950 951 Returns: 952 A dictionary containing the memory object count information: 953 { 954 'DOMNodeCount': integer, # Total number of DOM nodes. 955 'EventListenerCount': integer, # Total number of event listeners. 956 } 957 """ 958 MEMORY_COUNT_MESSAGES = [ 959 ('Memory.getDOMCounters', {}) 960 ] 961 962 self._event_listener_count = None 963 self._dom_node_count = None 964 965 done_condition = threading.Condition() 966 def HandleReply(reply_dict): 967 """Processes a reply message received from the remote Chrome instance. 968 969 Args: 970 reply_dict: A dictionary object representing the reply message received 971 from the remote Chrome instance. 972 """ 973 if 'result' in reply_dict: 974 self._event_listener_count = reply_dict['result']['jsEventListeners'] 975 self._dom_node_count = reply_dict['result']['nodes'] 976 977 done_condition.acquire() 978 done_condition.notify() 979 done_condition.release() 980 981 # Tell the remote inspector to collect memory count info, then wait until 982 # that information is available to return. 983 self._remote_inspector_thread.PerformAction(MEMORY_COUNT_MESSAGES, 984 HandleReply) 985 986 done_condition.acquire() 987 done_condition.wait() 988 done_condition.release() 989 990 return { 991 'DOMNodeCount': self._dom_node_count, 992 'EventListenerCount': self._event_listener_count, 993 } 994 995 def CollectGarbage(self): 996 """Forces a garbage collection.""" 997 COLLECT_GARBAGE_MESSAGES = [ 998 ('Profiler.collectGarbage', {}) 999 ] 1000 1001 # Tell the remote inspector to do a garbage collect. We can return 1002 # immediately, since there is no result for which to wait. 1003 self._remote_inspector_thread.PerformAction(COLLECT_GARBAGE_MESSAGES, None) 1004 1005 def StartTimelineEventMonitoring(self, event_callback): 1006 """Starts timeline event monitoring. 1007 1008 Args: 1009 event_callback: A callable to invoke whenever a timeline event is observed 1010 from the remote inspector. The callable should take a single input, 1011 which is a dictionary containing the detailed information of a 1012 timeline event. 1013 """ 1014 if self._timeline_started: 1015 self._logger.warning('Timeline monitoring already started.') 1016 return 1017 TIMELINE_MESSAGES = [ 1018 ('Timeline.start', {}) 1019 ] 1020 1021 self._event_callback = event_callback 1022 1023 done_condition = threading.Condition() 1024 def HandleReply(reply_dict): 1025 """Processes a reply message received from the remote Chrome instance. 1026 1027 Args: 1028 reply_dict: A dictionary object representing the reply message received 1029 from the remote Chrome instance. 1030 """ 1031 if 'result' in reply_dict: 1032 done_condition.acquire() 1033 done_condition.notify() 1034 done_condition.release() 1035 if reply_dict.get('method') == 'Timeline.eventRecorded': 1036 self._event_callback(reply_dict['params']['record']) 1037 1038 # Tell the remote inspector to start the timeline. 1039 self._timeline_callback = HandleReply 1040 self._remote_inspector_thread.AddMessageCallback(self._timeline_callback) 1041 self._remote_inspector_thread.PerformAction(TIMELINE_MESSAGES, None) 1042 1043 done_condition.acquire() 1044 done_condition.wait() 1045 done_condition.release() 1046 1047 self._timeline_started = True 1048 1049 def StopTimelineEventMonitoring(self): 1050 """Stops timeline event monitoring.""" 1051 if not self._timeline_started: 1052 self._logger.warning('Timeline monitoring already stopped.') 1053 return 1054 TIMELINE_MESSAGES = [ 1055 ('Timeline.stop', {}) 1056 ] 1057 1058 done_condition = threading.Condition() 1059 def HandleReply(reply_dict): 1060 """Processes a reply message received from the remote Chrome instance. 1061 1062 Args: 1063 reply_dict: A dictionary object representing the reply message received 1064 from the remote Chrome instance. 1065 """ 1066 if 'result' in reply_dict: 1067 done_condition.acquire() 1068 done_condition.notify() 1069 done_condition.release() 1070 1071 # Tell the remote inspector to stop the timeline. 1072 self._remote_inspector_thread.RemoveMessageCallback(self._timeline_callback) 1073 self._remote_inspector_thread.PerformAction(TIMELINE_MESSAGES, HandleReply) 1074 1075 done_condition.acquire() 1076 done_condition.wait() 1077 done_condition.release() 1078 1079 self._timeline_started = False 1080 1081 def _ConvertByteCountToHumanReadableString(self, num_bytes): 1082 """Converts an integer number of bytes into a human-readable string. 1083 1084 Args: 1085 num_bytes: An integer number of bytes. 1086 1087 Returns: 1088 A human-readable string representation of the given number of bytes. 1089 """ 1090 if num_bytes < 1024: 1091 return '%d B' % num_bytes 1092 elif num_bytes < 1048576: 1093 return '%.2f KB' % (num_bytes / 1024.0) 1094 else: 1095 return '%.2f MB' % (num_bytes / 1048576.0) 1096 1097 @staticmethod 1098 def _GetVersion(endpoint): 1099 """Fetches version information from a remote Chrome instance. 1100 1101 Args: 1102 endpoint: The base URL to connent to. 1103 1104 Returns: 1105 A dictionary containing Browser and Content version information: 1106 { 1107 'Browser': { 1108 'major': integer, 1109 'minor': integer, 1110 'fix': integer, 1111 'day': integer 1112 }, 1113 'Content': { 1114 'name': string, 1115 'major': integer, 1116 'minor': integer 1117 } 1118 } 1119 1120 Raises: 1121 RuntimeError: When Browser version info can't be fetched or parsed. 1122 """ 1123 try: 1124 f = urllib2.urlopen(endpoint + '/json/version') 1125 result = f.read(); 1126 result = simplejson.loads(result) 1127 except urllib2.URLError, e: 1128 raise RuntimeError( 1129 'Error accessing Chrome instance debugging port: ' + str(e)) 1130 1131 if 'Browser' not in result: 1132 raise RuntimeError('Browser version is not specified.') 1133 1134 parsed = re.search('^Chrome\/(\d+).(\d+).(\d+).(\d+)', result['Browser']) 1135 if parsed is None: 1136 raise RuntimeError('Browser-Version cannot be parsed.') 1137 try: 1138 day = int(parsed.group(3)) 1139 browser_info = { 1140 'major': int(parsed.group(1)), 1141 'minor': int(parsed.group(2)), 1142 'day': day, 1143 'fix': int(parsed.group(4)), 1144 } 1145 except ValueError: 1146 raise RuntimeError('Browser-Version cannot be parsed.') 1147 1148 if 'WebKit-Version' not in result: 1149 raise RuntimeError('Content-Version is not specified.') 1150 1151 parsed = re.search('^(\d+)\.(\d+)', result['WebKit-Version']) 1152 if parsed is None: 1153 raise RuntimeError('Content-Version cannot be parsed.') 1154 1155 try: 1156 platform_info = { 1157 'name': 'Blink' if day > 1464 else 'WebKit', 1158 'major': int(parsed.group(1)), 1159 'minor': int(parsed.group(2)), 1160 } 1161 except ValueError: 1162 raise RuntimeError('WebKit-Version cannot be parsed.') 1163 1164 return { 1165 'browser': browser_info, 1166 'platform': platform_info 1167 } 1168 1169 def _IsContentVersionNotOlderThan(self, major, minor): 1170 """Compares remote Browser Content version with specified one. 1171 1172 Args: 1173 major: Major Webkit version. 1174 minor: Minor Webkit version. 1175 1176 Returns: 1177 True if remote Content version is same or newer than specified, 1178 False otherwise. 1179 1180 Raises: 1181 RuntimeError: If remote Content version hasn't been fetched yet. 1182 """ 1183 if not hasattr(self, '_version'): 1184 raise RuntimeError('Browser version has not been fetched yet.') 1185 version = self._version['platform'] 1186 1187 if version['major'] < major: 1188 return False 1189 elif version['major'] == major and version['minor'] < minor: 1190 return False 1191 else: 1192 return True 1193 1194 def _IsBrowserDayNumberGreaterThan(self, day_number): 1195 """Compares remote Chromium day number with specified one. 1196 1197 Args: 1198 day_number: Forth part of the chromium version. 1199 1200 Returns: 1201 True if remote Chromium day number is same or newer than specified, 1202 False otherwise. 1203 1204 Raises: 1205 RuntimeError: If remote Chromium version hasn't been fetched yet. 1206 """ 1207 if not hasattr(self, '_version'): 1208 raise RuntimeError('Browser revision has not been fetched yet.') 1209 version = self._version['browser'] 1210 1211 return version['day'] > day_number 1212