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