blob: d33f9e92709ff00a654e9b3a8251d01f38419300 [file] [log] [blame]
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:vm_service/vm_service.dart';
import 'hitmap.dart';
import 'util.dart';
const _retryInterval = Duration(milliseconds: 200);
const _debugTokenPositions = bool.fromEnvironment('DEBUG_COVERAGE');
/// Collects coverage for all isolates in the running VM.
///
/// Collects a hit-map containing merged coverage for all isolates in the Dart
/// VM associated with the specified [serviceUri]. Returns a map suitable for
/// input to the coverage formatters that ship with this package.
///
/// [serviceUri] must specify the http/https URI of the service port of a
/// running Dart VM and must not be null.
///
/// If [resume] is true, all isolates will be resumed once coverage collection
/// is complete.
///
/// If [waitPaused] is true, collection will not begin until all isolates are
/// in the paused state.
///
/// If [includeDart] is true, code coverage for core `dart:*` libraries will be
/// collected.
///
/// If [functionCoverage] is true, function coverage information will be
/// collected.
///
/// If [branchCoverage] is true, branch coverage information will be collected.
/// This will only work correctly if the target VM was run with the
/// --branch-coverage flag.
///
/// If [scopedOutput] is non-empty, coverage will be restricted so that only
/// scripts that start with any of the provided paths are considered.
///
/// if [isolateIds] is set, the coverage gathering will be restricted to only
/// those VM isolates.
///
/// [serviceOverrideForTesting] is for internal testing only, and should not be
/// set by users.
Future<Map<String, dynamic>> collect(Uri serviceUri, bool resume,
bool waitPaused, bool includeDart, Set<String>? scopedOutput,
{Set<String>? isolateIds,
Duration? timeout,
bool functionCoverage = false,
bool branchCoverage = false,
VmService? serviceOverrideForTesting}) async {
scopedOutput ??= <String>{};
late VmService service;
if (serviceOverrideForTesting != null) {
service = serviceOverrideForTesting;
} else {
// Create websocket URI. Handle any trailing slashes.
final pathSegments =
serviceUri.pathSegments.where((c) => c.isNotEmpty).toList()..add('ws');
final uri = serviceUri.replace(scheme: 'ws', pathSegments: pathSegments);
await retry(() async {
try {
final options = const CompressionOptions(enabled: false);
final socket = await WebSocket.connect('$uri', compression: options);
final controller = StreamController<String>();
socket.listen((data) => controller.add(data as String), onDone: () {
controller.close();
service.dispose();
});
service = VmService(
controller.stream, (String message) => socket.add(message),
log: StdoutLog(), disposeHandler: () => socket.close());
await service.getVM().timeout(_retryInterval);
} on TimeoutException {
// The signature changed in vm_service version 6.0.0.
// ignore: await_only_futures
await service.dispose();
rethrow;
}
}, _retryInterval, timeout: timeout);
}
try {
if (waitPaused) {
await _waitIsolatesPaused(service, timeout: timeout);
}
return await _getAllCoverage(service, includeDart, functionCoverage,
branchCoverage, scopedOutput, isolateIds);
} finally {
if (resume) {
await _resumeIsolates(service);
}
// The signature changed in vm_service version 6.0.0.
// ignore: await_only_futures
await service.dispose();
}
}
bool _versionCheck(Version version, int minMajor, int minMinor) {
final major = version.major ?? 0;
final minor = version.minor ?? 0;
return major > minMajor || (major == minMajor && minor >= minMinor);
}
Future<Map<String, dynamic>> _getAllCoverage(
VmService service,
bool includeDart,
bool functionCoverage,
bool branchCoverage,
Set<String>? scopedOutput,
Set<String>? isolateIds) async {
scopedOutput ??= <String>{};
final vm = await service.getVM();
final allCoverage = <Map<String, dynamic>>[];
final version = await service.getVersion();
final reportLines = _versionCheck(version, 3, 51);
final branchCoverageSupported = _versionCheck(version, 3, 56);
final libraryFilters = _versionCheck(version, 3, 57);
final fastIsoGroups = _versionCheck(version, 3, 61);
if (branchCoverage && !branchCoverageSupported) {
branchCoverage = false;
stderr.writeln('Branch coverage was requested, but is not supported'
' by the VM version. Try updating to a newer version of Dart');
}
final sourceReportKinds = [
SourceReportKind.kCoverage,
if (branchCoverage) SourceReportKind.kBranchCoverage,
];
// Program counters are shared between isolates in the same group. So we need
// to make sure we're only gathering coverage data for one isolate in each
// group, otherwise we'll double count the hits.
final isolateOwnerGroup = <String, String>{};
final coveredIsolateGroups = <String>{};
if (!fastIsoGroups) {
for (var isolateGroupRef in vm.isolateGroups!) {
final isolateGroup = await service.getIsolateGroup(isolateGroupRef.id!);
for (var isolateRef in isolateGroup.isolates!) {
isolateOwnerGroup[isolateRef.id!] = isolateGroupRef.id!;
}
}
}
for (var isolateRef in vm.isolates!) {
if (isolateIds != null && !isolateIds.contains(isolateRef.id)) continue;
final isolateGroupId = fastIsoGroups
? isolateRef.isolateGroupId
: isolateOwnerGroup[isolateRef.id];
if (isolateGroupId != null) {
if (coveredIsolateGroups.contains(isolateGroupId)) continue;
coveredIsolateGroups.add(isolateGroupId);
}
if (scopedOutput.isNotEmpty && !libraryFilters) {
late final ScriptList scripts;
try {
scripts = await service.getScripts(isolateRef.id!);
} on SentinelException {
continue;
}
for (final script in scripts.scripts!) {
final uri = Uri.parse(script.uri!);
if (uri.scheme != 'package') continue;
final scope = uri.path.split('/').first;
// Skip scripts which should not be included in the report.
if (!scopedOutput.contains(scope)) continue;
late final SourceReport scriptReport;
try {
scriptReport = await service.getSourceReport(
isolateRef.id!, sourceReportKinds,
forceCompile: true,
scriptId: script.id,
reportLines: reportLines ? true : null);
} on SentinelException {
continue;
}
final coverage = await _getCoverageJson(service, isolateRef,
scriptReport, includeDart, functionCoverage, reportLines);
allCoverage.addAll(coverage);
}
} else {
late final SourceReport isolateReport;
try {
isolateReport = await service.getSourceReport(
isolateRef.id!,
sourceReportKinds,
forceCompile: true,
reportLines: reportLines ? true : null,
libraryFilters: scopedOutput.isNotEmpty && libraryFilters
? List.from(scopedOutput.map((filter) => 'package:$filter/'))
: null,
);
} on SentinelException {
continue;
}
final coverage = await _getCoverageJson(service, isolateRef,
isolateReport, includeDart, functionCoverage, reportLines);
allCoverage.addAll(coverage);
}
}
return <String, dynamic>{'type': 'CodeCoverage', 'coverage': allCoverage};
}
Future _resumeIsolates(VmService service) async {
final vm = await service.getVM();
final futures = <Future>[];
for (var isolateRef in vm.isolates!) {
// Guard against sync as well as async errors: sync - when we are writing
// message to the socket, the socket might be closed; async - when we are
// waiting for the response, the socket again closes.
futures.add(Future.sync(() async {
final isolate = await service.getIsolate(isolateRef.id!);
if (isolate.pauseEvent!.kind != EventKind.kResume) {
await service.resume(isolateRef.id!);
}
}));
}
try {
await Future.wait(futures);
} catch (_) {
// Ignore resume isolate failures
}
}
Future _waitIsolatesPaused(VmService service, {Duration? timeout}) async {
final pauseEvents = <String>{
EventKind.kPauseStart,
EventKind.kPauseException,
EventKind.kPauseExit,
EventKind.kPauseInterrupted,
EventKind.kPauseBreakpoint
};
Future allPaused() async {
final vm = await service.getVM();
if (vm.isolates!.isEmpty) throw 'No isolates.';
for (var isolateRef in vm.isolates!) {
final isolate = await service.getIsolate(isolateRef.id!);
if (!pauseEvents.contains(isolate.pauseEvent!.kind)) {
throw 'Unpaused isolates remaining.';
}
}
}
return retry(allPaused, _retryInterval, timeout: timeout);
}
/// Returns the line number to which the specified token position maps.
///
/// Performs a binary search within the script's token position table to locate
/// the line in question.
int? _getLineFromTokenPos(Script script, int tokenPos) {
// TODO(cbracken): investigate whether caching this lookup results in
// significant performance gains.
var min = 0;
var max = script.tokenPosTable!.length;
while (min < max) {
final mid = min + ((max - min) >> 1);
final row = script.tokenPosTable![mid];
if (row[1] > tokenPos) {
max = mid;
} else {
for (var i = 1; i < row.length; i += 2) {
if (row[i] == tokenPos) return row.first;
}
min = mid + 1;
}
}
return null;
}
/// Returns a JSON coverage list backward-compatible with pre-1.16.0 SDKs.
Future<List<Map<String, dynamic>>> _getCoverageJson(
VmService service,
IsolateRef isolateRef,
SourceReport report,
bool includeDart,
bool functionCoverage,
bool reportLines) async {
final hitMaps = <Uri, HitMap>{};
final scripts = <ScriptRef, Script>{};
final libraries = <LibraryRef>{};
final needScripts = functionCoverage || !reportLines;
Future<Script?> getScript(ScriptRef? scriptRef) async {
if (scriptRef == null) {
return null;
}
if (!scripts.containsKey(scriptRef)) {
scripts[scriptRef] =
await service.getObject(isolateRef.id!, scriptRef.id!) as Script;
}
return scripts[scriptRef];
}
HitMap getHitMap(Uri scriptUri) =>
hitMaps.putIfAbsent(scriptUri, () => HitMap());
Future<void> processFunction(FuncRef funcRef) async {
final func = await service.getObject(isolateRef.id!, funcRef.id!) as Func;
if ((func.implicit ?? false) || (func.isAbstract ?? false)) {
return;
}
final location = func.location;
if (location == null) {
return;
}
final script = await getScript(location.script);
if (script == null) {
return;
}
final funcName = await _getFuncName(service, isolateRef, func);
final tokenPos = location.tokenPos!;
final line = _getLineFromTokenPos(script, tokenPos);
if (line == null) {
if (_debugTokenPositions) {
stderr.writeln(
'tokenPos $tokenPos in function ${funcRef.name} has no line '
'mapping for script ${script.uri!}');
}
return;
}
final hits = getHitMap(Uri.parse(script.uri!));
hits.funcHits ??= <int, int>{};
(hits.funcNames ??= <int, String>{})[line] = funcName;
}
for (var range in report.ranges!) {
final scriptRef = report.scripts![range.scriptIndex!];
final scriptUri = Uri.parse(scriptRef.uri!);
// Not returned in scripts section of source report.
if (scriptUri.scheme == 'evaluate') continue;
// Skip scripts from dart:.
if (!includeDart && scriptUri.scheme == 'dart') continue;
// Look up the hit maps for this script (shared across isolates).
final hits = getHitMap(scriptUri);
Script? script;
if (needScripts) {
script = await getScript(scriptRef);
if (script == null) continue;
}
// If the script's library isn't loaded, load it then look up all its funcs.
final libRef = script?.library;
if (functionCoverage && libRef != null && !libraries.contains(libRef)) {
libraries.add(libRef);
final library =
await service.getObject(isolateRef.id!, libRef.id!) as Library;
if (library.functions != null) {
for (var funcRef in library.functions!) {
await processFunction(funcRef);
}
}
if (library.classes != null) {
for (var classRef in library.classes!) {
final clazz =
await service.getObject(isolateRef.id!, classRef.id!) as Class;
if (clazz.functions != null) {
for (var funcRef in clazz.functions!) {
await processFunction(funcRef);
}
}
}
}
}
// Collect hits and misses.
final coverage = range.coverage;
if (coverage == null) continue;
void forEachLine(List<int> tokenPositions, void Function(int line) body) {
for (final pos in tokenPositions) {
final line = reportLines ? pos : _getLineFromTokenPos(script!, pos);
if (line == null) {
if (_debugTokenPositions) {
stderr.write(
'tokenPos $pos has no line mapping for script $scriptUri');
}
continue;
}
body(line);
}
}
forEachLine(coverage.hits!, (line) {
hits.lineHits.increment(line);
if (hits.funcNames != null && hits.funcNames!.containsKey(line)) {
hits.funcHits!.increment(line);
}
});
forEachLine(coverage.misses!, (line) {
hits.lineHits.putIfAbsent(line, () => 0);
});
hits.funcNames?.forEach((line, funcName) {
hits.funcHits?.putIfAbsent(line, () => 0);
});
final branchCoverage = range.branchCoverage;
if (branchCoverage != null) {
hits.branchHits ??= <int, int>{};
forEachLine(branchCoverage.hits!, (line) {
hits.branchHits!.increment(line);
});
forEachLine(branchCoverage.misses!, (line) {
hits.branchHits!.putIfAbsent(line, () => 0);
});
}
}
// Output JSON
final coverage = <Map<String, dynamic>>[];
hitMaps.forEach((uri, hits) {
coverage.add(hitmapToJson(hits, uri));
});
return coverage;
}
extension _MapExtension<T> on Map<T, int> {
void increment(T key) => this[key] = (this[key] ?? 0) + 1;
}
Future<String> _getFuncName(
VmService service, IsolateRef isolateRef, Func func) async {
if (func.name == null) {
return '${func.type}:${func.location!.tokenPos}';
}
final owner = func.owner;
if (owner is ClassRef) {
final cls = await service.getObject(isolateRef.id!, owner.id!) as Class;
if (cls.name != null) return '${cls.name}.${func.name}';
}
return func.name!;
}
class StdoutLog extends Log {
@override
void warning(String message) => print(message);
@override
void severe(String message) => print(message);
}