1# Copyright 2014 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import logging
6import re
7import socket
8import sys
9
10from telemetry.core import exceptions
11from telemetry import decorators
12from telemetry.internal.backends import browser_backend
13from telemetry.internal.backends.chrome_inspector import devtools_http
14from telemetry.internal.backends.chrome_inspector import inspector_backend
15from telemetry.internal.backends.chrome_inspector import inspector_websocket
16from telemetry.internal.backends.chrome_inspector import memory_backend
17from telemetry.internal.backends.chrome_inspector import tracing_backend
18from telemetry.internal.backends.chrome_inspector import websocket
19from telemetry.internal.platform.tracing_agent import chrome_tracing_agent
20from telemetry.internal.platform.tracing_agent import (
21    chrome_tracing_devtools_manager)
22from telemetry.timeline import trace_data as trace_data_module
23
24
25BROWSER_INSPECTOR_WEBSOCKET_URL = 'ws://127.0.0.1:%i/devtools/browser'
26
27
28class TabNotFoundError(exceptions.Error):
29  pass
30
31
32def IsDevToolsAgentAvailable(port, app_backend):
33  """Returns True if a DevTools agent is available on the given port."""
34  if (isinstance(app_backend, browser_backend.BrowserBackend) and
35      app_backend.supports_tracing):
36    inspector_websocket_instance = inspector_websocket.InspectorWebsocket()
37    try:
38      if not _IsInspectorWebsocketAvailable(inspector_websocket_instance, port):
39        return False
40    finally:
41      inspector_websocket_instance.Disconnect()
42
43  devtools_http_instance = devtools_http.DevToolsHttp(port)
44  try:
45    return _IsDevToolsAgentAvailable(devtools_http_instance)
46  finally:
47    devtools_http_instance.Disconnect()
48
49
50def _IsInspectorWebsocketAvailable(inspector_websocket_instance, port):
51  try:
52    inspector_websocket_instance.Connect(BROWSER_INSPECTOR_WEBSOCKET_URL % port)
53  except websocket.WebSocketException:
54    return False
55  except socket.error:
56    return False
57  except Exception as e:
58    sys.stderr.write('Unidentified exception while checking if wesocket is'
59                     'available on port %i. Exception message: %s\n' %
60                     (port, e.message))
61    return False
62  else:
63    return True
64
65
66# TODO(nednguyen): Find a more reliable way to check whether the devtool agent
67# is still alive.
68def _IsDevToolsAgentAvailable(devtools_http_instance):
69  try:
70    devtools_http_instance.Request('')
71  except devtools_http.DevToolsClientConnectionError:
72    return False
73  else:
74    return True
75
76
77class DevToolsClientBackend(object):
78  """An object that communicates with Chrome's devtools.
79
80  This class owns a map of InspectorBackends. It is responsible for creating
81  them and destroying them.
82  """
83  def __init__(self, devtools_port, remote_devtools_port, app_backend):
84    """Creates a new DevToolsClientBackend.
85
86    A DevTools agent must exist on the given devtools_port.
87
88    Args:
89      devtools_port: The port to use to connect to DevTools agent.
90      remote_devtools_port: In some cases (e.g., app running on
91          Android device, devtools_port is the forwarded port on the
92          host platform. We also need to know the remote_devtools_port
93          so that we can uniquely identify the DevTools agent.
94      app_backend: For the app that contains the DevTools agent.
95    """
96    self._devtools_port = devtools_port
97    self._remote_devtools_port = remote_devtools_port
98    self._devtools_http = devtools_http.DevToolsHttp(devtools_port)
99    self._browser_inspector_websocket = None
100    self._tracing_backend = None
101    self._memory_backend = None
102    self._app_backend = app_backend
103    self._devtools_context_map_backend = _DevToolsContextMapBackend(
104        self._app_backend, self)
105
106    if not self.supports_tracing:
107      return
108    chrome_tracing_devtools_manager.RegisterDevToolsClient(
109        self, self._app_backend.platform_backend)
110
111    # Telemetry has started Chrome tracing if there is trace config, so start
112    # tracing on this newly created devtools client if needed.
113    trace_config = (self._app_backend.platform_backend
114                    .tracing_controller_backend.GetChromeTraceConfig())
115    if not trace_config:
116      self._CreateTracingBackendIfNeeded(is_tracing_running=False)
117      return
118
119    if self.support_startup_tracing:
120      self._CreateTracingBackendIfNeeded(is_tracing_running=True)
121      return
122
123    self._CreateTracingBackendIfNeeded(is_tracing_running=False)
124    self.StartChromeTracing(
125        trace_config=trace_config,
126        custom_categories=trace_config.tracing_category_filter.filter_string)
127
128  @property
129  def remote_port(self):
130    return self._remote_devtools_port
131
132  @property
133  def supports_tracing(self):
134    if not isinstance(self._app_backend, browser_backend.BrowserBackend):
135      return False
136    return self._app_backend.supports_tracing
137
138  @property
139  def supports_overriding_memory_pressure_notifications(self):
140    if not isinstance(self._app_backend, browser_backend.BrowserBackend):
141      return False
142    return self._app_backend.supports_overriding_memory_pressure_notifications
143
144
145  @property
146  def is_tracing_running(self):
147    if not self.supports_tracing:
148      return False
149    if not self._tracing_backend:
150      return False
151    return self._tracing_backend.is_tracing_running
152
153  @property
154  def support_startup_tracing(self):
155    # Startup tracing with --trace-config-file flag was not supported until
156    # Chromium branch number 2512 (see crrev.com/1309243004 and
157    # crrev.com/1353583002).
158    if not chrome_tracing_agent.ChromeTracingAgent.IsStartupTracingSupported(
159        self._app_backend.platform_backend):
160      return False
161    # TODO(zhenw): Remove this once stable Chrome and reference browser have
162    # passed 2512.
163    return self.GetChromeBranchNumber() >= 2512
164
165  def IsAlive(self):
166    """Whether the DevTools server is available and connectable."""
167    return (self._devtools_http and
168        _IsDevToolsAgentAvailable(self._devtools_http))
169
170  def Close(self):
171    if self._tracing_backend:
172      self._tracing_backend.Close()
173      self._tracing_backend = None
174    if self._memory_backend:
175      self._memory_backend.Close()
176      self._memory_backend = None
177
178    if self._devtools_context_map_backend:
179      self._devtools_context_map_backend.Clear()
180
181    # Close the browser inspector socket last (in case the backend needs to
182    # interact with it before closing).
183    if self._browser_inspector_websocket:
184      self._browser_inspector_websocket.Disconnect()
185      self._browser_inspector_websocket = None
186
187    assert self._devtools_http
188    self._devtools_http.Disconnect()
189    self._devtools_http = None
190
191
192  @decorators.Cache
193  def GetChromeBranchNumber(self):
194    # Detect version information.
195    resp = self._devtools_http.RequestJson('version')
196    if 'Protocol-Version' in resp:
197      if 'Browser' in resp:
198        branch_number_match = re.search(r'Chrome/\d+\.\d+\.(\d+)\.\d+',
199                                        resp['Browser'])
200      else:
201        branch_number_match = re.search(
202            r'Chrome/\d+\.\d+\.(\d+)\.\d+ (Mobile )?Safari',
203            resp['User-Agent'])
204
205      if branch_number_match:
206        branch_number = int(branch_number_match.group(1))
207        if branch_number:
208          return branch_number
209
210    # Branch number can't be determined, so fail any branch number checks.
211    return 0
212
213  def _ListInspectableContexts(self):
214    return self._devtools_http.RequestJson('')
215
216  def RequestNewTab(self, timeout):
217    """Creates a new tab.
218
219    Returns:
220      A JSON string as returned by DevTools. Example:
221      {
222        "description": "",
223        "devtoolsFrontendUrl":
224            "/devtools/inspector.html?ws=host:port/devtools/page/id-string",
225        "id": "id-string",
226        "title": "Page Title",
227        "type": "page",
228        "url": "url",
229        "webSocketDebuggerUrl": "ws://host:port/devtools/page/id-string"
230      }
231
232    Raises:
233      devtools_http.DevToolsClientConnectionError
234    """
235    return self._devtools_http.Request('new', timeout=timeout)
236
237  def CloseTab(self, tab_id, timeout):
238    """Closes the tab with the given id.
239
240    Raises:
241      devtools_http.DevToolsClientConnectionError
242      TabNotFoundError
243    """
244    try:
245      return self._devtools_http.Request('close/%s' % tab_id,
246                                         timeout=timeout)
247    except devtools_http.DevToolsClientUrlError:
248      error = TabNotFoundError(
249          'Unable to close tab, tab id not found: %s' % tab_id)
250      raise error, None, sys.exc_info()[2]
251
252  def ActivateTab(self, tab_id, timeout):
253    """Activates the tab with the given id.
254
255    Raises:
256      devtools_http.DevToolsClientConnectionError
257      TabNotFoundError
258    """
259    try:
260      return self._devtools_http.Request('activate/%s' % tab_id,
261                                         timeout=timeout)
262    except devtools_http.DevToolsClientUrlError:
263      error = TabNotFoundError(
264          'Unable to activate tab, tab id not found: %s' % tab_id)
265      raise error, None, sys.exc_info()[2]
266
267  def GetUrl(self, tab_id):
268    """Returns the URL of the tab with |tab_id|, as reported by devtools.
269
270    Raises:
271      devtools_http.DevToolsClientConnectionError
272    """
273    for c in self._ListInspectableContexts():
274      if c['id'] == tab_id:
275        return c['url']
276    return None
277
278  def IsInspectable(self, tab_id):
279    """Whether the tab with |tab_id| is inspectable, as reported by devtools.
280
281    Raises:
282      devtools_http.DevToolsClientConnectionError
283    """
284    contexts = self._ListInspectableContexts()
285    return tab_id in [c['id'] for c in contexts]
286
287  def GetUpdatedInspectableContexts(self):
288    """Returns an updated instance of _DevToolsContextMapBackend."""
289    contexts = self._ListInspectableContexts()
290    self._devtools_context_map_backend._Update(contexts)
291    return self._devtools_context_map_backend
292
293  def _CreateTracingBackendIfNeeded(self, is_tracing_running=False):
294    assert self.supports_tracing
295    if not self._tracing_backend:
296      self._CreateAndConnectBrowserInspectorWebsocketIfNeeded()
297      self._tracing_backend = tracing_backend.TracingBackend(
298          self._browser_inspector_websocket, is_tracing_running)
299
300  def _CreateMemoryBackendIfNeeded(self):
301    assert self.supports_overriding_memory_pressure_notifications
302    if not self._memory_backend:
303      self._CreateAndConnectBrowserInspectorWebsocketIfNeeded()
304      self._memory_backend = memory_backend.MemoryBackend(
305          self._browser_inspector_websocket)
306
307  def _CreateAndConnectBrowserInspectorWebsocketIfNeeded(self):
308    if not self._browser_inspector_websocket:
309      self._browser_inspector_websocket = (
310          inspector_websocket.InspectorWebsocket())
311      self._browser_inspector_websocket.Connect(
312          BROWSER_INSPECTOR_WEBSOCKET_URL % self._devtools_port)
313
314  def IsChromeTracingSupported(self):
315    if not self.supports_tracing:
316      return False
317    self._CreateTracingBackendIfNeeded()
318    return self._tracing_backend.IsTracingSupported()
319
320  def StartChromeTracing(
321      self, trace_config, custom_categories=None, timeout=10):
322    """
323    Args:
324        trace_config: An tracing_config.TracingConfig instance.
325        custom_categories: An optional string containing a list of
326                         comma separated categories that will be traced
327                         instead of the default category set.  Example: use
328                         "webkit,cc,disabled-by-default-cc.debug" to trace only
329                         those three event categories.
330    """
331    assert trace_config and trace_config.enable_chrome_trace
332    self._CreateTracingBackendIfNeeded()
333    return self._tracing_backend.StartTracing(
334        trace_config, custom_categories, timeout)
335
336  def StopChromeTracing(self, trace_data_builder, timeout=30):
337    assert self.is_tracing_running
338    try:
339      context_map = self.GetUpdatedInspectableContexts()
340      for context in context_map.contexts:
341        if context['type'] not in ['iframe', 'page', 'webview']:
342          continue
343        context_id = context['id']
344        backend = context_map.GetInspectorBackend(context_id)
345        success = backend.EvaluateJavaScript(
346            "console.time('" + backend.id + "');" +
347            "console.timeEnd('" + backend.id + "');" +
348            "console.time.toString().indexOf('[native code]') != -1;")
349        if not success:
350          raise Exception('Page stomped on console.time')
351        trace_data_builder.AddEventsTo(
352            trace_data_module.TAB_ID_PART, [backend.id])
353    finally:
354      self._tracing_backend.StopTracing(trace_data_builder, timeout)
355
356  def DumpMemory(self, timeout=30):
357    """Dumps memory.
358
359    Returns:
360      GUID of the generated dump if successful, None otherwise.
361
362    Raises:
363      TracingTimeoutException: If more than |timeout| seconds has passed
364      since the last time any data is received.
365      TracingUnrecoverableException: If there is a websocket error.
366      TracingUnexpectedResponseException: If the response contains an error
367      or does not contain the expected result.
368    """
369    self._CreateTracingBackendIfNeeded()
370    return self._tracing_backend.DumpMemory(timeout)
371
372  def SetMemoryPressureNotificationsSuppressed(self, suppressed, timeout=30):
373    """Enable/disable suppressing memory pressure notifications.
374
375    Args:
376      suppressed: If true, memory pressure notifications will be suppressed.
377      timeout: The timeout in seconds.
378
379    Raises:
380      MemoryTimeoutException: If more than |timeout| seconds has passed
381      since the last time any data is received.
382      MemoryUnrecoverableException: If there is a websocket error.
383      MemoryUnexpectedResponseException: If the response contains an error
384      or does not contain the expected result.
385    """
386    self._CreateMemoryBackendIfNeeded()
387    return self._memory_backend.SetMemoryPressureNotificationsSuppressed(
388        suppressed, timeout)
389
390  def SimulateMemoryPressureNotification(self, pressure_level, timeout=30):
391    """Simulate a memory pressure notification.
392
393    Args:
394      pressure level: The memory pressure level of the notification ('moderate'
395      or 'critical').
396      timeout: The timeout in seconds.
397
398    Raises:
399      MemoryTimeoutException: If more than |timeout| seconds has passed
400      since the last time any data is received.
401      MemoryUnrecoverableException: If there is a websocket error.
402      MemoryUnexpectedResponseException: If the response contains an error
403      or does not contain the expected result.
404    """
405    self._CreateMemoryBackendIfNeeded()
406    return self._memory_backend.SimulateMemoryPressureNotification(
407        pressure_level, timeout)
408
409
410class _DevToolsContextMapBackend(object):
411  def __init__(self, app_backend, devtools_client):
412    self._app_backend = app_backend
413    self._devtools_client = devtools_client
414    self._contexts = None
415    self._inspector_backends_dict = {}
416
417  @property
418  def contexts(self):
419    """The most up to date contexts data.
420
421    Returned in the order returned by devtools agent."""
422    return self._contexts
423
424  def GetContextInfo(self, context_id):
425    for context in self._contexts:
426      if context['id'] == context_id:
427        return context
428    raise KeyError('Cannot find a context with id=%s' % context_id)
429
430  def GetInspectorBackend(self, context_id):
431    """Gets an InspectorBackend instance for the given context_id.
432
433    This lazily creates InspectorBackend for the context_id if it does
434    not exist yet. Otherwise, it will return the cached instance."""
435    if context_id in self._inspector_backends_dict:
436      return self._inspector_backends_dict[context_id]
437
438    for context in self._contexts:
439      if context['id'] == context_id:
440        new_backend = inspector_backend.InspectorBackend(
441            self._app_backend.app, self._devtools_client, context)
442        self._inspector_backends_dict[context_id] = new_backend
443        return new_backend
444
445    raise KeyError('Cannot find a context with id=%s' % context_id)
446
447  def _Update(self, contexts):
448    # Remove InspectorBackend that is not in the current inspectable
449    # contexts list.
450    context_ids = [context['id'] for context in contexts]
451    for context_id in self._inspector_backends_dict.keys():
452      if context_id not in context_ids:
453        backend = self._inspector_backends_dict[context_id]
454        backend.Disconnect()
455        del self._inspector_backends_dict[context_id]
456
457    valid_contexts = []
458    for context in contexts:
459      # If the context does not have webSocketDebuggerUrl, skip it.
460      # If an InspectorBackend is already created for the tab,
461      # webSocketDebuggerUrl will be missing, and this is expected.
462      context_id = context['id']
463      if context_id not in self._inspector_backends_dict:
464        if 'webSocketDebuggerUrl' not in context:
465          logging.debug('webSocketDebuggerUrl missing, removing %s'
466                        % context_id)
467          continue
468      valid_contexts.append(context)
469    self._contexts = valid_contexts
470
471  def Clear(self):
472    for backend in self._inspector_backends_dict.values():
473      backend.Disconnect()
474    self._inspector_backends_dict = {}
475    self._contexts = None
476