Fix ParallelWaitError in forEachIsolateHelper during service disconnect (#10074) `forEachIsolateHelper` used `[for (final isolate in vmIsolates) callback(isolate)].wait`. In Dart 3, the `Iterable.wait` extension wraps any error from completed futures in a `ParallelWaitError<V, E>`. When a target application exits or a VM Service connection is shut down while an operation is in flight (e.g. periodic polling in `refreshNetworkData`), VM Service methods like `getSocketProfileWrapper` throw `RPCError: (112) Service has disappeared`. Callers catch typed errors (e.g., `on RPCError catch (e)` in `network_service.dart` or `on SentinelException` / `on RPCError` in `service_extension_manager.dart`) and check `e.isServiceDisposedError` to safely swallow teardown exceptions. Because `ParallelWaitError` does not inherit from `RPCError` or `Exception`, it bypassed these typed catch blocks and escaped uncaught, causing flaky integration test failures in `network_screen_test.dart`. Using `Future.wait` preserves the underlying exception type thrown by the isolate callback. Also update `_enableNetworkTrafficRecordingOnAllIsolates` to use `Future.wait`.
diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart index 88490a2..b340f9c 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart
@@ -377,10 +377,10 @@ /// Enables HTTP timeline logging and socket profiling on all isolates. Future<void> _enableNetworkTrafficRecordingOnAllIsolates() async { - await [ + await Future.wait([ http_service.toggleHttpRequestLogging(true), networkService.toggleSocketProfiling(true), - ].wait; + ]); } Future<void> stopRecording() async {
diff --git a/packages/devtools_app/test/screens/network/network_hot_restart_test.dart b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart index cbf9cd6..3e9cff1 100644 --- a/packages/devtools_app/test/screens/network/network_hot_restart_test.dart +++ b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart
@@ -374,6 +374,26 @@ ); test( + 'refreshNetworkData swallows service disappeared error during isolate iteration', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + localVmService.simulateServiceDisappeared = true; + await expectLater( + localController.networkService.refreshNetworkData(), + completes, + ); + }, + ); + + test( 'updateLastHttpDataRefreshTime does not add entries for new isolates', () async { final localVmService = HotRestartNetworkVmService();
diff --git a/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart index ea1eed2..cdd175e 100644 --- a/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart +++ b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart
@@ -109,6 +109,10 @@ return Success(); } + /// When true, [getSocketProfileWrapper] throws an [RPCError] with + /// [RPCErrorKind.kServiceDisappeared], simulating service shutdown. + bool simulateServiceDisappeared = false; + @override Future<void> forEachIsolate(Future<void> Function(IsolateRef) callback) => callback(IsolateRef.parse({'id': _currentIsolateId, 'name': 'main'})!); @@ -173,6 +177,13 @@ @override Future<SocketProfile> getSocketProfileWrapper(String isolateId) async { + if (simulateServiceDisappeared) { + throw RPCError( + 'ext.dart.io.getSocketProfile', + RPCErrorKind.kServiceDisappeared.code, + 'Service has disappeared', + ); + } if (!(_socketProfilingEnabled[isolateId] ?? false)) { return SocketProfile(sockets: []); }
diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index 683bc2b..577b5c2 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md
@@ -4,10 +4,14 @@ found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. --> ## 0.5.2-wip +* Fix `forEachIsolateHelper` to rethrow isolate callback exceptions directly + using `Future.wait` instead of wrapping them in a `ParallelWaitError`. * Fix a `RangeError` thrown by `SplitPane` when the number of children changes between rebuilds. -* Fix garbage collection and timeout issues with the result list in `asyncEval` on both native VM and web. -* Safely handle RPC errors and unexpected exceptions when calling service extensions in `ServiceExtensionManager`. +* Fix garbage collection and timeout issues with the result list in `asyncEval` + on both native VM and web. +* Safely handle RPC errors and unexpected exceptions when calling service + extensions in `ServiceExtensionManager`. * The minimum Dart SDK version is bumped to 3.11.0. * The minimum Flutter SDK version is bumped to 3.41.0. * Fix `RootInfo.package` to be `null` when the root library is not a `package:` URI,
diff --git a/packages/devtools_app_shared/lib/src/service/service_utils.dart b/packages/devtools_app_shared/lib/src/service/service_utils.dart index 5470605..82e0051 100644 --- a/packages/devtools_app_shared/lib/src/service/service_utils.dart +++ b/packages/devtools_app_shared/lib/src/service/service_utils.dart
@@ -88,6 +88,6 @@ ) async { final vm = await vmService.getVM(); if (vm.isolates case final vmIsolates? when vmIsolates.isNotEmpty) { - await [for (final isolate in vmIsolates) callback(isolate)].wait; + await Future.wait([for (final isolate in vmIsolates) callback(isolate)]); } }
diff --git a/packages/devtools_app_shared/test/service/service_utils_test.dart b/packages/devtools_app_shared/test/service/service_utils_test.dart new file mode 100644 index 0000000..9d3c7c2 --- /dev/null +++ b/packages/devtools_app_shared/test/service/service_utils_test.dart
@@ -0,0 +1,110 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/service.dart' hide SentinelException; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + group('forEachIsolateHelper', () { + test('calls callback for each isolate', () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + IsolateRef(id: 'isolate_2', number: '2', name: 'main_2'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final visited = <String>[]; + await forEachIsolateHelper(fakeService, (isolate) async { + visited.add(isolate.id!); + }); + expect(visited, equals(['isolate_1', 'isolate_2'])); + }); + + test('is a no-op when isolates list is empty or null', () async { + final emptyService = _FakeVmService(isolates: []); + var called = false; + await forEachIsolateHelper(emptyService, (isolate) async { + called = true; + }); + expect(called, isFalse); + + final nullService = _FakeVmService(isolates: null); + await forEachIsolateHelper(nullService, (isolate) async { + called = true; + }); + expect(called, isFalse); + }); + + test( + 'rethrows RPCError directly without wrapping in ParallelWaitError', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + IsolateRef(id: 'isolate_2', number: '2', name: 'main_2'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final error = RPCError( + 'ext.dart.io.getSocketProfile', + RPCErrorKind.kServiceDisappeared.code, + 'Service has disappeared', + ); + + await expectLater( + () => forEachIsolateHelper( + fakeService, + (isolate) => Future<void>.error(error), + ), + throwsA(same(error)), + ); + }, + ); + + test( + 'rethrows SentinelException directly without wrapping in ParallelWaitError', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final sentinel = SentinelException.parse('getIsolate', { + 'type': 'Sentinel', + 'kind': 'Collected', + 'valueAsString': '<collected>', + }); + + await expectLater( + () => forEachIsolateHelper( + fakeService, + (isolate) => Future<void>.error(sentinel), + ), + throwsA(same(sentinel)), + ); + }, + ); + + test( + 'VmServiceExtension.forEachIsolate delegates to forEachIsolateHelper', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final visited = <String>[]; + await fakeService.forEachIsolate((isolate) async { + visited.add(isolate.id!); + }); + expect(visited, equals(['isolate_1'])); + }, + ); + }); +} + +class _FakeVmService extends Fake implements VmService { + _FakeVmService({required this.isolates}); + + final List<IsolateRef>? isolates; + + @override + Future<VM> getVM() async => VM(isolates: isolates); +}