| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // ignore_for_file: avoid_print, avoid_dynamic_calls |
| |
| import 'dart:async'; |
| import 'dart:convert' show JsonEncoder, LineSplitter, json, utf8; |
| import 'dart:io' as io; |
| import 'dart:math' as math; |
| |
| import 'package:path/path.dart' as path; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; |
| |
| import 'common.dart'; |
| |
| /// Options passed to Chrome when launching it. |
| class ChromeOptions { |
| /// Creates chrome options. |
| ChromeOptions({ |
| this.userDataDirectory, |
| this.url, |
| this.windowWidth = 1024, |
| this.windowHeight = 1024, |
| required this.headless, |
| this.debugPort, |
| }); |
| |
| /// If not null passed as `--user-data-dir`. |
| final String? userDataDirectory; |
| |
| /// If not null launches a Chrome tab at this URL. |
| final String? url; |
| |
| /// The width of the Chrome window. |
| /// |
| /// This is important for screenshots and benchmarks. |
| final int windowWidth; |
| |
| /// The height of the Chrome window. |
| /// |
| /// This is important for screenshots and benchmarks. |
| final int windowHeight; |
| |
| /// Launches code in "headless" mode, which allows running Chrome in |
| /// environments without a display, such as LUCI and Cirrus. |
| final bool headless; |
| |
| /// The port Chrome will use for its debugging protocol. |
| /// |
| /// If null, Chrome is launched without debugging. When running in headless |
| /// mode without a debug port, Chrome quits immediately. For most tests it is |
| /// typical to set [headless] to true and set a non-null debug port. |
| final int? debugPort; |
| } |
| |
| /// A function called when the Chrome process encounters an error. |
| typedef ChromeErrorCallback = void Function(String); |
| |
| /// Manages a single Chrome process. |
| class Chrome { |
| Chrome._(this._chromeProcess, this._onError, this._debugConnection, |
| bool headless) { |
| if (headless) { |
| // In headless mode, if the Chrome process quits before it was asked to |
| // quit, notify the error listener. If it's not running headless, the |
| // developer may close the browser any time, so it's not considered to |
| // be an error. |
| _chromeProcess.exitCode.then((int exitCode) { |
| if (!_isStopped) { |
| _onError( |
| 'Chrome process exited prematurely with exit code $exitCode'); |
| } |
| }); |
| } |
| } |
| |
| /// Launches Chrome with the give [options]. |
| /// |
| /// The [onError] callback is called with an error message when the Chrome |
| /// process encounters an error. In particular, [onError] is called when the |
| /// Chrome process exits prematurely, i.e. before [stop] is called. |
| static Future<Chrome> launch(ChromeOptions options, |
| {String? workingDirectory, required ChromeErrorCallback onError}) async { |
| if (!io.Platform.isWindows) { |
| final io.ProcessResult versionResult = io.Process.runSync( |
| _findSystemChromeExecutable(), const <String>['--version']); |
| print('Launching ${versionResult.stdout}'); |
| } else { |
| print('Launching Chrome...'); |
| } |
| final String? url = options.url; |
| final bool withDebugging = options.debugPort != null; |
| |
| final List<String> args = <String>[ |
| if (options.userDataDirectory != null) |
| '--user-data-dir=${options.userDataDirectory}', |
| if (url != null) url, |
| if (io.Platform.environment['CHROME_NO_SANDBOX'] == 'true') |
| '--no-sandbox', |
| if (options.headless) '--headless', |
| if (withDebugging) '--remote-debugging-port=${options.debugPort}', |
| '--window-size=${options.windowWidth},${options.windowHeight}', |
| '--disable-extensions', |
| '--disable-popup-blocking', |
| // Indicates that the browser is in "browse without sign-in" (Guest session) mode. |
| '--bwsi', |
| '--no-first-run', |
| '--no-default-browser-check', |
| '--disable-default-apps', |
| '--disable-translate', |
| ]; |
| final io.Process chromeProcess = await io.Process.start( |
| _findSystemChromeExecutable(), |
| args, |
| workingDirectory: workingDirectory, |
| ); |
| |
| WipConnection? debugConnection; |
| final int? debugPort = options.debugPort; |
| if (debugPort != null) { |
| debugConnection = |
| await _connectToChromeDebugPort(chromeProcess, debugPort); |
| } |
| |
| return Chrome._(chromeProcess, onError, debugConnection, options.headless); |
| } |
| |
| final io.Process _chromeProcess; |
| final ChromeErrorCallback _onError; |
| final WipConnection? _debugConnection; |
| bool _isStopped = false; |
| |
| Completer<void>? _tracingCompleter; |
| StreamSubscription<WipEvent>? _tracingSubscription; |
| List<Map<String, dynamic>>? _tracingData; |
| |
| /// Starts recording a performance trace. |
| /// |
| /// If there is already a tracing session in progress, throws an error. Call |
| /// [endRecordingPerformance] before starting a new tracing session. |
| /// |
| /// The [label] is for debugging convenience. |
| Future<void> beginRecordingPerformance(String? label) async { |
| if (_tracingCompleter != null) { |
| throw StateError( |
| 'Cannot start a new performance trace. A tracing session labeled ' |
| '"$label" is already in progress.'); |
| } |
| _tracingCompleter = Completer<void>(); |
| _tracingData = <Map<String, dynamic>>[]; |
| |
| // Subscribe to tracing events prior to calling "Tracing.start". Otherwise, |
| // we'll miss tracing data. |
| _tracingSubscription = |
| _debugConnection?.onNotification.listen((WipEvent event) { |
| // We receive data as a sequence of "Tracing.dataCollected" followed by |
| // "Tracing.tracingComplete" at the end. Until "Tracing.tracingComplete" |
| // is received, the data may be incomplete. |
| if (event.method == 'Tracing.tracingComplete') { |
| _tracingCompleter!.complete(); |
| _tracingSubscription?.cancel(); |
| _tracingSubscription = null; |
| } else if (event.method == 'Tracing.dataCollected') { |
| final dynamic value = event.params!['value']; |
| if (value is! List) { |
| throw FormatException( |
| '"Tracing.dataCollected" returned malformed data. ' |
| 'Expected a List but got: ${value.runtimeType}'); |
| } |
| _tracingData?.addAll((event.params!['value'] as List<dynamic>) |
| .cast<Map<String, dynamic>>()); |
| } |
| }); |
| await _debugConnection?.sendCommand('Tracing.start', <String, dynamic>{ |
| // The choice of categories is as follows: |
| // |
| // blink: |
| // provides everything on the UI thread, including scripting, |
| // style recalculations, layout, painting, and some compositor |
| // work. |
| // blink.user_timing: |
| // provides marks recorded using window.performance. We use marks |
| // to find frames that the benchmark cares to measure. |
| // gpu: |
| // provides tracing data from the GPU data |
| // disabled due to https://bugs.chromium.org/p/chromium/issues/detail?id=1068259 |
| // TODO(yjbanov): extract useful GPU data |
| 'categories': 'blink,blink.user_timing', |
| 'transferMode': 'SendAsStream', |
| }); |
| } |
| |
| /// Stops a performance tracing session started by [beginRecordingPerformance]. |
| /// |
| /// Returns all the collected tracing data unfiltered. |
| Future<List<Map<String, dynamic>>?> endRecordingPerformance() async { |
| await _debugConnection?.sendCommand('Tracing.end'); |
| await _tracingCompleter?.future; |
| final List<Map<String, dynamic>>? data = _tracingData; |
| _tracingCompleter = null; |
| _tracingData = null; |
| return data; |
| } |
| |
| /// Stops the Chrome process. |
| void stop() { |
| _isStopped = true; |
| _tracingSubscription?.cancel(); |
| _chromeProcess.kill(); |
| } |
| |
| /// Resolves when the Chrome process exits. |
| Future<void> get whenExits async { |
| await _chromeProcess.exitCode; |
| } |
| } |
| |
| String _findSystemChromeExecutable() { |
| // On some environments, such as the Dart HHH tester, Chrome resides in a |
| // non-standard location and is provided via the following environment |
| // variable. |
| final String? envExecutable = io.Platform.environment['CHROME_EXECUTABLE']; |
| if (envExecutable != null) { |
| return envExecutable; |
| } |
| |
| if (io.Platform.isLinux) { |
| final io.ProcessResult which = |
| io.Process.runSync('which', <String>['google-chrome']); |
| |
| if (which.exitCode != 0) { |
| throw Exception('Failed to locate system Chrome installation.'); |
| } |
| |
| final String output = which.stdout as String; |
| return output.trim(); |
| } else if (io.Platform.isMacOS) { |
| return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; |
| } else if (io.Platform.isWindows) { |
| const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe'; |
| final List<String> kWindowsPrefixes = <String>[ |
| for (final String? item in <String?>[ |
| io.Platform.environment['LOCALAPPDATA'], |
| io.Platform.environment['PROGRAMFILES'], |
| io.Platform.environment['PROGRAMFILES(X86)'], |
| ]) |
| if (item != null) item |
| ]; |
| |
| final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) { |
| final String expectedPath = path.join(prefix, kWindowsExecutable); |
| return io.File(expectedPath).existsSync(); |
| }, orElse: () => '.'); |
| return path.join(windowsPrefix, kWindowsExecutable); |
| } else { |
| throw Exception( |
| 'Web benchmarks cannot run on ${io.Platform.operatingSystem}.'); |
| } |
| } |
| |
| /// Waits for Chrome to print DevTools URI and connects to it. |
| Future<WipConnection> _connectToChromeDebugPort( |
| io.Process chromeProcess, int port) async { |
| chromeProcess.stdout |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .listen((String line) { |
| print('[CHROME]: $line'); |
| }); |
| |
| await chromeProcess.stderr |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .map((String line) { |
| print('[CHROME]: $line'); |
| return line; |
| }).firstWhere((String line) => line.startsWith('DevTools listening'), |
| orElse: () { |
| throw Exception('Expected Chrome to print "DevTools listening" string ' |
| 'with DevTools URL, but the string was never printed.'); |
| }); |
| |
| final Uri devtoolsUri = |
| await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')); |
| print('Connecting to DevTools: $devtoolsUri'); |
| final ChromeConnection chromeConnection = ChromeConnection('localhost', port); |
| final Iterable<ChromeTab> tabs = |
| (await chromeConnection.getTabs()).where((ChromeTab tab) { |
| return tab.url.startsWith('http://localhost'); |
| }); |
| final ChromeTab tab = tabs.single; |
| final WipConnection debugConnection = await tab.connect(); |
| print('Connected to Chrome tab: ${tab.title} (${tab.url})'); |
| return debugConnection; |
| } |
| |
| /// Gets the Chrome debugger URL for the web page being benchmarked. |
| Future<Uri> _getRemoteDebuggerUrl(Uri base) async { |
| final io.HttpClient client = io.HttpClient(); |
| final io.HttpClientRequest request = |
| await client.getUrl(base.resolve('/json/list')); |
| final io.HttpClientResponse response = await request.close(); |
| final List<dynamic>? jsonObject = |
| await json.fuse(utf8).decoder.bind(response).single as List<dynamic>?; |
| if (jsonObject == null || jsonObject.isEmpty) { |
| return base; |
| } |
| return base.resolve(jsonObject.first['webSocketDebuggerUrl'] as String); |
| } |
| |
| /// Summarizes a Blink trace down to a few interesting values. |
| class BlinkTraceSummary { |
| BlinkTraceSummary._({ |
| required this.averageBeginFrameTime, |
| required this.averageUpdateLifecyclePhasesTime, |
| }) : averageTotalUIFrameTime = |
| averageBeginFrameTime + averageUpdateLifecyclePhasesTime; |
| |
| /// Summarizes Blink trace from the raw JSON trace. |
| static BlinkTraceSummary? fromJson(List<Map<String, dynamic>> traceJson) { |
| try { |
| // Convert raw JSON data to BlinkTraceEvent objects sorted by timestamp. |
| List<BlinkTraceEvent> events = traceJson |
| .map<BlinkTraceEvent>(BlinkTraceEvent.fromJson) |
| .toList() |
| ..sort((BlinkTraceEvent a, BlinkTraceEvent b) => a.ts - b.ts); |
| |
| Exception noMeasuredFramesFound() => Exception( |
| 'No measured frames found in benchmark tracing data. This likely ' |
| 'indicates a bug in the benchmark. For example, the benchmark failed ' |
| "to pump enough frames. It may also indicate a change in Chrome's " |
| 'tracing data format. Check if Chrome version changed recently and ' |
| 'adjust the parsing code accordingly.', |
| ); |
| |
| // Use the pid from the first "measured_frame" event since the event is |
| // emitted by the script running on the process we're interested in. |
| // |
| // We previously tried using the "CrRendererMain" event. However, for |
| // reasons unknown, Chrome in the devicelab refuses to emit this event |
| // sometimes, causing to flakes. |
| final BlinkTraceEvent firstMeasuredFrameEvent = events.firstWhere( |
| (BlinkTraceEvent event) => event.isBeginMeasuredFrame, |
| orElse: () => throw noMeasuredFramesFound(), |
| ); |
| |
| final int tabPid = firstMeasuredFrameEvent.pid; |
| |
| // Filter out data from unrelated processes |
| events = events |
| .where((BlinkTraceEvent element) => element.pid == tabPid) |
| .toList(); |
| |
| // Extract frame data. |
| final List<BlinkFrame> frames = <BlinkFrame>[]; |
| int skipCount = 0; |
| BlinkFrame frame = BlinkFrame(); |
| for (final BlinkTraceEvent event in events) { |
| if (event.isBeginFrame) { |
| frame.beginFrame = event; |
| } else if (event.isUpdateAllLifecyclePhases) { |
| frame.updateAllLifecyclePhases = event; |
| if (frame.endMeasuredFrame != null) { |
| frames.add(frame); |
| } else { |
| skipCount += 1; |
| } |
| frame = BlinkFrame(); |
| } else if (event.isBeginMeasuredFrame) { |
| frame.beginMeasuredFrame = event; |
| } else if (event.isEndMeasuredFrame) { |
| frame.endMeasuredFrame = event; |
| } |
| } |
| |
| print('Extracted ${frames.length} measured frames.'); |
| print('Skipped $skipCount non-measured frames.'); |
| |
| if (frames.isEmpty) { |
| throw noMeasuredFramesFound(); |
| } |
| |
| // Compute averages and summarize. |
| return BlinkTraceSummary._( |
| averageBeginFrameTime: _computeAverageDuration(frames |
| .map((BlinkFrame frame) => frame.beginFrame) |
| .whereType<BlinkTraceEvent>() |
| .toList()), |
| averageUpdateLifecyclePhasesTime: _computeAverageDuration(frames |
| .map((BlinkFrame frame) => frame.updateAllLifecyclePhases) |
| .whereType<BlinkTraceEvent>() |
| .toList()), |
| ); |
| } catch (_) { |
| final io.File traceFile = io.File('./chrome-trace.json'); |
| io.stderr.writeln( |
| 'Failed to interpret the Chrome trace contents. The trace was saved in ${traceFile.path}'); |
| traceFile.writeAsStringSync( |
| const JsonEncoder.withIndent(' ').convert(traceJson)); |
| rethrow; |
| } |
| } |
| |
| /// The average duration of "WebViewImpl::beginFrame" events. |
| /// |
| /// This event contains all of scripting time of an animation frame, plus an |
| /// unknown small amount of work browser does before and after scripting. |
| final Duration averageBeginFrameTime; |
| |
| /// The average duration of "WebViewImpl::updateAllLifecyclePhases" events. |
| /// |
| /// This event contains style, layout, painting, and compositor computations, |
| /// which are not included in the scripting time. This event does not |
| /// include GPU time, which happens on a separate thread. |
| final Duration averageUpdateLifecyclePhasesTime; |
| |
| /// The average sum of [averageBeginFrameTime] and |
| /// [averageUpdateLifecyclePhasesTime]. |
| /// |
| /// This value contains the vast majority of work the UI thread performs in |
| /// any given animation frame. |
| final Duration averageTotalUIFrameTime; |
| |
| @override |
| String toString() => '$BlinkTraceSummary(' |
| 'averageBeginFrameTime: ${averageBeginFrameTime.inMicroseconds / 1000}ms, ' |
| 'averageUpdateLifecyclePhasesTime: ${averageUpdateLifecyclePhasesTime.inMicroseconds / 1000}ms)'; |
| } |
| |
| /// Contains events pertaining to a single frame in the Blink trace data. |
| class BlinkFrame { |
| /// Corresponds to 'WebViewImpl::beginFrame' event. |
| BlinkTraceEvent? beginFrame; |
| |
| /// Corresponds to 'WebViewImpl::updateAllLifecyclePhases' event. |
| BlinkTraceEvent? updateAllLifecyclePhases; |
| |
| /// Corresponds to 'measured_frame' begin event. |
| BlinkTraceEvent? beginMeasuredFrame; |
| |
| /// Corresponds to 'measured_frame' end event. |
| BlinkTraceEvent? endMeasuredFrame; |
| } |
| |
| /// Takes a list of events that have non-null [BlinkTraceEvent.tdur] computes |
| /// their average as a [Duration] value. |
| Duration _computeAverageDuration(List<BlinkTraceEvent> events) { |
| // Compute the sum of "tdur" fields of the last kMeasuredSampleCount events. |
| final double sum = events |
| .skip(math.max(events.length - kMeasuredSampleCount, 0)) |
| .fold(0.0, (double previousValue, BlinkTraceEvent event) { |
| final int? threadClockDuration = event.tdur; |
| if (threadClockDuration == null) { |
| throw FormatException('Trace event lacks "tdur" field: $event'); |
| } |
| return previousValue + threadClockDuration; |
| }); |
| final int sampleCount = math.min(events.length, kMeasuredSampleCount); |
| return Duration(microseconds: sum ~/ sampleCount); |
| } |
| |
| /// An event collected by the Blink tracer (in Chrome accessible using chrome://tracing). |
| /// |
| /// See also: |
| /// * https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview |
| class BlinkTraceEvent { |
| BlinkTraceEvent._({ |
| required this.args, |
| required this.cat, |
| required this.name, |
| required this.ph, |
| required this.pid, |
| required this.tid, |
| required this.ts, |
| required this.tts, |
| required this.tdur, |
| }); |
| |
| /// Parses an event from its JSON representation. |
| /// |
| /// Sample event encoded as JSON (the data is bogus, this just shows the format): |
| /// |
| /// ``` |
| /// { |
| /// "name": "myName", |
| /// "cat": "category,list", |
| /// "ph": "B", |
| /// "ts": 12345, |
| /// "pid": 123, |
| /// "tid": 456, |
| /// "args": { |
| /// "someArg": 1, |
| /// "anotherArg": { |
| /// "value": "my value" |
| /// } |
| /// } |
| /// } |
| /// ``` |
| /// |
| /// For detailed documentation of the format see: |
| /// |
| /// https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview |
| static BlinkTraceEvent fromJson(Map<String, dynamic> json) { |
| return BlinkTraceEvent._( |
| args: json['args'] as Map<String, dynamic>, |
| cat: json['cat'] as String, |
| name: json['name'] as String, |
| ph: json['ph'] as String, |
| pid: _readInt(json, 'pid')!, |
| tid: _readInt(json, 'tid')!, |
| ts: _readInt(json, 'ts')!, |
| tts: _readInt(json, 'tts'), |
| tdur: _readInt(json, 'tdur'), |
| ); |
| } |
| |
| /// Event-specific data. |
| final Map<String, dynamic> args; |
| |
| /// Event category. |
| final String cat; |
| |
| /// Event name. |
| final String name; |
| |
| /// Event "phase". |
| final String ph; |
| |
| /// Process ID of the process that emitted the event. |
| final int pid; |
| |
| /// Thread ID of the thread that emitted the event. |
| final int tid; |
| |
| /// Timestamp in microseconds using tracer clock. |
| final int ts; |
| |
| /// Timestamp in microseconds using thread clock. |
| final int? tts; |
| |
| /// Event duration in microseconds. |
| final int? tdur; |
| |
| /// A "begin frame" event contains all of the scripting time of an animation |
| /// frame (JavaScript, WebAssembly), plus a negligible amount of internal |
| /// browser overhead. |
| /// |
| /// This event does not include non-UI thread scripting, such as web workers, |
| /// service workers, and CSS Paint paintlets. |
| /// |
| /// WebViewImpl::beginFrame was used in earlier versions of Chrome, kept |
| /// for compatibility. |
| /// |
| /// This event is a duration event that has its `tdur` populated. |
| bool get isBeginFrame => |
| ph == 'X' && |
| (name == 'WebViewImpl::beginFrame' || |
| name == 'WebFrameWidgetBase::BeginMainFrame' || |
| name == 'WebFrameWidgetImpl::BeginMainFrame'); |
| |
| /// An "update all lifecycle phases" event contains UI thread computations |
| /// related to an animation frame that's outside the scripting phase. |
| /// |
| /// This event includes style recalculation, layer tree update, layout, |
| /// painting, and parts of compositing work. |
| /// |
| /// This event is a duration event that has its `tdur` populated. |
| bool get isUpdateAllLifecyclePhases => |
| ph == 'X' && |
| (name == 'WebViewImpl::updateAllLifecyclePhases' || |
| name == 'WebFrameWidgetImpl::UpdateLifecycle'); |
| |
| /// Whether this is the beginning of a "measured_frame" event. |
| /// |
| /// This event is a custom event emitted by our benchmark test harness. |
| /// |
| /// See also: |
| /// * `recorder.dart`, which emits this event. |
| bool get isBeginMeasuredFrame => ph == 'b' && name == 'measured_frame'; |
| |
| /// Whether this is the end of a "measured_frame" event. |
| /// |
| /// This event is a custom event emitted by our benchmark test harness. |
| /// |
| /// See also: |
| /// * `recorder.dart`, which emits this event. |
| bool get isEndMeasuredFrame => ph == 'e' && name == 'measured_frame'; |
| |
| @override |
| String toString() => '$BlinkTraceEvent(' |
| 'args: ${json.encode(args)}, ' |
| 'cat: $cat, ' |
| 'name: $name, ' |
| 'ph: $ph, ' |
| 'pid: $pid, ' |
| 'tid: $tid, ' |
| 'ts: $ts, ' |
| 'tts: $tts, ' |
| 'tdur: $tdur)'; |
| } |
| |
| /// Read an integer out of [json] stored under [key]. |
| /// |
| /// Since JSON does not distinguish between `int` and `double`, extra |
| /// validation and conversion is needed. |
| /// |
| /// Returns null if the value is null. |
| int? _readInt(Map<String, dynamic> json, String key) { |
| final num? jsonValue = json[key] as num?; |
| |
| if (jsonValue == null) { |
| return null; // ignore: avoid_returning_null |
| } |
| |
| return jsonValue.toInt(); |
| } |