blob: 76227bac9f3ec784b8ce547d701d3f16b8da6c58 [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 'isolate_paused_listener.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 for an isolate until it
/// is 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.
///
/// If [coverableLineCache] is set, the collector will avoid recompiling
/// libraries it has already seen (see VmService.getSourceReport's
/// librariesAlreadyCompiled parameter). This is only useful when doing more
/// than one [collect] call over the same libraries. Pass an empty map to the
/// first call, and then pass the same map to all subsequent calls.
///
/// [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,
Map<String, Set<int>>? coverableLineCache,
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, socket.add,
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 {
return await _getAllCoverage(
service,
includeDart,
functionCoverage,
branchCoverage,
scopedOutput,
isolateIds,
coverableLineCache,
waitPaused);
} finally {
if (resume && !waitPaused) {
await _resumeIsolates(service);
}
// The signature changed in vm_service version 6.0.0.
// ignore: await_only_futures
await service.dispose();
}
}
Future<Map<String, dynamic>> _getAllCoverage(
VmService service,
bool includeDart,
bool functionCoverage,
bool branchCoverage,
Set<String> scopedOutput,
Set<String>? isolateIds,
Map<String, Set<int>>? coverableLineCache,
bool waitPaused) async {
final allCoverage = <Map<String, dynamic>>[];
final sourceReportKinds = [
SourceReportKind.kCoverage,
if (branchCoverage) SourceReportKind.kBranchCoverage,
];
final librariesAlreadyCompiled = coverableLineCache?.keys.toList();
// 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 coveredIsolateGroups = <String>{};
Future<void> collectIsolate(IsolateRef isolateRef) async {
if (!(isolateIds?.contains(isolateRef.id) ?? true)) return;
// coveredIsolateGroups is only relevant for the !waitPaused flow. The
// waitPaused flow achieves the same once-per-group behavior using the
// isLastIsolateInGroup flag.
final isolateGroupId = isolateRef.isolateGroupId;
if (isolateGroupId != null) {
if (coveredIsolateGroups.contains(isolateGroupId)) return;
coveredIsolateGroups.add(isolateGroupId);
}
late final SourceReport isolateReport;
try {
isolateReport = await service.getSourceReport(
isolateRef.id!,
sourceReportKinds,
forceCompile: true,
reportLines: true,
libraryFilters: scopedOutput.isNotEmpty
? List.from(scopedOutput.map((filter) => 'package:$filter/'))
: null,
librariesAlreadyCompiled: librariesAlreadyCompiled,
);
} on SentinelException {
return;
}
final coverage = await _processSourceReport(
service,
isolateRef,
isolateReport,
includeDart,
functionCoverage,
coverableLineCache,
scopedOutput);
allCoverage.addAll(coverage);
}
if (waitPaused) {
await IsolatePausedListener(service,
(IsolateRef isolateRef, bool isLastIsolateInGroup) async {
if (isLastIsolateInGroup) {
await collectIsolate(isolateRef);
}
}, stderr.writeln)
.waitUntilAllExited();
} else {
for (final isolateRef in await getAllIsolates(service)) {
await collectIsolate(isolateRef);
}
}
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
}
}
/// 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>>> _processSourceReport(
VmService service,
IsolateRef isolateRef,
SourceReport report,
bool includeDart,
bool functionCoverage,
Map<String, Set<int>>? coverableLineCache,
Set<String> scopedOutput) async {
final hitMaps = <Uri, HitMap>{};
final scripts = <ScriptRef, Script>{};
final libraries = <LibraryRef>{};
final needScripts = functionCoverage;
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.new);
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);
// TODO(liama): Is this still necessary, or is location.line valid?
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 scriptUriString = scriptRef.uri;
if (!scopedOutput.includesScript(scriptUriString)) {
// Sometimes a range's script can be different to the function's script
// (eg mixins), so we have to re-check the scope filter.
// See https://github.com/dart-lang/tools/issues/530
continue;
}
final scriptUri = Uri.parse(scriptUriString!);
// If we have a coverableLineCache, use it in the same way we use
// SourceReportCoverage.misses: to add zeros to the coverage result for all
// the lines that don't have a hit. Afterwards, add all the lines that were
// hit or missed to the cache, so that the next coverage collection won't
// need to compile this library.
final coverableLines =
coverableLineCache?.putIfAbsent(scriptUriString, () => <int>{});
// 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) {
if (tokenPositions == null) return;
for (final line in tokenPositions) {
body(line);
}
}
if (coverableLines != null) {
for (final line in coverableLines) {
hits.lineHits.putIfAbsent(line, () => 0);
}
}
forEachLine(coverage.hits, (line) {
hits.lineHits.increment(line);
coverableLines?.add(line);
if (hits.funcNames != null && hits.funcNames!.containsKey(line)) {
hits.funcHits!.increment(line);
}
});
forEachLine(coverage.misses, (line) {
hits.lineHits.putIfAbsent(line, () => 0);
coverableLines?.add(line);
});
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);
}
extension _ScopedOutput on Set<String> {
bool includesScript(String? scriptUriString) {
if (scriptUriString == null) return false;
// If the set is empty, it means the user didn't specify a --scope-output
// flag, so allow everything.
if (isEmpty) return true;
final scriptUri = Uri.parse(scriptUriString);
if (scriptUri.scheme != 'package') return false;
final scope = scriptUri.pathSegments.first;
return contains(scope);
}
}