From 19ac7129702a80683c9d013516db08ee7d4d7ee7 Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Tue, 10 Mar 2026 16:44:41 +0530 Subject: [PATCH 1/4] Add reconnect option for Inspector when VM service disconnects --- .../observer/disconnect_observer.dart | 26 ++++++++++++++++--- .../ide_shared/not_connected_overlay.dart | 6 ++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart index bf415e4b3c8..31f88f010a9 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -127,10 +127,20 @@ class DisconnectObserverState extends State Text('Disconnected', style: theme.textTheme.headlineMedium), const SizedBox(height: defaultSpacing), if (!isEmbedded()) - ConnectToNewAppButton( - routerDelegate: widget.routerDelegate, - onPressed: hideDisconnectedOverlay, - gaScreen: gac.devToolsMain, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + const SizedBox(width: defaultSpacing), + ConnectToNewAppButton( + routerDelegate: widget.routerDelegate, + onPressed: hideDisconnectedOverlay, + gaScreen: gac.devToolsMain, + ), + ], ) else const Text('Run a new debug session to reconnect.'), @@ -150,4 +160,12 @@ class DisconnectObserverState extends State ); return currentDisconnectedOverlay!; } + + Future _attemptReconnect() async { + // Hide the overlay immediately so user knows something happened + hideDisconnectedOverlay(); + + // Try to reconnect DTD which may help restore service connection + await dtdManager.reconnect(); + } } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart index 0a67722ffdd..6e6b212093c 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart @@ -26,8 +26,8 @@ class _NotConnectedOverlayState extends State { final connectionState = widget.connectionState; final theme = Theme.of(context); - final showSpinner = connectionState is! ConnectionFailedDTDState; - final showReconnectButton = connectionState is ConnectionFailedDTDState; + final showSpinner = connectionState is ConnectingDTDState || connectionState is WaitingToRetryDTDState; + final showReconnectButton = connectionState is NotConnectedDTDState || connectionState is ConnectionFailedDTDState; final stateLabel = switch (connectionState) { NotConnectedDTDState() => 'Waiting to connect...', ConnectingDTDState() => 'Connecting...', @@ -52,7 +52,7 @@ class _NotConnectedOverlayState extends State { if (showReconnectButton) ElevatedButton( onPressed: () => dtdManager.reconnect(), - child: const Text('Retry'), + child: const Text('Reconnect'), ), ], ), From 4e2c4c3857f95daace0e03db110a6755d1b9671e Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Tue, 10 Mar 2026 22:29:00 +0530 Subject: [PATCH 2/4] Add reconnect button to DisconnectObserver for VM service reconnection --- .../observer/disconnect_observer.dart | 81 ++++++++++++++++--- .../observer/disconnect_observer_test.dart | 8 +- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart index 31f88f010a9..e6cd2201ce0 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -9,6 +9,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; +import '../../framework/framework_core.dart'; import '../../service/connected_app/connection_info.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -37,6 +38,12 @@ class DisconnectObserverState extends State late ConnectedState currentConnectionState; + /// Stores the last known VM service URI so we can attempt to reconnect + /// after the connection is lost (e.g. when the machine sleeps). + String? _lastVmServiceUri; + + bool _isReconnecting = false; + @override void initState() { super.initState(); @@ -59,8 +66,15 @@ class DisconnectObserverState extends State !currentConnectionState.connected && !currentConnectionState.userInitiatedConnectionState) { // We became disconnected by means other than a manual disconnect - // action, so show the overlay and ensure the 'uri' query paraemter + // action, so show the overlay and ensure the 'uri' query parameter // has been cleared. + // + // Store the VM service URI before clearing so we can attempt + // reconnection later (e.g. after machine sleep/wake). + // Fall back to the live service URI if router params are already gone. + _lastVmServiceUri = + widget.routerDelegate.currentConfiguration?.params.vmServiceUri ?? + serviceConnection.serviceManager.serviceUri; unawaited(widget.routerDelegate.clearUriParameter()); showDisconnectedOverlay(); } @@ -126,7 +140,9 @@ class DisconnectObserverState extends State const Spacer(), Text('Disconnected', style: theme.textTheme.headlineMedium), const SizedBox(height: defaultSpacing), - if (!isEmbedded()) + if (_isReconnecting) + const CircularProgressIndicator() + else if (!isEmbedded()) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -143,7 +159,20 @@ class DisconnectObserverState extends State ], ) else - const Text('Run a new debug session to reconnect.'), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + const SizedBox(height: defaultSpacing), + Text( + 'Or run a new debug session to reconnect.', + style: theme.textTheme.bodyMedium, + ), + ], + ), const Spacer(), if (offlineDataController.offlineDataJson.isNotEmpty) ...[ ElevatedButton( @@ -162,10 +191,44 @@ class DisconnectObserverState extends State } Future _attemptReconnect() async { - // Hide the overlay immediately so user knows something happened - hideDisconnectedOverlay(); - - // Try to reconnect DTD which may help restore service connection - await dtdManager.reconnect(); + setState(() => _isReconnecting = true); + currentDisconnectedOverlay?.markNeedsBuild(); + + try { + await dtdManager.reconnect(); + + final uri = _lastVmServiceUri; + if (uri != null && + !serviceConnection.serviceManager.connectedState.value.connected) { + // Call initVmService directly — do NOT use routerDelegate.navigate() + // because that goes through _replaceStack which calls manuallyDisconnect + // when clearing the URI, causing the disconnect observer to suppress + // the overlay (userInitiatedConnectionState = true). + await FrameworkCore.initVmService( + serviceUriAsString: uri, + logException: false, + // Suppress the error notification — we handle failure ourselves below. + errorReporter: (_, __) {}, + ); + } + } catch (e) { + // Swallow errors — we check connected state in finally instead. + } finally { + _isReconnecting = false; + + if (serviceConnection.serviceManager.connectedState.value.connected) { + // Success — also update the router so the URI is reflected in the URL. + unawaited( + widget.routerDelegate.updateArgsIfChanged({ + DevToolsQueryParams.vmServiceUriKey: _lastVmServiceUri, + }), + ); + setState(() => hideDisconnectedOverlay()); + } else { + // Failed (stale URI, VM dead, etc.) — restore the overlay with buttons. + currentDisconnectedOverlay?.markNeedsBuild(); + showDisconnectedOverlay(); + } + } } -} +} \ No newline at end of file diff --git a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart index 16787447f1e..9a3d610c45e 100644 --- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart +++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart @@ -67,8 +67,14 @@ void main() { find.byType(ConnectToNewAppButton), showingOverlay && !isEmbedded() ? findsOneWidget : findsNothing, ); + // The Reconnect button should be present in both embedded and + // non-embedded modes when the overlay is showing. expect( - find.text('Run a new debug session to reconnect.'), + find.text('Reconnect'), + showingOverlay ? findsOneWidget : findsNothing, + ); + expect( + find.text('Or run a new debug session to reconnect.'), showingOverlay && isEmbedded() ? findsOneWidget : findsNothing, ); expect( From ea66c0f1011a31a23d99e828e8e02a4d0520a09b Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Tue, 10 Mar 2026 22:44:29 +0530 Subject: [PATCH 3/4] edited in release notes --- packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index b98ca752ea0..8bcd66001b3 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -19,7 +19,9 @@ TODO: Remove this section if there are not any updates. ## Inspector updates -TODO: Remove this section if there are not any updates. +- Added a "Reconnect" button to the disconnected overlay in embedded/IDE mode, + and fixed reconnection to restore the VM service connection after machine + sleep/wake (#9683). ## Performance updates From 8260a7c4554f63cfe222f55cc13089917378d4c4 Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Fri, 20 Mar 2026 00:06:26 +0530 Subject: [PATCH 4/4] Addressed PR review comments --- .../observer/disconnect_observer.dart | 154 +++++++++++------- .../ide_shared/not_connected_overlay.dart | 4 +- .../observer/disconnect_observer_test.dart | 45 ++++- 3 files changed, 139 insertions(+), 64 deletions(-) diff --git a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart index e6cd2201ce0..453ffd0bb22 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -42,7 +42,9 @@ class DisconnectObserverState extends State /// after the connection is lost (e.g. when the machine sleeps). String? _lastVmServiceUri; - bool _isReconnecting = false; + final _isReconnecting = ValueNotifier(false); + + final _reconnectErrorText = ValueNotifier(null); @override void initState() { @@ -71,7 +73,6 @@ class DisconnectObserverState extends State // // Store the VM service URI before clearing so we can attempt // reconnection later (e.g. after machine sleep/wake). - // Fall back to the live service URI if router params are already gone. _lastVmServiceUri = widget.routerDelegate.currentConfiguration?.params.vmServiceUri ?? serviceConnection.serviceManager.serviceUri; @@ -85,6 +86,8 @@ class DisconnectObserverState extends State @override void dispose() { hideDisconnectedOverlay(); + _isReconnecting.dispose(); + _reconnectErrorText.dispose(); super.dispose(); } @@ -134,55 +137,77 @@ class DisconnectObserverState extends State builder: (context) => Material( child: Container( color: theme.colorScheme.surface, - child: Center( - child: Column( - children: [ - const Spacer(), - Text('Disconnected', style: theme.textTheme.headlineMedium), - const SizedBox(height: defaultSpacing), - if (_isReconnecting) - const CircularProgressIndicator() - else if (!isEmbedded()) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: _attemptReconnect, - child: const Text('Reconnect'), - ), - const SizedBox(width: defaultSpacing), - ConnectToNewAppButton( - routerDelegate: widget.routerDelegate, - onPressed: hideDisconnectedOverlay, - gaScreen: gac.devToolsMain, - ), - ], - ) - else - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ElevatedButton( - onPressed: _attemptReconnect, - child: const Text('Reconnect'), - ), - const SizedBox(height: defaultSpacing), - Text( - 'Or run a new debug session to reconnect.', - style: theme.textTheme.bodyMedium, - ), - ], - ), - const Spacer(), - if (offlineDataController.offlineDataJson.isNotEmpty) ...[ - ElevatedButton( - onPressed: _reviewHistory, - child: const Text('Review recent data (offline)'), + child: ValueListenableBuilder( + valueListenable: _isReconnecting, + builder: (context, isReconnecting, _) => + ValueListenableBuilder( + valueListenable: _reconnectErrorText, + builder: (context, reconnectErrorText, _) => Center( + child: Column( + children: [ + const Spacer(), + Text( + 'Disconnected', + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: defaultSpacing), + if (isReconnecting) + const CircularProgressIndicator() + else if (!isEmbedded()) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + const SizedBox(width: defaultSpacing), + ConnectToNewAppButton( + routerDelegate: widget.routerDelegate, + onPressed: hideDisconnectedOverlay, + gaScreen: gac.devToolsMain, + ), + ], + ) + else + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + const SizedBox(height: defaultSpacing), + Text( + 'Or run a new debug session to connect to it.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + if (reconnectErrorText case final error?) ...[ + const SizedBox(height: denseSpacing), + Text( + error, + style: theme.regularTextStyle.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ], + const Spacer(), + if (offlineDataController + .offlineDataJson + .isNotEmpty) ...[ + ElevatedButton( + onPressed: _reviewHistory, + child: const Text('Review recent data (offline)'), + ), + const Spacer(), + ], + ], + ), ), - const Spacer(), - ], - ], - ), + ), ), ), ), @@ -191,8 +216,10 @@ class DisconnectObserverState extends State } Future _attemptReconnect() async { - setState(() => _isReconnecting = true); - currentDisconnectedOverlay?.markNeedsBuild(); + _isReconnecting.value = true; + _reconnectErrorText.value = null; + + var reconnectionSuccess = false; try { await dtdManager.reconnect(); @@ -204,31 +231,36 @@ class DisconnectObserverState extends State // because that goes through _replaceStack which calls manuallyDisconnect // when clearing the URI, causing the disconnect observer to suppress // the overlay (userInitiatedConnectionState = true). - await FrameworkCore.initVmService( + reconnectionSuccess = await FrameworkCore.initVmService( serviceUriAsString: uri, logException: false, - // Suppress the error notification — we handle failure ourselves below. - errorReporter: (_, __) {}, + errorReporter: (title, error) { + _reconnectErrorText.value = '$title, $error'; + }, ); + } else { + reconnectionSuccess = + serviceConnection.serviceManager.connectedState.value.connected; } } catch (e) { - // Swallow errors — we check connected state in finally instead. + _reconnectErrorText.value = e.toString(); } finally { - _isReconnecting = false; + _isReconnecting.value = false; - if (serviceConnection.serviceManager.connectedState.value.connected) { + if (reconnectionSuccess || + serviceConnection.serviceManager.connectedState.value.connected) { // Success — also update the router so the URI is reflected in the URL. unawaited( widget.routerDelegate.updateArgsIfChanged({ DevToolsQueryParams.vmServiceUriKey: _lastVmServiceUri, }), ); - setState(() => hideDisconnectedOverlay()); + _reconnectErrorText.value = null; + hideDisconnectedOverlay(); } else { // Failed (stale URI, VM dead, etc.) — restore the overlay with buttons. - currentDisconnectedOverlay?.markNeedsBuild(); showDisconnectedOverlay(); } } } -} \ No newline at end of file +} diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart index 6e6b212093c..773fcc26d56 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart @@ -26,8 +26,8 @@ class _NotConnectedOverlayState extends State { final connectionState = widget.connectionState; final theme = Theme.of(context); - final showSpinner = connectionState is ConnectingDTDState || connectionState is WaitingToRetryDTDState; - final showReconnectButton = connectionState is NotConnectedDTDState || connectionState is ConnectionFailedDTDState; + final showSpinner = connectionState is! ConnectionFailedDTDState; + final showReconnectButton = connectionState is ConnectionFailedDTDState; final stateLabel = switch (connectionState) { NotConnectedDTDState() => 'Waiting to connect...', ConnectingDTDState() => 'Connecting...', diff --git a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart index 9a3d610c45e..07caf0668ec 100644 --- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart +++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart @@ -4,7 +4,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/framework/observer/disconnect_observer.dart'; +import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; import 'package:devtools_app/src/shared/framework/framework_controller.dart'; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; @@ -12,16 +14,21 @@ import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import '../../test_infra/matchers/matchers.dart'; void main() { group('DisconnectObserver', () { late FakeServiceConnectionManager fakeServiceConnectionManager; + late MockDTDManager mockDtdManager; setUp(() { fakeServiceConnectionManager = FakeServiceConnectionManager(); + mockDtdManager = MockDTDManager(); + when(mockDtdManager.reconnect()).thenAnswer((_) async {}); setGlobal(ServiceConnectionManager, fakeServiceConnectionManager); + setGlobal(DTDManager, mockDtdManager); setGlobal(FrameworkController, FrameworkController()); setGlobal(OfflineDataController, OfflineDataController()); setGlobal(IdeTheme, IdeTheme()); @@ -30,6 +37,7 @@ void main() { Future pumpDisconnectObserver( WidgetTester tester, { Widget child = const Placeholder(), + DevToolsQueryParams? queryParams, }) async { await tester.pumpWidget( wrap( @@ -41,6 +49,7 @@ void main() { ); }, ), + queryParams: queryParams, ), ); await tester.pumpAndSettle(); @@ -74,7 +83,7 @@ void main() { showingOverlay ? findsOneWidget : findsNothing, ); expect( - find.text('Or run a new debug session to reconnect.'), + find.text('Or run a new debug session to connect to it.'), showingOverlay && isEmbedded() ? findsOneWidget : findsNothing, ); expect( @@ -140,6 +149,40 @@ void main() { await showOverlayAndVerifyContents(tester); }); + testWidgets( + 'reconnect button restores previous VM service URI on success', + (WidgetTester tester) async { + const previousVmServiceUri = 'http://127.0.0.1:8181/'; + when(mockDtdManager.reconnect()).thenAnswer((_) async { + fakeServiceConnectionManager.serviceManager.setConnectedState(true); + }); + + await pumpDisconnectObserver( + tester, + queryParams: DevToolsQueryParams({ + DevToolsQueryParams.vmServiceUriKey: previousVmServiceUri, + }), + ); + verifyObserverState(tester, connected: true, showingOverlay: false); + + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + await tester.tap(find.text('Reconnect')); + await tester.pumpAndSettle(); + + verify(mockDtdManager.reconnect()).called(1); + verifyObserverState(tester, connected: true, showingOverlay: false); + final context = tester.element(find.byType(DisconnectObserver)); + final routerDelegate = DevToolsRouterDelegate.of(context); + expect( + routerDelegate.currentConfiguration!.params.vmServiceUri, + previousVmServiceUri, + ); + }, + ); + // Regression test for https://github.com/flutter/devtools/issues/8050. testWidgets('hides widgets at lower z-index', ( WidgetTester tester,