diff options
Diffstat (limited to 'catapult/telemetry/telemetry/internal/backends/chrome_inspector/devtools_client_backend.py')
-rw-r--r-- | catapult/telemetry/telemetry/internal/backends/chrome_inspector/devtools_client_backend.py | 490 |
1 files changed, 490 insertions, 0 deletions
diff --git a/catapult/telemetry/telemetry/internal/backends/chrome_inspector/devtools_client_backend.py b/catapult/telemetry/telemetry/internal/backends/chrome_inspector/devtools_client_backend.py new file mode 100644 index 00000000..dc24172f --- /dev/null +++ b/catapult/telemetry/telemetry/internal/backends/chrome_inspector/devtools_client_backend.py @@ -0,0 +1,490 @@ +# Copyright 2014 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import logging +import re +import socket +import sys + +from telemetry.core import exceptions +from telemetry import decorators +from telemetry.internal.backends import browser_backend +from telemetry.internal.backends.chrome_inspector import devtools_http +from telemetry.internal.backends.chrome_inspector import inspector_backend +from telemetry.internal.backends.chrome_inspector import inspector_websocket +from telemetry.internal.backends.chrome_inspector import memory_backend +from telemetry.internal.backends.chrome_inspector import tracing_backend +from telemetry.internal.backends.chrome_inspector import websocket +from telemetry.internal.platform.tracing_agent import chrome_tracing_agent +from telemetry.internal.platform.tracing_agent import ( + chrome_tracing_devtools_manager) +from telemetry.timeline import trace_data as trace_data_module + + +BROWSER_INSPECTOR_WEBSOCKET_URL = 'ws://127.0.0.1:%i/devtools/browser' + + +class TabNotFoundError(exceptions.Error): + pass + + +def IsDevToolsAgentAvailable(port, app_backend): + """Returns True if a DevTools agent is available on the given port.""" + if (isinstance(app_backend, browser_backend.BrowserBackend) and + app_backend.supports_tracing): + inspector_websocket_instance = inspector_websocket.InspectorWebsocket() + try: + if not _IsInspectorWebsocketAvailable(inspector_websocket_instance, port): + return False + finally: + inspector_websocket_instance.Disconnect() + + devtools_http_instance = devtools_http.DevToolsHttp(port) + try: + return _IsDevToolsAgentAvailable(devtools_http_instance) + finally: + devtools_http_instance.Disconnect() + + +def _IsInspectorWebsocketAvailable(inspector_websocket_instance, port): + try: + inspector_websocket_instance.Connect( + BROWSER_INSPECTOR_WEBSOCKET_URL % port, timeout=10) + except websocket.WebSocketException: + return False + except socket.error: + return False + except Exception as e: + sys.stderr.write('Unidentified exception while checking if wesocket is' + 'available on port %i. Exception message: %s\n' % + (port, e.message)) + return False + else: + return True + + +# TODO(nednguyen): Find a more reliable way to check whether the devtool agent +# is still alive. +def _IsDevToolsAgentAvailable(devtools_http_instance): + try: + devtools_http_instance.Request('') + except devtools_http.DevToolsClientConnectionError: + return False + else: + return True + + +class DevToolsClientBackend(object): + """An object that communicates with Chrome's devtools. + + This class owns a map of InspectorBackends. It is responsible for creating + them and destroying them. + """ + def __init__(self, devtools_port, remote_devtools_port, app_backend): + """Creates a new DevToolsClientBackend. + + A DevTools agent must exist on the given devtools_port. + + Args: + devtools_port: The port to use to connect to DevTools agent. + remote_devtools_port: In some cases (e.g., app running on + Android device, devtools_port is the forwarded port on the + host platform. We also need to know the remote_devtools_port + so that we can uniquely identify the DevTools agent. + app_backend: For the app that contains the DevTools agent. + """ + self._devtools_port = devtools_port + self._remote_devtools_port = remote_devtools_port + self._devtools_http = devtools_http.DevToolsHttp(devtools_port) + self._browser_inspector_websocket = None + self._tracing_backend = None + self._memory_backend = None + self._app_backend = app_backend + self._devtools_context_map_backend = _DevToolsContextMapBackend( + self._app_backend, self) + + self._tab_ids = None + + if not self.supports_tracing: + return + chrome_tracing_devtools_manager.RegisterDevToolsClient( + self, self._app_backend.platform_backend) + + # Telemetry has started Chrome tracing if there is trace config, so start + # tracing on this newly created devtools client if needed. + trace_config = (self._app_backend.platform_backend + .tracing_controller_backend.GetChromeTraceConfig()) + if not trace_config: + self._CreateTracingBackendIfNeeded(is_tracing_running=False) + return + + if self.support_startup_tracing: + self._CreateTracingBackendIfNeeded(is_tracing_running=True) + return + + self._CreateTracingBackendIfNeeded(is_tracing_running=False) + self.StartChromeTracing(trace_config) + + @property + def remote_port(self): + return self._remote_devtools_port + + @property + def supports_tracing(self): + if not isinstance(self._app_backend, browser_backend.BrowserBackend): + return False + return self._app_backend.supports_tracing + + @property + def supports_overriding_memory_pressure_notifications(self): + if not isinstance(self._app_backend, browser_backend.BrowserBackend): + return False + return self._app_backend.supports_overriding_memory_pressure_notifications + + + @property + def is_tracing_running(self): + if not self.supports_tracing: + return False + if not self._tracing_backend: + return False + return self._tracing_backend.is_tracing_running + + @property + def support_startup_tracing(self): + # Startup tracing with --trace-config-file flag was not supported until + # Chromium branch number 2512 (see crrev.com/1309243004 and + # crrev.com/1353583002). + if not chrome_tracing_agent.ChromeTracingAgent.IsStartupTracingSupported( + self._app_backend.platform_backend): + return False + # TODO(zhenw): Remove this once stable Chrome and reference browser have + # passed 2512. + return self.GetChromeBranchNumber() >= 2512 + + @property + def support_modern_devtools_tracing_start_api(self): + # Modern DevTools Tracing.start API (via 'traceConfig' parameter) was not + # supported until Chromium branch number 2683 (see crrev.com/1808353002). + # TODO(petrcermak): Remove this once stable Chrome and reference browser + # have passed 2683. + return self.GetChromeBranchNumber() >= 2683 + + def IsAlive(self): + """Whether the DevTools server is available and connectable.""" + return (self._devtools_http and + _IsDevToolsAgentAvailable(self._devtools_http)) + + def Close(self): + if self._tracing_backend: + self._tracing_backend.Close() + self._tracing_backend = None + if self._memory_backend: + self._memory_backend.Close() + self._memory_backend = None + + if self._devtools_context_map_backend: + self._devtools_context_map_backend.Clear() + + # Close the browser inspector socket last (in case the backend needs to + # interact with it before closing). + if self._browser_inspector_websocket: + self._browser_inspector_websocket.Disconnect() + self._browser_inspector_websocket = None + + assert self._devtools_http + self._devtools_http.Disconnect() + self._devtools_http = None + + + @decorators.Cache + def GetChromeBranchNumber(self): + # Detect version information. + resp = self._devtools_http.RequestJson('version') + if 'Protocol-Version' in resp: + if 'Browser' in resp: + branch_number_match = re.search(r'Chrome/\d+\.\d+\.(\d+)\.\d+', + resp['Browser']) + else: + branch_number_match = re.search( + r'Chrome/\d+\.\d+\.(\d+)\.\d+ (Mobile )?Safari', + resp['User-Agent']) + + if branch_number_match: + branch_number = int(branch_number_match.group(1)) + if branch_number: + return branch_number + + # Branch number can't be determined, so fail any branch number checks. + return 0 + + def _ListInspectableContexts(self): + return self._devtools_http.RequestJson('') + + def RequestNewTab(self, timeout): + """Creates a new tab. + + Returns: + A JSON string as returned by DevTools. Example: + { + "description": "", + "devtoolsFrontendUrl": + "/devtools/inspector.html?ws=host:port/devtools/page/id-string", + "id": "id-string", + "title": "Page Title", + "type": "page", + "url": "url", + "webSocketDebuggerUrl": "ws://host:port/devtools/page/id-string" + } + + Raises: + devtools_http.DevToolsClientConnectionError + """ + return self._devtools_http.Request('new', timeout=timeout) + + def CloseTab(self, tab_id, timeout): + """Closes the tab with the given id. + + Raises: + devtools_http.DevToolsClientConnectionError + TabNotFoundError + """ + try: + return self._devtools_http.Request('close/%s' % tab_id, + timeout=timeout) + except devtools_http.DevToolsClientUrlError: + error = TabNotFoundError( + 'Unable to close tab, tab id not found: %s' % tab_id) + raise error, None, sys.exc_info()[2] + + def ActivateTab(self, tab_id, timeout): + """Activates the tab with the given id. + + Raises: + devtools_http.DevToolsClientConnectionError + TabNotFoundError + """ + try: + return self._devtools_http.Request('activate/%s' % tab_id, + timeout=timeout) + except devtools_http.DevToolsClientUrlError: + error = TabNotFoundError( + 'Unable to activate tab, tab id not found: %s' % tab_id) + raise error, None, sys.exc_info()[2] + + def GetUrl(self, tab_id): + """Returns the URL of the tab with |tab_id|, as reported by devtools. + + Raises: + devtools_http.DevToolsClientConnectionError + """ + for c in self._ListInspectableContexts(): + if c['id'] == tab_id: + return c['url'] + return None + + def IsInspectable(self, tab_id): + """Whether the tab with |tab_id| is inspectable, as reported by devtools. + + Raises: + devtools_http.DevToolsClientConnectionError + """ + contexts = self._ListInspectableContexts() + return tab_id in [c['id'] for c in contexts] + + def GetUpdatedInspectableContexts(self): + """Returns an updated instance of _DevToolsContextMapBackend.""" + contexts = self._ListInspectableContexts() + self._devtools_context_map_backend._Update(contexts) + return self._devtools_context_map_backend + + def _CreateTracingBackendIfNeeded(self, is_tracing_running=False): + assert self.supports_tracing + if not self._tracing_backend: + self._CreateAndConnectBrowserInspectorWebsocketIfNeeded() + self._tracing_backend = tracing_backend.TracingBackend( + self._browser_inspector_websocket, is_tracing_running, + self.support_modern_devtools_tracing_start_api) + + def _CreateMemoryBackendIfNeeded(self): + assert self.supports_overriding_memory_pressure_notifications + if not self._memory_backend: + self._CreateAndConnectBrowserInspectorWebsocketIfNeeded() + self._memory_backend = memory_backend.MemoryBackend( + self._browser_inspector_websocket) + + def _CreateAndConnectBrowserInspectorWebsocketIfNeeded(self): + if not self._browser_inspector_websocket: + self._browser_inspector_websocket = ( + inspector_websocket.InspectorWebsocket()) + self._browser_inspector_websocket.Connect( + BROWSER_INSPECTOR_WEBSOCKET_URL % self._devtools_port, timeout=10) + + def IsChromeTracingSupported(self): + if not self.supports_tracing: + return False + self._CreateTracingBackendIfNeeded() + return self._tracing_backend.IsTracingSupported() + + def StartChromeTracing(self, trace_config, timeout=10): + """ + Args: + trace_config: An tracing_config.TracingConfig instance. + """ + assert trace_config and trace_config.enable_chrome_trace + self._CreateTracingBackendIfNeeded() + return self._tracing_backend.StartTracing( + trace_config.chrome_trace_config, timeout) + + def RecordChromeClockSyncMarker(self, sync_id): + assert self.is_tracing_running, 'Tracing must be running to clock sync.' + self._tracing_backend.RecordClockSyncMarker(sync_id) + + def StopChromeTracing(self): + assert self.is_tracing_running + self._tab_ids = [] + try: + context_map = self.GetUpdatedInspectableContexts() + for context in context_map.contexts: + if context['type'] not in ['iframe', 'page', 'webview']: + continue + context_id = context['id'] + backend = context_map.GetInspectorBackend(context_id) + # TODO(catapult:#3028): Fix interpolation of JavaScript values. + backend.EvaluateJavaScript( + "console.time('" + backend.id + "');" + + "console.timeEnd('" + backend.id + "');" + + "console.time.toString().indexOf('[native code]') != -1;") + self._tab_ids.append(backend.id) + finally: + self._tracing_backend.StopTracing() + + def CollectChromeTracingData(self, trace_data_builder, timeout=30): + try: + trace_data_builder.AddTraceFor( + trace_data_module.TAB_ID_PART, self._tab_ids[:]) + self._tab_ids = None + finally: + self._tracing_backend.CollectTraceData(trace_data_builder, timeout) + + def DumpMemory(self, timeout=30): + """Dumps memory. + + Returns: + GUID of the generated dump if successful, None otherwise. + + Raises: + TracingTimeoutException: If more than |timeout| seconds has passed + since the last time any data is received. + TracingUnrecoverableException: If there is a websocket error. + TracingUnexpectedResponseException: If the response contains an error + or does not contain the expected result. + """ + self._CreateTracingBackendIfNeeded() + return self._tracing_backend.DumpMemory(timeout) + + def SetMemoryPressureNotificationsSuppressed(self, suppressed, timeout=30): + """Enable/disable suppressing memory pressure notifications. + + Args: + suppressed: If true, memory pressure notifications will be suppressed. + timeout: The timeout in seconds. + + Raises: + MemoryTimeoutException: If more than |timeout| seconds has passed + since the last time any data is received. + MemoryUnrecoverableException: If there is a websocket error. + MemoryUnexpectedResponseException: If the response contains an error + or does not contain the expected result. + """ + self._CreateMemoryBackendIfNeeded() + return self._memory_backend.SetMemoryPressureNotificationsSuppressed( + suppressed, timeout) + + def SimulateMemoryPressureNotification(self, pressure_level, timeout=30): + """Simulate a memory pressure notification. + + Args: + pressure level: The memory pressure level of the notification ('moderate' + or 'critical'). + timeout: The timeout in seconds. + + Raises: + MemoryTimeoutException: If more than |timeout| seconds has passed + since the last time any data is received. + MemoryUnrecoverableException: If there is a websocket error. + MemoryUnexpectedResponseException: If the response contains an error + or does not contain the expected result. + """ + self._CreateMemoryBackendIfNeeded() + return self._memory_backend.SimulateMemoryPressureNotification( + pressure_level, timeout) + + +class _DevToolsContextMapBackend(object): + def __init__(self, app_backend, devtools_client): + self._app_backend = app_backend + self._devtools_client = devtools_client + self._contexts = None + self._inspector_backends_dict = {} + + @property + def contexts(self): + """The most up to date contexts data. + + Returned in the order returned by devtools agent.""" + return self._contexts + + def GetContextInfo(self, context_id): + for context in self._contexts: + if context['id'] == context_id: + return context + raise KeyError('Cannot find a context with id=%s' % context_id) + + def GetInspectorBackend(self, context_id): + """Gets an InspectorBackend instance for the given context_id. + + This lazily creates InspectorBackend for the context_id if it does + not exist yet. Otherwise, it will return the cached instance.""" + if context_id in self._inspector_backends_dict: + return self._inspector_backends_dict[context_id] + + for context in self._contexts: + if context['id'] == context_id: + new_backend = inspector_backend.InspectorBackend( + self._app_backend.app, self._devtools_client, context) + self._inspector_backends_dict[context_id] = new_backend + return new_backend + + raise KeyError('Cannot find a context with id=%s' % context_id) + + def _Update(self, contexts): + # Remove InspectorBackend that is not in the current inspectable + # contexts list. + context_ids = [context['id'] for context in contexts] + for context_id in self._inspector_backends_dict.keys(): + if context_id not in context_ids: + backend = self._inspector_backends_dict[context_id] + backend.Disconnect() + del self._inspector_backends_dict[context_id] + + valid_contexts = [] + for context in contexts: + # If the context does not have webSocketDebuggerUrl, skip it. + # If an InspectorBackend is already created for the tab, + # webSocketDebuggerUrl will be missing, and this is expected. + context_id = context['id'] + if context_id not in self._inspector_backends_dict: + if 'webSocketDebuggerUrl' not in context: + logging.debug('webSocketDebuggerUrl missing, removing %s' + % context_id) + continue + valid_contexts.append(context) + self._contexts = valid_contexts + + def Clear(self): + for backend in self._inspector_backends_dict.values(): + backend.Disconnect() + self._inspector_backends_dict = {} + self._contexts = None |