blob: 52b31d69f943d9503218e1e4c0fbbcc72c68a8b0 [file] [log] [blame]
// 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'].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;
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 (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']);
}
/// 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(),
);
if (firstMeasuredFrameEvent == null) {
// This happens in benchmarks that do not measure frames, such as some
// of the text layout benchmarks.
return null;
}
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];
if (jsonValue == null) {
return null; // ignore: avoid_returning_null
}
return jsonValue.toInt();
}