diff --git a/pkgs/coverage/CHANGELOG.md b/pkgs/coverage/CHANGELOG.md index e14e83c8d..9ef5103b6 100644 --- a/pkgs/coverage/CHANGELOG.md +++ b/pkgs/coverage/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.10.0-wip + +- Fix bug where tests involving multiple isolates never finish (#520). + ## 1.9.2 - Fix repository link in pubspec. diff --git a/pkgs/coverage/lib/src/collect.dart b/pkgs/coverage/lib/src/collect.dart index fa298b50c..76227bac9 100644 --- a/pkgs/coverage/lib/src/collect.dart +++ b/pkgs/coverage/lib/src/collect.dart @@ -8,6 +8,7 @@ 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); @@ -25,8 +26,8 @@ const _debugTokenPositions = bool.fromEnvironment('DEBUG_COVERAGE'); /// 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 [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. @@ -93,14 +94,17 @@ Future> collect(Uri serviceUri, bool resume, } try { - if (waitPaused) { - await _waitIsolatesPaused(service, timeout: timeout); - } - - return await _getAllCoverage(service, includeDart, functionCoverage, - branchCoverage, scopedOutput, isolateIds, coverableLineCache); + return await _getAllCoverage( + service, + includeDart, + functionCoverage, + branchCoverage, + scopedOutput, + isolateIds, + coverableLineCache, + waitPaused); } finally { - if (resume) { + if (resume && !waitPaused) { await _resumeIsolates(service); } // The signature changed in vm_service version 6.0.0. @@ -114,11 +118,10 @@ Future> _getAllCoverage( bool includeDart, bool functionCoverage, bool branchCoverage, - Set? scopedOutput, + Set scopedOutput, Set? isolateIds, - Map>? coverableLineCache) async { - scopedOutput ??= {}; - final vm = await service.getVM(); + Map>? coverableLineCache, + bool waitPaused) async { final allCoverage = >[]; final sourceReportKinds = [ @@ -133,11 +136,15 @@ Future> _getAllCoverage( // group, otherwise we'll double count the hits. final coveredIsolateGroups = {}; - for (var isolateRef in vm.isolates!) { - if (isolateIds != null && !isolateIds.contains(isolateRef.id)) continue; + 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)) continue; + if (coveredIsolateGroups.contains(isolateGroupId)) return; coveredIsolateGroups.add(isolateGroupId); } @@ -154,8 +161,9 @@ Future> _getAllCoverage( librariesAlreadyCompiled: librariesAlreadyCompiled, ); } on SentinelException { - continue; + return; } + final coverage = await _processSourceReport( service, isolateRef, @@ -166,6 +174,21 @@ Future> _getAllCoverage( 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}; } @@ -190,29 +213,6 @@ Future _resumeIsolates(VmService service) async { } } -Future _waitIsolatesPaused(VmService service, {Duration? timeout}) async { - final pauseEvents = { - EventKind.kPauseStart, - EventKind.kPauseException, - EventKind.kPauseExit, - EventKind.kPauseInterrupted, - EventKind.kPauseBreakpoint - }; - - Future allPaused() async { - final vm = await service.getVM(); - if (vm.isolates!.isEmpty) throw StateError('No isolates.'); - for (var isolateRef in vm.isolates!) { - final isolate = await service.getIsolate(isolateRef.id!); - if (!pauseEvents.contains(isolate.pauseEvent!.kind)) { - throw StateError('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 @@ -278,6 +278,7 @@ Future>> _processSourceReport( 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) { @@ -299,7 +300,7 @@ Future>> _processSourceReport( 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/coverage/issues/495 + // See https://github.com/dart-lang/tools/issues/530 continue; } final scriptUri = Uri.parse(scriptUriString!); @@ -308,7 +309,7 @@ Future>> _processSourceReport( // 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 libarry. + // need to compile this library. final coverableLines = coverableLineCache?.putIfAbsent(scriptUriString, () => {}); diff --git a/pkgs/coverage/lib/src/isolate_paused_listener.dart b/pkgs/coverage/lib/src/isolate_paused_listener.dart new file mode 100644 index 000000000..a89a88b8a --- /dev/null +++ b/pkgs/coverage/lib/src/isolate_paused_listener.dart @@ -0,0 +1,275 @@ +// Copyright (c) 2024, 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:collection'; + +import 'package:meta/meta.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'util.dart'; + +typedef SyncIsolateCallback = void Function(IsolateRef isolate); +typedef AsyncIsolateCallback = Future Function(IsolateRef isolate); +typedef AsyncIsolatePausedCallback = Future Function( + IsolateRef isolate, bool isLastIsolateInGroup); +typedef AsyncVmServiceEventCallback = Future Function(Event event); +typedef SyncErrorLogger = void Function(String message); + +/// Calls onIsolatePaused whenever an isolate reaches the pause-on-exit state, +/// and passes a flag stating whether that isolate is the last one in the group. +class IsolatePausedListener { + IsolatePausedListener(this._service, this._onIsolatePaused, this._log); + + final VmService _service; + final AsyncIsolatePausedCallback _onIsolatePaused; + final SyncErrorLogger _log; + + final _isolateGroups = {}; + + int _numNonMainIsolates = 0; + final _allNonMainIsolatesExited = Completer(); + bool _finished = false; + + IsolateRef? _mainIsolate; + final _mainIsolatePaused = Completer(); + + /// Starts listening and returns a future that completes when all isolates + /// have exited. + Future waitUntilAllExited() async { + await listenToIsolateLifecycleEvents(_service, _onStart, _onPause, _onExit); + + await _allNonMainIsolatesExited.future; + + // Resume the main isolate. + try { + if (_mainIsolate != null) { + if (await _mainIsolatePaused.future) { + await _runCallbackAndResume(_mainIsolate!, true); + } + } + } finally { + _finished = true; + } + } + + IsolateGroupState _getGroup(IsolateRef isolateRef) => + _isolateGroups[isolateRef.isolateGroupId!] ??= IsolateGroupState(); + + void _onStart(IsolateRef isolateRef) { + if (_finished) return; + final group = _getGroup(isolateRef); + group.start(isolateRef.id!); + if (_mainIsolate == null && _isMainIsolate(isolateRef)) { + _mainIsolate = isolateRef; + } else { + ++_numNonMainIsolates; + } + } + + Future _onPause(IsolateRef isolateRef) async { + if (_finished) return; + final group = _getGroup(isolateRef); + group.pause(isolateRef.id!); + if (isolateRef.id! == _mainIsolate?.id) { + _mainIsolatePaused.complete(true); + + // If the main isolate is the only isolate, then _allNonMainIsolatesExited + // will never be completed. So check that case here. + _checkCompleted(); + } else { + await _runCallbackAndResume(isolateRef, group.noRunningIsolates); + } + } + + Future _runCallbackAndResume( + IsolateRef isolateRef, bool isLastIsolateInGroup) async { + if (isLastIsolateInGroup) { + _getGroup(isolateRef).collected = true; + } + try { + await _onIsolatePaused(isolateRef, isLastIsolateInGroup); + } finally { + await _service.resume(isolateRef.id!); + } + } + + void _onExit(IsolateRef isolateRef) { + if (_finished) return; + final group = _getGroup(isolateRef); + group.exit(isolateRef.id!); + if (group.noLiveIsolates && !group.collected) { + _log('ERROR: An isolate exited without pausing, causing ' + 'coverage data to be lost for group ${isolateRef.isolateGroupId!}.'); + } + if (isolateRef.id! == _mainIsolate?.id) { + if (!_mainIsolatePaused.isCompleted) { + // Main isolate exited without pausing. + _mainIsolatePaused.complete(false); + } + } else { + --_numNonMainIsolates; + _checkCompleted(); + } + } + + void _checkCompleted() { + if (_numNonMainIsolates == 0 && !_allNonMainIsolatesExited.isCompleted) { + _allNonMainIsolatesExited.complete(); + } + } + + static bool _isMainIsolate(IsolateRef isolateRef) { + // HACK: This should pretty reliably detect the main isolate, but it's not + // foolproof and relies on unstable features. The Dart standalone embedder + // and Flutter both call the main isolate "main", and they both also list + // this isolate first when querying isolates from the VM service. So + // selecting the first isolate named "main" combines these conditions and + // should be reliable enough for now, while we wait for a better test. + // TODO(https://github.com/dart-lang/sdk/issues/56732): Switch to more + // reliable test when it's available. + return isolateRef.name == 'main'; + } +} + +/// Listens to isolate start and pause events, and backfills events for isolates +/// that existed before listening started. +/// +/// Ensures that: +/// - Every [onIsolatePaused] and [onIsolateExited] call will be preceeded by +/// an [onIsolateStarted] call for the same isolate. +/// - Not every [onIsolateExited] call will be preceeded by a [onIsolatePaused] +/// call, but a [onIsolatePaused] will never follow a [onIsolateExited]. +/// - [onIsolateExited] will always run after [onIsolatePaused] completes, even +/// if an exit event arrives while [onIsolatePaused] is being awaited. +/// - Each callback will only be called once per isolate. +Future listenToIsolateLifecycleEvents( + VmService service, + SyncIsolateCallback onIsolateStarted, + AsyncIsolateCallback onIsolatePaused, + SyncIsolateCallback onIsolateExited) async { + final started = {}; + void onStart(IsolateRef isolateRef) { + if (started.add(isolateRef.id!)) onIsolateStarted(isolateRef); + } + + final paused = >{}; + Future onPause(IsolateRef isolateRef) async { + try { + onStart(isolateRef); + } finally { + await (paused[isolateRef.id!] ??= onIsolatePaused(isolateRef)); + } + } + + final exited = {}; + Future onExit(IsolateRef isolateRef) async { + onStart(isolateRef); + if (exited.add(isolateRef.id!)) { + try { + // Wait for in-progress pause callbacks, and prevent future pause + // callbacks from running. + await (paused[isolateRef.id!] ??= Future.value()); + } finally { + onIsolateExited(isolateRef); + } + } + } + + final eventBuffer = IsolateEventBuffer((Event event) async { + switch (event.kind) { + case EventKind.kIsolateStart: + return onStart(event.isolate!); + case EventKind.kPauseExit: + return await onPause(event.isolate!); + case EventKind.kIsolateExit: + return await onExit(event.isolate!); + } + }); + + // Listen for isolate start/exit events. + service.onIsolateEvent.listen(eventBuffer.add); + await service.streamListen(EventStreams.kIsolate); + + // Listen for isolate paused events. + service.onDebugEvent.listen(eventBuffer.add); + await service.streamListen(EventStreams.kDebug); + + // Backfill. Add/pause isolates that existed before we subscribed. + for (final isolateRef in await getAllIsolates(service)) { + onStart(isolateRef); + final isolate = await service.getIsolate(isolateRef.id!); + if (isolate.pauseEvent?.kind == EventKind.kPauseExit) { + await onPause(isolateRef); + } + } + + // Flush the buffered stream events, and the start processing them as they + // arrive. + await eventBuffer.flush(); +} + +/// Keeps track of isolates in an isolate group. +class IsolateGroupState { + // IDs of the isolates running in this group. + @visibleForTesting + final running = {}; + + // IDs of the isolates paused just before exiting in this group. + @visibleForTesting + final paused = {}; + + bool collected = false; + + bool get noRunningIsolates => running.isEmpty; + bool get noLiveIsolates => running.isEmpty && paused.isEmpty; + + void start(String id) { + paused.remove(id); + running.add(id); + } + + void pause(String id) { + running.remove(id); + paused.add(id); + } + + void exit(String id) { + running.remove(id); + paused.remove(id); + } + + @override + String toString() => '{running: $running, paused: $paused}'; +} + +/// Buffers VM service isolate [Event]s until [flush] is called. +/// +/// [flush] passes each buffered event to the handler function. After that, any +/// further events are immediately passed to the handler. [flush] returns a +/// future that completes when all the events in the queue have been handled (as +/// well as any events that arrive while flush is in progress). +class IsolateEventBuffer { + IsolateEventBuffer(this._handler); + + final AsyncVmServiceEventCallback _handler; + final _buffer = Queue(); + var _flushed = false; + + Future add(Event event) async { + if (_flushed) { + await _handler(event); + } else { + _buffer.add(event); + } + } + + Future flush() async { + while (_buffer.isNotEmpty) { + final event = _buffer.removeFirst(); + await _handler(event); + } + _flushed = true; + } +} diff --git a/pkgs/coverage/lib/src/util.dart b/pkgs/coverage/lib/src/util.dart index 7ffe74728..cc7f58425 100644 --- a/pkgs/coverage/lib/src/util.dart +++ b/pkgs/coverage/lib/src/util.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:vm_service/vm_service.dart'; + // TODO(cbracken) make generic /// Retries the specified function with the specified interval and returns /// the result on successful completion. @@ -179,3 +181,6 @@ Future serviceUriFromProcess(Stream procStdout) { }); return serviceUriCompleter.future; } + +Future> getAllIsolates(VmService service) async => + (await service.getVM()).isolates ?? []; diff --git a/pkgs/coverage/pubspec.yaml b/pkgs/coverage/pubspec.yaml index c0f26bfcd..c7dc933b8 100644 --- a/pkgs/coverage/pubspec.yaml +++ b/pkgs/coverage/pubspec.yaml @@ -1,5 +1,5 @@ name: coverage -version: 1.9.2 +version: 1.10.0-wip description: Coverage data manipulation and formatting repository: https://github.com/dart-lang/tools/tree/main/pkgs/coverage @@ -10,6 +10,7 @@ dependencies: args: ^2.0.0 glob: ^2.1.2 logging: ^1.0.0 + meta: ^1.0.2 package_config: ^2.0.0 path: ^1.8.0 source_maps: ^0.10.10 diff --git a/pkgs/coverage/test/chrome_test.dart b/pkgs/coverage/test/chrome_test.dart index 165692fd4..50e7600f4 100644 --- a/pkgs/coverage/test/chrome_test.dart +++ b/pkgs/coverage/test/chrome_test.dart @@ -2,7 +2,7 @@ // 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. -// TODO(#388): Fix and re-enable this test. +// TODO(https://github.com/dart-lang/tools/issues/494): Fix and re-enable this. @TestOn('!windows') library; diff --git a/pkgs/coverage/test/collect_coverage_mock_test.dart b/pkgs/coverage/test/collect_coverage_mock_test.dart index 2c173d509..372bd48b7 100644 --- a/pkgs/coverage/test/collect_coverage_mock_test.dart +++ b/pkgs/coverage/test/collect_coverage_mock_test.dart @@ -367,7 +367,7 @@ void main() { test( 'Collect coverage, scoped output, ' 'handles SourceReports that contain unfiltered ranges', () async { - // Regression test for https://github.com/dart-lang/coverage/issues/495 + // Regression test for https://github.com/dart-lang/tools/issues/530 final service = _mockService(4, 13); when(service.getSourceReport( 'isolate', diff --git a/pkgs/coverage/test/collect_coverage_test.dart b/pkgs/coverage/test/collect_coverage_test.dart index 3c61ee5b8..7262757be 100644 --- a/pkgs/coverage/test/collect_coverage_test.dart +++ b/pkgs/coverage/test/collect_coverage_test.dart @@ -132,7 +132,7 @@ void main() { 38: 1, 39: 1, 41: 1, - 42: 3, + 42: 4, 43: 1, 44: 3, 45: 1, @@ -149,7 +149,8 @@ void main() { 64: 1, 66: 1, 67: 1, - 68: 1 + 68: 1, + 71: 3, }; expect(isolateFile?.lineHits, expectedHits); expect(isolateFile?.funcHits, {11: 1, 19: 1, 23: 1, 28: 1, 38: 1}); @@ -162,63 +163,20 @@ void main() { }); expect( isolateFile?.branchHits, - {11: 1, 12: 1, 15: 0, 19: 1, 23: 1, 28: 1, 29: 1, 32: 0, 38: 1, 42: 1}, - ); - }); - - test('HitMap.parseJson, old VM without branch coverage', () async { - final resultString = await _collectCoverage(true, true); - final jsonResult = json.decode(resultString) as Map; - final coverage = jsonResult['coverage'] as List; - final hitMap = await HitMap.parseJson( - coverage.cast>(), + { + 11: 1, + 12: 1, + 15: 0, + 19: 1, + 23: 1, + 28: 1, + 29: 1, + 32: 0, + 38: 1, + 42: 1, + 71: 1, + }, ); - expect(hitMap, contains(_sampleAppFileUri)); - - final isolateFile = hitMap[_isolateLibFileUri]; - final expectedHits = { - 11: 1, - 12: 1, - 13: 1, - 15: 0, - 19: 1, - 23: 1, - 24: 2, - 28: 1, - 29: 1, - 30: 1, - 32: 0, - 38: 1, - 39: 1, - 41: 1, - 42: 3, - 43: 1, - 44: 3, - 45: 1, - 48: 1, - 49: 1, - 51: 1, - 54: 1, - 55: 1, - 56: 1, - 59: 1, - 60: 1, - 62: 1, - 63: 1, - 64: 1, - 66: 1, - 67: 1, - 68: 1 - }; - expect(isolateFile?.lineHits, expectedHits); - expect(isolateFile?.funcHits, {11: 1, 19: 1, 23: 1, 28: 1, 38: 1}); - expect(isolateFile?.funcNames, { - 11: 'fooSync', - 19: 'BarClass.BarClass', - 23: 'BarClass.baz', - 28: 'fooAsync', - 38: 'isolateTask' - }); }); test('parseCoverage', () async { diff --git a/pkgs/coverage/test/isolate_paused_listener_test.dart b/pkgs/coverage/test/isolate_paused_listener_test.dart new file mode 100644 index 000000000..4fc7d4b12 --- /dev/null +++ b/pkgs/coverage/test/isolate_paused_listener_test.dart @@ -0,0 +1,753 @@ +// Copyright (c) 2024, 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 'package:coverage/src/isolate_paused_listener.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'collect_coverage_mock_test.mocks.dart'; + +Event event( + String id, { + String? kind, + String? groupId, + String? name, +}) => + Event( + kind: kind, + isolate: IsolateRef( + isolateGroupId: groupId, + id: id, + name: name, + )); + +Isolate isolate( + String id, { + String? groupId, + String? name, + String? pauseKind, +}) => + Isolate( + isolateGroupId: groupId, + id: id, + name: name, + pauseEvent: pauseKind == null ? null : Event(kind: pauseKind), + ); + +(MockVmService, StreamController) createServiceAndEventStreams() { + final service = MockVmService(); + when(service.streamListen(any)).thenAnswer((_) async => Success()); + + // The VM service events we care about come in on 2 different streams, + // onIsolateEvent and onDebugEvent. We want to write tests that send sequences + // of events like [I1, D1, I2, D2, I3, D3], but since I and D go to separate + // streams, the listener may see them arrive like [I1, I2, I3, D1, D2, D3] or + // [D1, D2, D3, I1, I2, I3] or any other interleaving. So instead we send all + // the events through a single stream that gets split up. This emulates how + // the events work in reality, since they all come from a single web socket. + final allEvents = StreamController(); + final isolateEvents = StreamController(); + final debugEvents = StreamController(); + allEvents.stream.listen((Event e) { + if (e.kind == EventKind.kIsolateStart || + e.kind == EventKind.kIsolateStart) { + isolateEvents.add(e); + } else { + debugEvents.add(e); + } + }); + when(service.onIsolateEvent).thenAnswer((_) => isolateEvents.stream); + when(service.onDebugEvent).thenAnswer((_) => debugEvents.stream); + + return (service, allEvents); +} + +void main() { + group('IsolateEventBuffer', () { + test('buffers events', () async { + final received = []; + final eventBuffer = IsolateEventBuffer((Event event) async { + await Future.delayed(Duration.zero); + received.add(event.isolate!.id!); + }); + + await eventBuffer.add(event('a')); + await eventBuffer.add(event('b')); + await eventBuffer.add(event('c')); + expect(received, []); + + await eventBuffer.flush(); + expect(received, ['a', 'b', 'c']); + + await eventBuffer.flush(); + expect(received, ['a', 'b', 'c']); + + await eventBuffer.add(event('d')); + await eventBuffer.add(event('e')); + await eventBuffer.add(event('f')); + expect(received, ['a', 'b', 'c', 'd', 'e', 'f']); + + await eventBuffer.flush(); + expect(received, ['a', 'b', 'c', 'd', 'e', 'f']); + }); + + test('buffers events during flush', () async { + final received = []; + final pause = Completer(); + final eventBuffer = IsolateEventBuffer((Event event) async { + await pause.future; + received.add(event.isolate!.id!); + }); + + await eventBuffer.add(event('a')); + await eventBuffer.add(event('b')); + await eventBuffer.add(event('c')); + expect(received, []); + + final flushing = eventBuffer.flush(); + expect(received, []); + + await eventBuffer.add(event('d')); + await eventBuffer.add(event('e')); + await eventBuffer.add(event('f')); + expect(received, []); + + pause.complete(); + await flushing; + expect(received, ['a', 'b', 'c', 'd', 'e', 'f']); + }); + }); + + test('IsolateEventBuffer', () { + final group = IsolateGroupState(); + expect(group.running, isEmpty); + expect(group.paused, isEmpty); + expect(group.noRunningIsolates, isTrue); + expect(group.noLiveIsolates, isTrue); + + group.start('a'); + expect(group.running, unorderedEquals(['a'])); + expect(group.paused, isEmpty); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.start('a'); + expect(group.running, unorderedEquals(['a'])); + expect(group.paused, isEmpty); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.start('b'); + expect(group.running, unorderedEquals(['a', 'b'])); + expect(group.paused, isEmpty); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.pause('a'); + expect(group.running, unorderedEquals(['b'])); + expect(group.paused, unorderedEquals(['a'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.pause('a'); + expect(group.running, unorderedEquals(['b'])); + expect(group.paused, unorderedEquals(['a'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.pause('c'); + expect(group.running, unorderedEquals(['b'])); + expect(group.paused, unorderedEquals(['a', 'c'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.start('c'); + expect(group.running, unorderedEquals(['b', 'c'])); + expect(group.paused, unorderedEquals(['a'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.pause('c'); + expect(group.running, unorderedEquals(['b'])); + expect(group.paused, unorderedEquals(['a', 'c'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.exit('a'); + expect(group.running, unorderedEquals(['b'])); + expect(group.paused, unorderedEquals(['c'])); + expect(group.noRunningIsolates, isFalse); + expect(group.noLiveIsolates, isFalse); + + group.pause('b'); + expect(group.running, isEmpty); + expect(group.paused, unorderedEquals(['b', 'c'])); + expect(group.noRunningIsolates, isTrue); + expect(group.noLiveIsolates, isFalse); + + group.exit('b'); + expect(group.running, isEmpty); + expect(group.paused, unorderedEquals(['c'])); + expect(group.noRunningIsolates, isTrue); + expect(group.noLiveIsolates, isFalse); + + group.exit('c'); + expect(group.running, isEmpty); + expect(group.paused, isEmpty); + expect(group.noRunningIsolates, isTrue); + expect(group.noLiveIsolates, isTrue); + }); + + group('listenToIsolateLifecycleEvents', () { + late MockVmService service; + late StreamController allEvents; + late Completer> isolates; + late Future backfilled; + late Future testEnded; + + late List received; + Future? delayTheOnPauseCallback; + + void startEvent(String id) => + allEvents.add(event(id, kind: EventKind.kIsolateStart)); + void exitEvent(String id) => + allEvents.add(event(id, kind: EventKind.kIsolateExit)); + void pauseEvent(String id) => + allEvents.add(event(id, kind: EventKind.kPauseExit)); + void otherEvent(String id, String kind) => + allEvents.add(event(id, kind: kind)); + + Future backfill(List isos) async { + isolates.complete(isos); + await backfilled; + } + + // We end the test by sending an exit event with a specific ID. + const endTestEventId = 'END'; + Future endTest() { + exitEvent(endTestEventId); + return testEnded; + } + + setUp(() { + (service, allEvents) = createServiceAndEventStreams(); + + isolates = Completer>(); + when(service.getVM()) + .thenAnswer((_) async => VM(isolates: await isolates.future)); + when(service.getIsolate(any)).thenAnswer((invocation) async { + final id = invocation.positionalArguments[0]; + return (await isolates.future).firstWhere((iso) => iso.id == id); + }); + + received = []; + delayTheOnPauseCallback = null; + final testEnder = Completer(); + testEnded = testEnder.future; + backfilled = listenToIsolateLifecycleEvents( + service, + (iso) { + if (iso.id == endTestEventId) return; + received.add('Start ${iso.id}'); + }, + (iso) async { + received.add('Pause ${iso.id}'); + if (delayTheOnPauseCallback != null) { + await delayTheOnPauseCallback; + received.add('Pause done ${iso.id}'); + } + }, + (iso) { + if (iso.id == endTestEventId) { + testEnder.complete(); + } else { + received.add('Exit ${iso.id}'); + } + }, + ); + }); + + test('ordinary flows', () async { + // Events sent before backfill. + startEvent('A'); + startEvent('C'); + startEvent('B'); + pauseEvent('C'); + pauseEvent('A'); + startEvent('D'); + pauseEvent('D'); + exitEvent('A'); + + // Run backfill. + await backfill([ + isolate('B'), + isolate('C', pauseKind: EventKind.kPauseExit), + isolate('D'), + isolate('E'), + isolate('F', pauseKind: EventKind.kPauseExit), + ]); + + // All the backfill events happen before any of the real events. + expect(received, [ + // Backfill events. + 'Start B', + 'Start C', + 'Pause C', + 'Start D', + 'Start E', + 'Start F', + 'Pause F', + + // Real events from before backfill. + 'Start A', + 'Pause A', + 'Pause D', + 'Exit A', + ]); + + // Events sent after backfill. + received.clear(); + startEvent('G'); + exitEvent('C'); + exitEvent('B'); + exitEvent('G'); + exitEvent('D'); + exitEvent('E'); + exitEvent('F'); + + await endTest(); + expect(received, [ + 'Start G', + 'Exit C', + 'Exit B', + 'Exit G', + 'Exit D', + 'Exit E', + 'Exit F', + ]); + + verify(service.streamListen(EventStreams.kIsolate)).called(1); + verify(service.streamListen(EventStreams.kDebug)).called(1); + }); + + test('pause and exit events without start', () async { + await backfill([]); + + pauseEvent('A'); + exitEvent('B'); + + await endTest(); + expect(received, [ + 'Start A', + 'Pause A', + 'Start B', + 'Exit B', + ]); + }); + + test('pause event after exit is ignored', () async { + await backfill([]); + + exitEvent('A'); + pauseEvent('A'); + + await endTest(); + expect(received, [ + 'Start A', + 'Exit A', + ]); + }); + + test('event deduping', () async { + startEvent('A'); + startEvent('A'); + pauseEvent('A'); + pauseEvent('A'); + exitEvent('A'); + exitEvent('A'); + + pauseEvent('B'); + startEvent('B'); + + exitEvent('C'); + startEvent('C'); + + await backfill([]); + await endTest(); + expect(received, [ + 'Start A', + 'Pause A', + 'Exit A', + 'Start B', + 'Pause B', + 'Start C', + 'Exit C', + ]); + }); + + test('ignore other events', () async { + await backfill([]); + + startEvent('A'); + pauseEvent('A'); + otherEvent('A', EventKind.kResume); + exitEvent('A'); + + startEvent('B'); + otherEvent('B', EventKind.kPauseBreakpoint); + exitEvent('B'); + + otherEvent('C', EventKind.kInspect); + + await endTest(); + expect(received, [ + 'Start A', + 'Pause A', + 'Exit A', + 'Start B', + 'Exit B', + ]); + }); + + test('exit event during pause callback', () async { + final delayingTheOnPauseCallback = Completer(); + delayTheOnPauseCallback = delayingTheOnPauseCallback.future; + await backfill([]); + + startEvent('A'); + pauseEvent('A'); + exitEvent('A'); + + while (received.length < 2) { + await Future.delayed(Duration.zero); + } + + expect(received, [ + 'Start A', + 'Pause A', + ]); + + delayingTheOnPauseCallback.complete(); + await endTest(); + expect(received, [ + 'Start A', + 'Pause A', + 'Pause done A', + 'Exit A', + ]); + }); + + test('exit event during pause callback, event deduping', () async { + final delayingTheOnPauseCallback = Completer(); + delayTheOnPauseCallback = delayingTheOnPauseCallback.future; + await backfill([]); + + startEvent('A'); + pauseEvent('A'); + exitEvent('A'); + pauseEvent('A'); + pauseEvent('A'); + exitEvent('A'); + exitEvent('A'); + + while (received.length < 2) { + await Future.delayed(Duration.zero); + } + + expect(received, [ + 'Start A', + 'Pause A', + ]); + + delayingTheOnPauseCallback.complete(); + await endTest(); + expect(received, [ + 'Start A', + 'Pause A', + 'Pause done A', + 'Exit A', + ]); + }); + }); + + group('IsolatePausedListener', () { + late MockVmService service; + late StreamController allEvents; + late Future allIsolatesExited; + + late List received; + late bool stopped; + + void startEvent(String id, String groupId, [String? name]) => + allEvents.add(event( + id, + kind: EventKind.kIsolateStart, + groupId: groupId, + name: name ?? id, + )); + void exitEvent(String id, String groupId, [String? name]) => + allEvents.add(event( + id, + kind: EventKind.kIsolateExit, + groupId: groupId, + name: name ?? id, + )); + void pauseEvent(String id, String groupId, [String? name]) => + allEvents.add(event( + id, + kind: EventKind.kPauseExit, + groupId: groupId, + name: name ?? id, + )); + + Future endTest() async { + await allIsolatesExited; + stopped = true; + } + + setUp(() { + (service, allEvents) = createServiceAndEventStreams(); + + // Backfill was tested above, so this test does everything using events, + // for simplicity. No need to report any isolates. + when(service.getVM()).thenAnswer((_) async => VM()); + + received = []; + when(service.resume(any)).thenAnswer((invocation) async { + final id = invocation.positionalArguments[0]; + received.add('Resume $id'); + return Success(); + }); + + stopped = false; + allIsolatesExited = IsolatePausedListener( + service, + (iso, isLastIsolateInGroup) async { + expect(stopped, isFalse); + received.add('Pause ${iso.id}. Last in group ${iso.isolateGroupId}? ' + '${isLastIsolateInGroup ? 'Yes' : 'No'}'); + }, + (message) => received.add(message), + ).waitUntilAllExited(); + }); + + test('ordinary flows', () async { + startEvent('A', '1'); + startEvent('B', '1'); + pauseEvent('A', '1'); + startEvent('C', '1'); + pauseEvent('B', '1'); + exitEvent('A', '1'); + startEvent('D', '2'); + startEvent('E', '2'); + startEvent('F', '2'); + pauseEvent('C', '1'); + pauseEvent('F', '2'); + pauseEvent('E', '2'); + exitEvent('C', '1'); + exitEvent('E', '2'); + startEvent('G', '3'); + exitEvent('F', '2'); + startEvent('H', '3'); + startEvent('I', '3'); + pauseEvent('I', '3'); + exitEvent('I', '3'); + pauseEvent('H', '3'); + exitEvent('H', '3'); + pauseEvent('D', '2'); + pauseEvent('G', '3'); + exitEvent('D', '2'); + exitEvent('G', '3'); + exitEvent('B', '1'); + + await endTest(); + + // Events sent after waitUntilAllExited is finished do nothing. + startEvent('Z', '9'); + pauseEvent('Z', '9'); + exitEvent('Z', '9'); + + expect(received, [ + 'Pause A. Last in group 1? No', + 'Resume A', + 'Pause B. Last in group 1? No', + 'Resume B', + 'Pause C. Last in group 1? Yes', + 'Resume C', + 'Pause F. Last in group 2? No', + 'Resume F', + 'Pause E. Last in group 2? No', + 'Resume E', + 'Pause I. Last in group 3? No', + 'Resume I', + 'Pause H. Last in group 3? No', + 'Resume H', + 'Pause D. Last in group 2? Yes', + 'Resume D', + 'Pause G. Last in group 3? Yes', + 'Resume G', + ]); + }); + + test('exit without pausing', () async { + // If an isolate exits without pausing, this may mess up coverage + // collection (if it happens to be the last isolate in the group, that + // group won't be collected). The best we can do is log an error, and make + // sure not to wait forever for pause events that aren't coming. + startEvent('A', '1'); + startEvent('B', '1'); + exitEvent('A', '1'); + pauseEvent('B', '1'); + startEvent('C', '2'); + startEvent('D', '2'); + pauseEvent('D', '2'); + exitEvent('D', '2'); + exitEvent('C', '2'); + exitEvent('B', '1'); + + await endTest(); + + // B was paused correctly and was the last to exit isolate 1, so isolate 1 + // was collected ok. + expect(received, [ + 'Pause B. Last in group 1? Yes', + 'Resume B', + 'Pause D. Last in group 2? No', + 'Resume D', + 'ERROR: An isolate exited without pausing, causing coverage data to ' + 'be lost for group 2.', + ]); + }); + + test('main isolate resumed last', () async { + startEvent('A', '1', 'main'); + startEvent('B', '1', 'main'); // Second isolate named main, ignored. + pauseEvent('B', '1', 'main'); + startEvent('C', '2', 'main'); // Third isolate named main, ignored. + pauseEvent('A', '1', 'main'); + startEvent('D', '2'); + pauseEvent('C', '2'); + exitEvent('C', '2'); + pauseEvent('D', '2'); + exitEvent('D', '2'); + exitEvent('B', '1'); + + await endTest(); + + expect(received, [ + 'Pause B. Last in group 1? No', + 'Resume B', + 'Pause C. Last in group 2? No', + 'Resume C', + 'Pause D. Last in group 2? Yes', + 'Resume D', + 'Pause A. Last in group 1? Yes', + 'Resume A', + ]); + }); + + test('main isolate exits without pausing', () async { + startEvent('A', '1', 'main'); + startEvent('B', '1'); + pauseEvent('B', '1'); + exitEvent('A', '1', 'main'); + exitEvent('B', '1'); + + await endTest(); + + expect(received, [ + 'Pause B. Last in group 1? No', + 'Resume B', + 'ERROR: An isolate exited without pausing, causing coverage data to ' + 'be lost for group 1.', + ]); + }); + + test('main isolate is the only isolate', () async { + startEvent('A', '1', 'main'); + pauseEvent('A', '1', 'main'); + + await endTest(); + + expect(received, [ + 'Pause A. Last in group 1? Yes', + 'Resume A', + ]); + }); + + test('all other isolates exit before main isolate pauses', () async { + startEvent('A', '1', 'main'); + startEvent('B', '1'); + pauseEvent('B', '1'); + exitEvent('B', '1'); + + await Future.delayed(Duration.zero); + + pauseEvent('A', '1', 'main'); + exitEvent('A', '1', 'main'); + + await endTest(); + + expect(received, [ + 'Pause B. Last in group 1? No', + 'Resume B', + 'Pause A. Last in group 1? Yes', + 'Resume A', + ]); + }); + + test('group reopened', () async { + // If an isolate is reported in a group after the group as believed to be + // closed, reopen the group. This double counts some coverage, but at + // least won't miss any. + + startEvent('Z', '9'); // Separate isolate to keep the system alive until + pauseEvent('Z', '9'); // the test is complete. + + startEvent('A', '1'); + startEvent('B', '1'); + pauseEvent('A', '1'); + pauseEvent('B', '1'); + exitEvent('B', '1'); + exitEvent('A', '1'); + + startEvent('D', '2'); + startEvent('E', '2'); + pauseEvent('E', '2'); + pauseEvent('D', '2'); + exitEvent('E', '2'); + exitEvent('D', '2'); + + startEvent('C', '1'); + pauseEvent('F', '2'); + pauseEvent('C', '1'); + exitEvent('C', '1'); + exitEvent('F', '2'); + + exitEvent('Z', '9'); + + await endTest(); + + expect(received, [ + 'Pause Z. Last in group 9? Yes', + 'Resume Z', + 'Pause A. Last in group 1? No', + 'Resume A', + 'Pause B. Last in group 1? Yes', + 'Resume B', + 'Pause E. Last in group 2? No', + 'Resume E', + 'Pause D. Last in group 2? Yes', + 'Resume D', + 'Pause F. Last in group 2? Yes', + 'Resume F', + 'Pause C. Last in group 1? Yes', + 'Resume C', + ]); + }); + }); +} diff --git a/pkgs/coverage/test/run_and_collect_test.dart b/pkgs/coverage/test/run_and_collect_test.dart index 733fc7440..e371f9000 100644 --- a/pkgs/coverage/test/run_and_collect_test.dart +++ b/pkgs/coverage/test/run_and_collect_test.dart @@ -73,7 +73,6 @@ class ThrowingResolver implements Resolver { void checkIgnoredLinesInFilesCache( Map>?> ignoredLinesInFilesCache) { - expect(ignoredLinesInFilesCache.length, 4); final keys = ignoredLinesInFilesCache.keys.toList(); final testAppKey = keys.where((element) => element.endsWith('test_app.dart')).single; @@ -88,7 +87,7 @@ void checkIgnoredLinesInFilesCache( [51, 51], [53, 57], [62, 65], - [66, 69] + [66, 72] ]); } @@ -112,7 +111,7 @@ void checkHitmap(Map hitMap) { 38: 1, 39: 1, 41: 1, - 42: 3, + 42: 4, 43: 1, 44: 3, 45: 1, diff --git a/pkgs/coverage/test/test_files/test_app_isolate.dart b/pkgs/coverage/test/test_files/test_app_isolate.dart index e7ade553c..8bbc6be31 100644 --- a/pkgs/coverage/test/test_files/test_app_isolate.dart +++ b/pkgs/coverage/test/test_files/test_app_isolate.dart @@ -35,15 +35,15 @@ Future fooAsync(int x) async { /// The number of covered lines is tested and expected to be 4. /// /// If you modify this method, you may have to update the tests! -void isolateTask(List threeThings) { +void isolateTask(List threeThings) async { sleep(const Duration(milliseconds: 500)); fooSync(answer); - fooAsync(answer).then((_) { + unawaited(fooAsync(answer).then((_) { final port = threeThings.first as SendPort; final sum = (threeThings[1] as int) + (threeThings[2] as int); port.send(sum); - }); + })); final bar = BarClass(123); bar.baz(); @@ -66,5 +66,8 @@ void isolateTask(List threeThings) { print('9'); // coverage:ignore-start print('10'); print('11'); // coverage:ignore-line + + // Regression test for https://github.com/dart-lang/tools/issues/520. + await Isolate.run(() => print('Isolate.run'), debugName: 'Grandchild'); // coverage:ignore-end }