Fix Network tab not capturing requests after hot restart and clear (#9856)
* Fix Network tab not capturing requests after hot restart and clear.
Re-enable HTTP logging and socket profiling when new isolates are spawned,
reset clear timestamps using the VM timeline, and keep the search field
enabled while recording.
* Add PR links to Network profiler release notes.
* Address review feedback for Network tab lifecycle fixes.
Keep search enabled when paused with visible requests, handle RPC errors
during clear, and wrap isolate profiling re-enable in allowedError.
* Update copyright year to 2026 and refactor timestamp values in network tests for clarity. Adjusted start times in various test cases and improved code readability in the HotRestartNetworkVmService class.
* docs: remove redundant release note for Network tab
* Address review feedback for Network tab lifecycle fixes.
Use VM timeline timestamps in updateLastHttpDataRefreshTime, restore
clearData helpers and the profiling TODO, and keep the Network search
field always enabled.
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 dfb9a90..086703f 100644
--- a/packages/devtools_app/lib/src/screens/network/network_controller.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart
@@ -192,6 +192,15 @@
_currentNetworkRequests,
_filterAndRefreshSearchMatches,
);
+ autoDisposeStreamSubscription(
+ serviceConnection.serviceManager.isolateManager.onIsolateCreated.listen((
+ _,
+ ) async {
+ if (_recordingNotifier.value) {
+ await allowedError(_enableNetworkTrafficRecordingOnAllIsolates());
+ }
+ }),
+ );
}
@override
@@ -327,7 +336,7 @@
// Cancel existing polling timer before starting recording.
_updatePollingState(false);
- networkService.updateLastHttpDataRefreshTime(
+ await networkService.updateLastHttpDataRefreshTime(
alreadyRecordingHttp: alreadyRecordingHttp,
);
final timestamp = await networkService.updateLastSocketDataRefreshTime(
@@ -352,11 +361,16 @@
// TODO(kenz): only call these if http logging and socket profiling are not
// already enabled. Listen to service manager streams for this info.
+ await _enableNetworkTrafficRecordingOnAllIsolates();
+ await togglePolling(true);
+ }
+
+ /// Enables HTTP timeline logging and socket profiling on all isolates.
+ Future<void> _enableNetworkTrafficRecordingOnAllIsolates() async {
await [
http_service.toggleHttpRequestLogging(true),
networkService.toggleSocketProfiling(true),
].wait;
- await togglePolling(true);
}
Future<void> stopRecording() async {
@@ -382,7 +396,7 @@
/// This will ensure that future fetches for http and socket requests will at
/// most fetch requests since [updateLastRefreshTime] was called.
Future<void> updateLastRefreshTime() async {
- networkService.updateLastHttpDataRefreshTime();
+ await networkService.updateLastHttpDataRefreshTime();
await networkService.updateLastSocketDataRefreshTime();
}
diff --git a/packages/devtools_app/lib/src/screens/network/network_service.dart b/packages/devtools_app/lib/src/screens/network/network_service.dart
index a93f35d..59fbb64 100644
--- a/packages/devtools_app/lib/src/screens/network/network_service.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_service.dart
@@ -14,8 +14,11 @@
NetworkController get networkController =>
screenControllers.lookup<NetworkController>();
- /// Tracks the time (microseconds since epoch) that the HTTP profile was last
- /// retrieved for a given isolate ID.
+ /// Tracks the VM timeline timestamp (microseconds) that the HTTP profile was
+ /// last retrieved for a given isolate ID.
+ ///
+ /// These values are passed to `getHttpProfile` as `updatedSince`, which must
+ /// use the VM's monotonic timeline clock — not wall-clock time.
final lastHttpDataRefreshTimePerIsolate = <String, int>{};
/// Updates the last Socket data refresh time to the current time.
@@ -41,21 +44,25 @@
return timestamp;
}
- /// Updates the last HTTP data refresh time to the current time.
+ /// Updates the last HTTP data refresh time to the current VM timeline time.
///
/// If [alreadyRecordingHttp] is true it's unclear when the last refresh time
/// would have occurred, so the refresh time is not updated. Otherwise,
- /// [lastHttpDataRefreshTimePerIsolate] is updated to the current
- /// time.
- void updateLastHttpDataRefreshTime({bool alreadyRecordingHttp = false}) {
+ /// [lastHttpDataRefreshTimePerIsolate] is updated to the current VM timeline
+ /// timestamp for each known isolate.
+ ///
+ /// Wall-clock time must not be used here: `getHttpProfile`'s `updatedSince`
+ /// expects the VM timeline clock. A wall-clock value would filter out all
+ /// subsequent requests.
+ Future<void> updateLastHttpDataRefreshTime({
+ bool alreadyRecordingHttp = false,
+ }) async {
if (!alreadyRecordingHttp) {
+ final service = serviceConnection.serviceManager.service;
+ if (service == null) return;
+ final timestamp = (await service.getVMTimelineMicros()).timestamp!;
for (final isolateId in lastHttpDataRefreshTimePerIsolate.keys.toList()) {
- // It's safe to use `DateTime.now()` here since we don't need to worry
- // about dropping data between the time the last profile was generated
- // by the target application and the time `DateTime.now()` is called
- // here.
- lastHttpDataRefreshTimePerIsolate[isolateId] =
- DateTime.now().microsecondsSinceEpoch;
+ lastHttpDataRefreshTimePerIsolate[isolateId] = timestamp;
}
}
}
@@ -202,9 +209,15 @@
}
Future<void> clearData() async {
- await updateLastSocketDataRefreshTime();
- updateLastHttpDataRefreshTime();
- await _clearSocketProfile();
- await _clearHttpProfile();
+ try {
+ await updateLastSocketDataRefreshTime();
+ await updateLastHttpDataRefreshTime();
+ await _clearSocketProfile();
+ await _clearHttpProfile();
+ } on RPCError catch (e) {
+ if (!e.isServiceDisposedError) {
+ rethrow;
+ }
+ }
}
}
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 0d7aeb8..998ed94 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -53,8 +53,12 @@
## Network profiler updates
-* Fixed exported response status in HAR files so that they parse as integers
- instead of strings. [#9900](https://github.com/flutter/devtools/pull/9900)
+* Fixed an issue where the Network tab would stop capturing HTTP requests after
+ a hot restart. -
+ [#9856](https://github.com/flutter/devtools/pull/9856)
+* Fixed an issue where the Network tab would stop capturing new HTTP requests
+ after pressing Clear while recording. -
+ [#9856](https://github.com/flutter/devtools/pull/9856)
## Logging updates
diff --git a/packages/devtools_app/test/screens/network/network_clear_test.dart b/packages/devtools_app/test/screens/network/network_clear_test.dart
new file mode 100644
index 0000000..b53140c
--- /dev/null
+++ b/packages/devtools_app/test/screens/network/network_clear_test.dart
@@ -0,0 +1,313 @@
+// 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.
+
+@TestOn('vm')
+library;
+
+import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'utils/hot_restart_network_vm_service.dart';
+import 'utils/network_lifecycle_test_utils.dart';
+import 'utils/network_test_utils.dart';
+
+void main() {
+ group('Network View clear button', () {
+ late HotRestartNetworkVmService vmService;
+ late FakeServiceConnectionManager fakeServiceConnection;
+
+ setUp(() {
+ vmService = HotRestartNetworkVmService();
+ fakeServiceConnection = FakeServiceConnectionManager(service: vmService);
+ });
+
+ tearDown(disposeNetworkLifecycleControllers);
+
+ group('request visibility after clear', () {
+ test('displays new HTTP requests after clear while recording', () async {
+ final isolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'before-clear', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(controller.requests.value, hasLength(1));
+
+ await controller.clear();
+ expect(controller.requests.value, isEmpty);
+
+ vmService.appendHttpRequest(
+ isolateId,
+ createTestHttpRequest(id: 'after-clear', method: 'POST'),
+ );
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('POST'),
+ reason:
+ 'Network View should display new requests after Clear while '
+ 'recording is active.',
+ );
+ });
+
+ test('polling remains active after clear', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'polling-test', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+
+ await controller.clear();
+
+ expect(controller.isPolling, isTrue);
+ expect(controller.recordingNotifier.value, isTrue);
+ });
+
+ test('keeps HTTP logging enabled after clear', () async {
+ final isolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'logging-test', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+
+ await controller.clear();
+
+ expect(vmService.isHttpLoggingEnabled(isolateId), isTrue);
+ });
+
+ test('keeps socket profiling enabled after clear', () async {
+ final isolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'socket-test', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+
+ await controller.clear();
+
+ expect(vmService.isSocketProfilingEnabled(isolateId), isTrue);
+ });
+ });
+
+ group('clear refresh timestamp tracking', () {
+ test(
+ 'resets HTTP refresh timestamps to the VM timeline on clear',
+ () async {
+ final isolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ await controller.networkService.refreshNetworkData();
+
+ controller
+ .networkService
+ .lastHttpDataRefreshTimePerIsolate[isolateId] =
+ 500_000;
+
+ await controller.clear();
+
+ final timelineMicros =
+ (await vmService.getVMTimelineMicros()).timestamp!;
+ expect(
+ controller
+ .networkService
+ .lastHttpDataRefreshTimePerIsolate[isolateId],
+ timelineMicros,
+ );
+ expect(controller.lastSocketDataRefreshMicros, timelineMicros);
+ },
+ );
+
+ test('does not show stale requests after clear', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(
+ id: 'stale-request',
+ method: 'GET',
+ startTime: 1_500_000,
+ ),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(controller.requests.value, hasLength(1));
+
+ await controller.clear();
+ await controller.networkService.refreshNetworkData();
+
+ expect(controller.requests.value, isEmpty);
+ });
+ });
+
+ group('clear combined with hot restart', () {
+ test('clear then hot restart then new requests', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'pre-clear', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ await controller.clear();
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ await pumpEventQueue();
+
+ vmService.appendHttpRequest(
+ postRestartIsolateId,
+ createTestHttpRequest(
+ id: 'after-clear-and-restart',
+ method: 'PUT',
+ startTime: 9_000_000,
+ ),
+ );
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('PUT'),
+ );
+ expect(vmService.isHttpLoggingEnabled(postRestartIsolateId), isTrue);
+ });
+
+ test('hot restart then clear then new requests', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'pre-restart', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ await pumpEventQueue();
+ vmService.appendHttpRequest(
+ postRestartIsolateId,
+ createTestHttpRequest(
+ id: 'after-restart',
+ method: 'POST',
+ startTime: 8_000_000,
+ ),
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(controller.requests.value, isNotEmpty);
+
+ await controller.clear();
+ expect(controller.requests.value, isEmpty);
+
+ vmService.appendHttpRequest(
+ postRestartIsolateId,
+ createTestHttpRequest(
+ id: 'after-restart-and-clear',
+ method: 'DELETE',
+ startTime: 10_000_000,
+ ),
+ );
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('DELETE'),
+ );
+ });
+ });
+
+ group('state after clear', () {
+ test('clears selected request', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'selected', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ controller.selectedRequest.value = controller.requests.value.first;
+
+ await controller.clear();
+
+ expect(controller.selectedRequest.value, isNull);
+ });
+
+ test('preserves active search text', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(
+ id: 'searchable',
+ method: 'GET',
+ uri: 'https://example.com/api',
+ ),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ controller.search = 'example';
+
+ await controller.clear();
+
+ expect(controller.search, 'example');
+ });
+
+ test('supports multiple consecutive clears', () async {
+ final isolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'multi-clear-1', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+
+ await controller.clear();
+ await controller.clear();
+
+ vmService.appendHttpRequest(
+ isolateId,
+ createTestHttpRequest(
+ id: 'after-multi-clear',
+ method: 'PATCH',
+ startTime: 3_000_000,
+ ),
+ );
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('PATCH'),
+ );
+ });
+ });
+ });
+}
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
new file mode 100644
index 0000000..cbf9cd6
--- /dev/null
+++ b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart
@@ -0,0 +1,456 @@
+// 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.
+
+@TestOn('vm')
+library;
+
+import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'utils/hot_restart_network_vm_service.dart';
+import 'utils/network_lifecycle_test_utils.dart';
+import 'utils/network_test_utils.dart';
+
+void main() {
+ group('Network View hot restart', () {
+ late HotRestartNetworkVmService vmService;
+ late FakeServiceConnectionManager fakeServiceConnection;
+
+ setUp(() {
+ vmService = HotRestartNetworkVmService();
+ fakeServiceConnection = FakeServiceConnectionManager(service: vmService);
+ });
+
+ tearDown(disposeNetworkLifecycleControllers);
+
+ group('baseline behavior before hot restart', () {
+ test('displays HTTP requests after recording starts', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'pre-restart-1', method: 'GET'),
+ ],
+ );
+
+ expect(
+ vmService.isHttpLoggingEnabled(vmService.currentIsolateId),
+ isTrue,
+ );
+ await controller.networkService.refreshNetworkData();
+
+ expect(controller.requests.value, hasLength(1));
+ expect(
+ controller.requests.value.single,
+ isA<DartIOHttpRequestData>().having(
+ (request) => request.method,
+ 'method',
+ 'GET',
+ ),
+ );
+ });
+
+ test('continues polling while connected', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ expect(controller.isPolling, isTrue);
+ expect(controller.recordingNotifier.value, isTrue);
+ });
+ });
+
+ group('hot restart request visibility', () {
+ test(
+ 'continues displaying new HTTP requests after hot restart',
+ () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'pre-restart', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(controller.requests.value, hasLength(1));
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ await pumpEventQueue();
+ vmService.setHttpProfile(postRestartIsolateId, [
+ createTestHttpRequest(
+ id: 'post-restart',
+ method: 'POST',
+ startTime: 8_000_000,
+ ),
+ ]);
+
+ await controller.networkService.refreshNetworkData();
+
+ final methods = controller.requests.value
+ .whereType<DartIOHttpRequestData>()
+ .map((request) => request.method)
+ .toSet();
+ expect(
+ methods,
+ contains('POST'),
+ reason:
+ 'Network View should display requests made on the new isolate '
+ 'after a hot restart.',
+ );
+ },
+ );
+
+ test('polling remains active after hot restart', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ expect(controller.isPolling, isTrue);
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+
+ expect(controller.isPolling, isTrue);
+ expect(controller.recordingNotifier.value, isTrue);
+ });
+ });
+
+ group('profiling re-registration after hot restart', () {
+ test(
+ 're-enables HTTP timeline logging on the new isolate after hot restart',
+ () async {
+ final preRestartIsolateId = vmService.currentIsolateId;
+ await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ expect(vmService.isHttpLoggingEnabled(preRestartIsolateId), isTrue);
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ await pumpEventQueue();
+
+ expect(
+ vmService.isHttpLoggingEnabled(postRestartIsolateId),
+ isTrue,
+ reason:
+ 'HTTP timeline logging must be re-enabled on the new main '
+ 'isolate after a hot restart.',
+ );
+ },
+ );
+
+ test(
+ 're-enables socket profiling on the new isolate after hot restart',
+ () async {
+ final preRestartIsolateId = vmService.currentIsolateId;
+ await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ expect(
+ vmService.isSocketProfilingEnabled(preRestartIsolateId),
+ isTrue,
+ );
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ await pumpEventQueue();
+
+ expect(
+ vmService.isSocketProfilingEnabled(postRestartIsolateId),
+ isTrue,
+ reason:
+ 'Socket profiling must be re-enabled on the new main isolate '
+ 'after a hot restart.',
+ );
+ },
+ );
+ });
+
+ group('isolate refresh timestamp tracking', () {
+ test(
+ 'fetches HTTP profile data for a new isolate after profiling is re-enabled',
+ () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ await controller.networkService.refreshNetworkData();
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ vmService.setHttpProfile(postRestartIsolateId, [
+ createTestHttpRequest(
+ id: 'new-isolate-request',
+ method: 'PUT',
+ startTime: 9_000_000,
+ ),
+ ]);
+
+ await pumpEventQueue();
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.networkService.lastHttpDataRefreshTimePerIsolate
+ .containsKey(postRestartIsolateId),
+ isTrue,
+ reason:
+ 'NetworkService should track refresh timestamps for the new '
+ 'isolate after a hot restart.',
+ );
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('PUT'),
+ );
+ },
+ );
+
+ test(
+ 'stale isolate IDs do not prevent fetching from the new isolate',
+ () async {
+ final preRestartIsolateId = vmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ );
+ await controller.networkService.refreshNetworkData();
+
+ controller
+ .networkService
+ .lastHttpDataRefreshTimePerIsolate[preRestartIsolateId] =
+ 12_000_000;
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ vmService.setHttpProfile(postRestartIsolateId, [
+ createTestHttpRequest(
+ id: 'after-stale-isolate',
+ method: 'DELETE',
+ startTime: 10_000_000,
+ ),
+ ]);
+ await pumpEventQueue();
+ await controller.networkService.refreshNetworkData();
+
+ expect(
+ controller.requests.value.whereType<DartIOHttpRequestData>().map(
+ (request) => request.method,
+ ),
+ contains('DELETE'),
+ );
+ },
+ );
+ });
+
+ group('state restoration across hot restart', () {
+ test('retains pre-restart requests when not cleared', () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'kept-request', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(controller.requests.value, hasLength(1));
+
+ final postRestartIsolateId = vmService.simulateHotRestart();
+ notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId);
+ vmService.setHttpProfile(postRestartIsolateId, [
+ createTestHttpRequest(
+ id: 'new-request',
+ method: 'POST',
+ startTime: 11_000_000,
+ ),
+ ]);
+ await pumpEventQueue();
+ await controller.networkService.refreshNetworkData();
+
+ final methods = controller.requests.value
+ .whereType<DartIOHttpRequestData>()
+ .map((request) => request.method)
+ .toList();
+ expect(methods, contains('GET'));
+ expect(methods, contains('POST'));
+ });
+
+ test(
+ 'clears selected request when it is no longer in filtered data',
+ () async {
+ final controller = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: fakeServiceConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'selected-request', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ controller.selectedRequest.value = controller.requests.value.first;
+
+ await controller.clear();
+ expect(controller.selectedRequest.value, isNull);
+ },
+ );
+ });
+ });
+
+ group('Network View service lifecycle', () {
+ tearDown(disposeNetworkLifecycleControllers);
+
+ test(
+ 'starts recording when controller initializes while connected',
+ () async {
+ final reconnectionService = HotRestartNetworkVmService();
+ final connection = FakeServiceConnectionManager(
+ service: reconnectionService,
+ );
+ final reconnectedController = await initNetworkLifecycleController(
+ vmService: reconnectionService,
+ fakeServiceConnection: connection,
+ );
+
+ expect(reconnectedController.isPolling, isTrue);
+ expect(
+ reconnectionService.isHttpLoggingEnabled(
+ reconnectionService.currentIsolateId,
+ ),
+ isTrue,
+ );
+ },
+ );
+
+ test(
+ 're-initializes recording after controller is disposed and recreated',
+ () async {
+ final vmService = HotRestartNetworkVmService();
+ final connection = FakeServiceConnectionManager(service: vmService);
+ final isolateId = vmService.currentIsolateId;
+ await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: connection,
+ );
+
+ screenControllers.disposeConnectedControllers();
+
+ final recreatedController = await initNetworkLifecycleController(
+ vmService: vmService,
+ fakeServiceConnection: connection,
+ );
+
+ expect(recreatedController.isPolling, isTrue);
+ expect(vmService.isHttpLoggingEnabled(isolateId), isTrue);
+ },
+ );
+ });
+
+ group('NetworkService hot restart edge cases', () {
+ tearDown(disposeNetworkLifecycleControllers);
+
+ test(
+ 'refreshNetworkData is a no-op when the VM service is unavailable',
+ () async {
+ final localVmService = HotRestartNetworkVmService();
+ final localConnection = FakeServiceConnectionManager(
+ service: localVmService,
+ );
+ final localController = await initNetworkLifecycleController(
+ vmService: localVmService,
+ fakeServiceConnection: localConnection,
+ );
+
+ localConnection.serviceManager.service = null;
+ await expectLater(
+ localController.networkService.refreshNetworkData(),
+ completes,
+ );
+ expect(localController.requests.value, isEmpty);
+ },
+ );
+
+ test(
+ 'updateLastHttpDataRefreshTime does not add entries for new isolates',
+ () async {
+ final localVmService = HotRestartNetworkVmService();
+ final localConnection = FakeServiceConnectionManager(
+ service: localVmService,
+ );
+ final localController = await initNetworkLifecycleController(
+ vmService: localVmService,
+ fakeServiceConnection: localConnection,
+ );
+
+ final postRestartIsolateId = localVmService.simulateHotRestart();
+ await localController.networkService.updateLastHttpDataRefreshTime();
+
+ expect(
+ localController.networkService.lastHttpDataRefreshTimePerIsolate
+ .containsKey(postRestartIsolateId),
+ isFalse,
+ reason:
+ 'updateLastHttpDataRefreshTime only updates existing isolate '
+ 'entries; new isolates are registered on the first profile fetch.',
+ );
+ },
+ );
+
+ test(
+ 'pause then resume uses VM timeline for HTTP refresh timestamps',
+ () async {
+ final localVmService = HotRestartNetworkVmService();
+ final localConnection = FakeServiceConnectionManager(
+ service: localVmService,
+ );
+ final isolateId = localVmService.currentIsolateId;
+ final controller = await initNetworkLifecycleController(
+ vmService: localVmService,
+ fakeServiceConnection: localConnection,
+ initialProfile: [
+ createTestHttpRequest(id: 'before-pause', method: 'GET'),
+ ],
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(
+ controller.networkService.lastHttpDataRefreshTimePerIsolate
+ .containsKey(isolateId),
+ isTrue,
+ );
+
+ await controller.togglePolling(false);
+ await controller.togglePolling(true);
+
+ final timelineMicros =
+ (await localVmService.getVMTimelineMicros()).timestamp!;
+ expect(
+ controller
+ .networkService
+ .lastHttpDataRefreshTimePerIsolate[isolateId],
+ timelineMicros,
+ reason:
+ 'Resuming recording must stamp HTTP refresh times with the VM '
+ 'timeline clock, not wall-clock time.',
+ );
+ expect(controller.lastSocketDataRefreshMicros, timelineMicros);
+
+ localVmService.appendHttpRequest(
+ isolateId,
+ createTestHttpRequest(
+ id: 'after-resume',
+ method: 'POST',
+ startTime: timelineMicros + 1_000,
+ ),
+ );
+ await controller.networkService.refreshNetworkData();
+ expect(
+ controller.requests.value.map((r) => r.id),
+ contains('after-resume'),
+ );
+ },
+ );
+ });
+}
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
new file mode 100644
index 0000000..5403dc7
--- /dev/null
+++ b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart
@@ -0,0 +1,191 @@
+// 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/devtools_app.dart';
+import 'package:devtools_test/devtools_test.dart';
+import 'package:vm_service/vm_service.dart';
+
+/// A fake VM service that models hot-restart isolate lifecycle for Network View
+/// tests.
+///
+/// After a hot restart, the VM spawns a new main isolate whose HTTP timeline
+/// logging and socket profiling are disabled until explicitly re-enabled.
+/// [getHttpProfileWrapper] and [getSocketProfileWrapper] return empty data when
+/// profiling is disabled for an isolate, matching real VM behavior.
+class HotRestartNetworkVmService extends FakeVmServiceWrapper {
+ HotRestartNetworkVmService()
+ : super(
+ _testVmFlagManager,
+ null,
+ null,
+ SocketProfile(sockets: []),
+ HttpProfile(
+ requests: [],
+ timestamp: DateTime.fromMicrosecondsSinceEpoch(0),
+ ),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ) {
+ isHttpProfilingAvailableResult = true;
+ _httpLoggingEnabled[_currentIsolateId] = false;
+ _socketProfilingEnabled[_currentIsolateId] = false;
+ }
+
+ static final _testVmFlagManager = VmFlagManager();
+
+ String _currentIsolateId = 'isolates/1';
+
+ /// A monotonic microsecond counter simulating the VM timeline clock.
+ int _timelineMicros = 1_000_000;
+
+ /// A map of isolate ID to the list of mock HTTP requests recorded on that
+ /// isolate.
+ final _httpProfiles = <String, List<HttpProfileRequest>>{};
+
+ /// A map of isolate ID to the list of mock socket statistics recorded on that
+ /// isolate.
+ final _socketProfiles = <String, List<SocketStatistic>>{};
+
+ /// A map tracking whether HTTP timeline logging is enabled (true/false) per
+ /// isolate ID.
+ final _httpLoggingEnabled = <String, bool>{};
+
+ /// A map tracking whether socket profiling is enabled (true/false) per isolate
+ /// ID.
+ final _socketProfilingEnabled = <String, bool>{};
+
+ /// The isolate ID currently returned by [forEachIsolate].
+ String get currentIsolateId => _currentIsolateId;
+
+ /// Sets the HTTP profile data that will be returned for [isolateId] when HTTP
+ /// timeline logging is enabled for that isolate.
+ void setHttpProfile(String isolateId, List<HttpProfileRequest> requests) {
+ _httpProfiles[isolateId] = requests;
+ }
+
+ /// Sets the socket profile data that will be returned for [isolateId] when
+ /// socket profiling is enabled for that isolate.
+ void setSocketProfile(String isolateId, List<SocketStatistic> sockets) {
+ _socketProfiles[isolateId] = sockets;
+ }
+
+ /// Whether HTTP timeline logging is enabled for [isolateId].
+ bool isHttpLoggingEnabled(String isolateId) =>
+ _httpLoggingEnabled[isolateId] ?? false;
+
+ /// Whether socket profiling is enabled for [isolateId].
+ bool isSocketProfilingEnabled(String isolateId) =>
+ _socketProfilingEnabled[isolateId] ?? false;
+
+ /// Simulates a hot restart by replacing the current isolate with a new one
+ /// that has HTTP logging and socket profiling disabled.
+ ///
+ /// Returns the ID of the new isolate.
+ String simulateHotRestart({String? newIsolateId}) {
+ final nextId = newIsolateId ?? '${_currentIsolateId}_restarted';
+ _currentIsolateId = nextId;
+ _httpLoggingEnabled[nextId] = false;
+ _socketProfilingEnabled[nextId] = false;
+ // Advance past pre-restart request timestamps so refresh logic treats them
+ // as stale on the new isolate.
+ _timelineMicros += 5_000_000;
+ return nextId;
+ }
+
+ /// Appends [request] to the HTTP profile for [isolateId].
+ void appendHttpRequest(String isolateId, HttpProfileRequest request) {
+ _httpProfiles[isolateId] = [...?_httpProfiles[isolateId], request];
+ }
+
+ @override
+ Future<Success> clearHttpProfileWrapper(String isolateId) async {
+ _httpProfiles[isolateId] = [];
+ return Success();
+ }
+
+ @override
+ Future<Success> clearSocketProfileWrapper(String isolateId) async {
+ _socketProfiles[isolateId] = [];
+ return Success();
+ }
+
+ @override
+ Future<void> forEachIsolate(Future<void> Function(IsolateRef) callback) =>
+ callback(IsolateRef.parse({'id': _currentIsolateId, 'name': 'main'})!);
+
+ @override
+ Future<HttpTimelineLoggingState> httpEnableTimelineLoggingWrapper(
+ String isolateId, [
+ bool? enabled,
+ ]) async {
+ if (enabled != null) {
+ _httpLoggingEnabled[isolateId] = enabled;
+ return HttpTimelineLoggingState(enabled: enabled);
+ }
+ return HttpTimelineLoggingState(
+ enabled: _httpLoggingEnabled[isolateId] ?? false,
+ );
+ }
+
+ @override
+ Future<SocketProfilingState> socketProfilingEnabledWrapper(
+ String isolateId, [
+ bool? enabled,
+ ]) async {
+ if (enabled != null) {
+ _socketProfilingEnabled[isolateId] = enabled;
+ return SocketProfilingState(enabled: enabled);
+ }
+ return SocketProfilingState(
+ enabled: _socketProfilingEnabled[isolateId] ?? false,
+ );
+ }
+
+ @override
+ Future<HttpProfile> getHttpProfileWrapper(
+ String isolateId, {
+ DateTime? updatedSince,
+ }) async {
+ if (!(_httpLoggingEnabled[isolateId] ?? false)) {
+ return HttpProfile(
+ requests: [],
+ timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros),
+ );
+ }
+
+ var requests = List<HttpProfileRequest>.from(
+ _httpProfiles[isolateId] ?? const [],
+ );
+ if (updatedSince != null) {
+ final sinceMicros = updatedSince.microsecondsSinceEpoch;
+ requests = requests
+ .where(
+ (request) =>
+ request.startTime.microsecondsSinceEpoch >= sinceMicros,
+ )
+ .toList();
+ }
+ return HttpProfile(
+ requests: requests,
+ timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros),
+ );
+ }
+
+ @override
+ Future<SocketProfile> getSocketProfileWrapper(String isolateId) async {
+ if (!(_socketProfilingEnabled[isolateId] ?? false)) {
+ return SocketProfile(sockets: []);
+ }
+ return SocketProfile(sockets: _socketProfiles[isolateId] ?? const []);
+ }
+
+ @override
+ Future<Timestamp> getVMTimelineMicros() async =>
+ Timestamp(timestamp: _timelineMicros);
+}
diff --git a/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart
new file mode 100644
index 0000000..7332fcb
--- /dev/null
+++ b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart
@@ -0,0 +1,51 @@
+// 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/devtools_app.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:vm_service/vm_service.dart';
+
+import 'hot_restart_network_vm_service.dart';
+
+Future<NetworkController> initNetworkLifecycleController({
+ required HotRestartNetworkVmService vmService,
+ required FakeServiceConnectionManager fakeServiceConnection,
+ List<HttpProfileRequest>? initialProfile,
+}) async {
+ if (initialProfile != null) {
+ vmService.setHttpProfile(vmService.currentIsolateId, initialProfile);
+ }
+ final controller = setUpNetworkLifecycleController(fakeServiceConnection);
+ await pumpEventQueue();
+ return controller;
+}
+
+NetworkController setUpNetworkLifecycleController(
+ FakeServiceConnectionManager fakeServiceConnection,
+) {
+ setGlobal(OfflineDataController, OfflineDataController());
+ setGlobal(ScreenControllers, ScreenControllers());
+ setGlobal(ServiceConnectionManager, fakeServiceConnection);
+ setGlobal(PreferencesController, PreferencesController());
+ screenControllers.register<NetworkController>(() => NetworkController());
+ return screenControllers.lookup<NetworkController>();
+}
+
+void notifyMainIsolateChanged(
+ FakeServiceConnectionManager connection,
+ String newIsolateId,
+) {
+ final isolateManager =
+ connection.serviceManager.isolateManager as FakeIsolateManager;
+ final isolateRef = IsolateRef.parse({'id': newIsolateId, 'name': 'main'})!;
+ (isolateManager.mainIsolate as ValueNotifier<IsolateRef?>).value = isolateRef;
+ isolateManager.notifyIsolateCreated(isolateRef);
+}
+
+void disposeNetworkLifecycleControllers() {
+ screenControllers.disposeConnectedControllers();
+}
diff --git a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart
index 0d9506c..391f91c 100644
--- a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart
+++ b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart
@@ -6,6 +6,32 @@
import '../../../test_infra/test_data/network.dart';
+/// Creates a minimal [HttpProfileRequest] for use in Network View tests.
+HttpProfileRequest createTestHttpRequest({
+ required String id,
+ required String method,
+ int startTime = 2_000_000,
+ String uri = 'https://example.com/test',
+}) {
+ final endTime = startTime + 1000;
+ return HttpProfileRequest.parse({
+ 'type': 'HttpProfileRequest',
+ 'id': id,
+ 'isolateId': 'isolates/test',
+ 'method': method,
+ 'uri': uri,
+ 'events': [],
+ 'startTime': startTime,
+ 'endTime': endTime,
+ 'response': {
+ 'startTime': startTime,
+ 'endTime': endTime,
+ 'redirects': [],
+ 'statusCode': 200,
+ },
+ })!;
+}
+
SocketProfile loadSocketProfile() {
return SocketProfile(
sockets: [
diff --git a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
index 9da67d8..71ac7d4 100644
--- a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
+++ b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
@@ -40,6 +40,13 @@
final _isolateCreatedController = StreamController<IsolateRef?>.broadcast();
final _isolateExitedController = StreamController<IsolateRef?>.broadcast();
+ /// Stream of isolates that have been created since the VM service connection
+ /// was opened.
+ ///
+ /// This can be used to detect when a new isolate is spawned, such as after a
+ /// hot restart.
+ Stream<IsolateRef?> get onIsolateCreated => _isolateCreatedController.stream;
+
ValueListenable<IsolateRef?> get selectedIsolate => _selectedIsolate;
final _selectedIsolate = ValueNotifier<IsolateRef?>(null);
diff --git a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
index dfc387e..5e9823f 100644
--- a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
+++ b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
@@ -4,6 +4,8 @@
// ignore_for_file: invalid_use_of_visible_for_testing_member, devtools_test is only used in test code.
+import 'dart:async';
+
import 'package:devtools_app_shared/service.dart';
// ignore: implementation_imports, intentional import from src/
import 'package:devtools_app_shared/src/service/isolate_manager.dart';
@@ -30,6 +32,20 @@
IsolateRef.parse({'id': 'fake_main_isolate_id'}),
);
+ final _isolateCreatedController = StreamController<IsolateRef?>.broadcast();
+
+ @override
+ Stream<IsolateRef?> get onIsolateCreated => _isolateCreatedController.stream;
+
+ /// Notifies listeners that a new isolate was created.
+ ///
+ /// This can be used in tests to simulate a hot restart spawning a new
+ /// isolate.
+ @visibleForTesting
+ void notifyIsolateCreated(IsolateRef isolate) {
+ _isolateCreatedController.add(isolate);
+ }
+
@override
ValueNotifier<List<IsolateRef>> get isolates {
return _isolates ??= ValueNotifier([?_selectedIsolate.value]);