• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
8This script provides a python interface that acts as a front-end for Chrome's
9remote inspector module, communicating via sockets to interact with Chrome in
10the same way that the Developer Tools does.  This -- in theory -- should allow
11a pyauto test to do anything that Chrome's Developer Tools does, as long as the
12appropriate communication with the remote inspector is implemented in this
13script.
14
15This script assumes that Chrome is already running on the local machine with
16flag '--remote-debugging-port=9222' to enable remote debugging on port 9222.
17
18To use this module, first create an instance of class RemoteInspectorClient;
19doing this sets up a connection to Chrome's remote inspector.  Then call the
20appropriate functions on that object to perform the desired actions with the
21remote inspector.  When done, call Stop() on the RemoteInspectorClient object
22to stop communication with the remote inspector.
23
24For example, to take v8 heap snapshots from a pyauto test:
25
26import remote_inspector_client
27my_client = remote_inspector_client.RemoteInspectorClient()
28snapshot_info = my_client.HeapSnapshot(include_summary=True)
29// Do some stuff...
30new_snapshot_info = my_client.HeapSnapshot(include_summary=True)
31my_client.Stop()
32
33It is expected that a test will only use one instance of RemoteInspectorClient
34at a time.  If a second instance is instantiated, a RuntimeError will be raised.
35RemoteInspectorClient could be made into a singleton in the future if the need
36for it arises.
37"""
38
39import asyncore
40import datetime
41import logging
42import optparse
43import pprint
44import re
45import simplejson
46import socket
47import sys
48import threading
49import time
50import urllib2
51import urlparse
52
53
54class _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
101class _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
274class _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
594class _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
610class _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.
730class 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