// 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> collect(Uri serviceUri, bool resume, bool waitPaused, bool includeDart, Set? scopedOutput, {Set? isolateIds, Duration? timeout, bool functionCoverage = false, bool branchCoverage = false, Map>? coverableLineCache, VmService? serviceOverrideForTesting}) async { scopedOutput ??= {}; 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(); 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> _getAllCoverage( VmService service, bool includeDart, bool functionCoverage, bool branchCoverage, Set scopedOutput, Set? isolateIds, Map>? coverableLineCache, bool waitPaused) async { final allCoverage = >[]; 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 = {}; Future 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, branchCoverage, 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 {'type': 'CodeCoverage', 'coverage': allCoverage}; } Future _resumeIsolates(VmService service) async { final vm = await service.getVM(); final futures = []; 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>> _processSourceReport( VmService service, IsolateRef isolateRef, SourceReport report, bool includeDart, bool functionCoverage, bool branchCoverage, Map>? coverableLineCache, Set scopedOutput) async { final hitMaps = {}; final scripts = {}; final libraries = {}; final needScripts = functionCoverage; Future 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.empty( functionCoverage: functionCoverage, branchCoverage: branchCoverage)); Future 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.funcNames![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, () => {}); // 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? 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 branches = range.branchCoverage; if (branchCoverage && branches != null) { forEachLine(branches.hits, (line) { hits.branchHits!.increment(line); }); forEachLine(branches.misses, (line) { hits.branchHits!.putIfAbsent(line, () => 0); }); } } // Output JSON final coverage = >[]; hitMaps.forEach((uri, hits) { coverage.add(hitmapToJson(hits, uri)); }); return coverage; } extension _MapExtension on Map { void increment(T key) => this[key] = (this[key] ?? 0) + 1; } Future _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 { 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); } }