From 19ac7129702a80683c9d013516db08ee7d4d7ee7 Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Tue, 10 Mar 2026 16:44:41 +0530 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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, From 51c7db0546dc31c1d8b3d10c98967f562489f246 Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Sat, 21 Mar 2026 13:35:41 +0530 Subject: [PATCH 05/10] Refactor disconnected overlay reconnect UI and simplify reconnect flow --- .../observer/disconnect_observer.dart | 140 +++++++++--------- 1 file changed, 71 insertions(+), 69 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 453ffd0bb22..88061ed9e35 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -8,6 +8,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import '../../framework/framework_core.dart'; import '../../service/connected_app/connection_info.dart'; @@ -18,6 +19,8 @@ import '../../shared/framework/routing.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/query_parameters.dart'; +final _log = Logger('disconnect_observer'); + class DisconnectObserver extends StatefulWidget { const DisconnectObserver({ super.key, @@ -131,6 +134,40 @@ class DisconnectObserverState extends State widget.routerDelegate.navigate(snapshotScreenId, args); } + Widget _buildReconnectActions(ThemeData theme) { + final reconnectButton = ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ); + + if (!isEmbedded()) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + reconnectButton, + const SizedBox(width: defaultSpacing), + ConnectToNewAppButton( + routerDelegate: widget.routerDelegate, + onPressed: hideDisconnectedOverlay, + gaScreen: gac.devToolsMain, + ), + ], + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + reconnectButton, + const SizedBox(height: defaultSpacing), + Text( + 'Or run a new debug session to connect to it.', + style: theme.textTheme.bodyMedium, + ), + ], + ); + } + OverlayEntry _createDisconnectedOverlay() { final theme = Theme.of(context); currentDisconnectedOverlay = OverlayEntry( @@ -153,37 +190,8 @@ class DisconnectObserverState extends State 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, - ), - ], - ), + _buildReconnectActions(theme), if (reconnectErrorText case final error?) ...[ const SizedBox(height: denseSpacing), Text( @@ -219,48 +227,42 @@ class DisconnectObserverState extends State _isReconnecting.value = true; _reconnectErrorText.value = null; - var reconnectionSuccess = false; - try { await dtdManager.reconnect(); + } catch (error, stackTrace) { + _log.warning('Failed to reconnect DTD.', error, stackTrace); + } - 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). - reconnectionSuccess = await FrameworkCore.initVmService( - serviceUriAsString: uri, - logException: false, - errorReporter: (title, error) { - _reconnectErrorText.value = '$title, $error'; - }, - ); - } else { - reconnectionSuccess = - serviceConnection.serviceManager.connectedState.value.connected; - } - } catch (e) { - _reconnectErrorText.value = e.toString(); - } finally { - _isReconnecting.value = false; - - 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, - }), - ); - _reconnectErrorText.value = null; - hideDisconnectedOverlay(); - } else { - // Failed (stale URI, VM dead, etc.) — restore the overlay with buttons. - showDisconnectedOverlay(); - } + var reconnectionSuccess = + serviceConnection.serviceManager.connectedState.value.connected; + + final uri = _lastVmServiceUri; + if (!reconnectionSuccess && uri != null) { + // 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). + reconnectionSuccess = await FrameworkCore.initVmService( + serviceUriAsString: uri, + logException: false, + errorReporter: (title, error) { + _reconnectErrorText.value = '$title, $error'; + }, + ); + } + + _isReconnecting.value = false; + + if (reconnectionSuccess) { + unawaited( + widget.routerDelegate.updateArgsIfChanged({ + DevToolsQueryParams.vmServiceUriKey: _lastVmServiceUri, + }), + ); + _reconnectErrorText.value = null; + hideDisconnectedOverlay(); + } else { + showDisconnectedOverlay(); } } } From 3741e507275eec42325c481922bd63557033f55f Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Wed, 1 Apr 2026 13:31:45 +0530 Subject: [PATCH 06/10] Address PR review: extract _ReconnectActions widget, use MultiValueListenableBuilder and safeUnawaited --- .../observer/disconnect_observer.dart | 167 ++++++++++-------- 1 file changed, 92 insertions(+), 75 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 88061ed9e35..18ecd378cc4 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -18,6 +18,8 @@ import '../../shared/config_specific/import_export/import_export.dart'; import '../../shared/framework/routing.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/query_parameters.dart'; +import '../../shared/ui/common_widgets.dart'; +import '../../shared/utils/utils.dart'; final _log = Logger('disconnect_observer'); @@ -134,88 +136,54 @@ class DisconnectObserverState extends State widget.routerDelegate.navigate(snapshotScreenId, args); } - Widget _buildReconnectActions(ThemeData theme) { - final reconnectButton = ElevatedButton( - onPressed: _attemptReconnect, - child: const Text('Reconnect'), - ); - - if (!isEmbedded()) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - reconnectButton, - const SizedBox(width: defaultSpacing), - ConnectToNewAppButton( - routerDelegate: widget.routerDelegate, - onPressed: hideDisconnectedOverlay, - gaScreen: gac.devToolsMain, - ), - ], - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - reconnectButton, - const SizedBox(height: defaultSpacing), - Text( - 'Or run a new debug session to connect to it.', - style: theme.textTheme.bodyMedium, - ), - ], - ); - } - OverlayEntry _createDisconnectedOverlay() { final theme = Theme.of(context); currentDisconnectedOverlay = OverlayEntry( builder: (context) => Material( child: Container( color: theme.colorScheme.surface, - 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, + child: MultiValueListenableBuilder( + listenables: [_isReconnecting, _reconnectErrorText], + builder: (context, values, _) { + final isReconnecting = values[0]! as bool; + final reconnectErrorText = values[1] as String?; + return Center( + child: Column( + children: [ + const Spacer(), + Text('Disconnected', style: theme.textTheme.headlineMedium), + const SizedBox(height: defaultSpacing), + if (isReconnecting) + const CircularProgressIndicator() + else + _ReconnectActions( + theme: theme, + onReconnect: _attemptReconnect, + routerDelegate: widget.routerDelegate, + onConnectToNewApp: hideDisconnectedOverlay, + ), + if (reconnectErrorText case final error?) ...[ + const SizedBox(height: denseSpacing), + Text( + error, + style: theme.regularTextStyle.copyWith( + color: theme.colorScheme.error, ), - const SizedBox(height: defaultSpacing), - if (isReconnecting) - const CircularProgressIndicator() - else - _buildReconnectActions(theme), - 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(), - ], - ], - ), - ), + textAlign: TextAlign.center, + ), + ], + const Spacer(), + if (offlineDataController.offlineDataJson.isNotEmpty) ...[ + ElevatedButton( + onPressed: _reviewHistory, + child: const Text('Review recent data (offline)'), + ), + const Spacer(), + ], + ], ), + ); + }, ), ), ), @@ -254,7 +222,7 @@ class DisconnectObserverState extends State _isReconnecting.value = false; if (reconnectionSuccess) { - unawaited( + safeUnawaited( widget.routerDelegate.updateArgsIfChanged({ DevToolsQueryParams.vmServiceUriKey: _lastVmServiceUri, }), @@ -266,3 +234,52 @@ class DisconnectObserverState extends State } } } + +class _ReconnectActions extends StatelessWidget { + const _ReconnectActions({ + required this.theme, + required this.onReconnect, + required this.routerDelegate, + required this.onConnectToNewApp, + }); + + final ThemeData theme; + final VoidCallback onReconnect; + final DevToolsRouterDelegate routerDelegate; + final VoidCallback onConnectToNewApp; + + @override + Widget build(BuildContext context) { + final reconnectButton = ElevatedButton( + onPressed: onReconnect, + child: const Text('Reconnect'), + ); + + if (!isEmbedded()) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + reconnectButton, + const SizedBox(width: defaultSpacing), + ConnectToNewAppButton( + routerDelegate: routerDelegate, + onPressed: onConnectToNewApp, + gaScreen: gac.devToolsMain, + ), + ], + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + reconnectButton, + const SizedBox(height: defaultSpacing), + Text( + 'Or run a new debug session to connect to it.', + style: theme.textTheme.bodyMedium, + ), + ], + ); + } +} From 6603abcdb1881e0a878598c457d75cc35f38586a Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Fri, 3 Apr 2026 16:16:47 +0530 Subject: [PATCH 07/10] Use safe listenable value access in disconnect observer --- .../lib/src/framework/observer/disconnect_observer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 18ecd378cc4..2b16b2d6e7e 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -145,7 +145,7 @@ class DisconnectObserverState extends State child: MultiValueListenableBuilder( listenables: [_isReconnecting, _reconnectErrorText], builder: (context, values, _) { - final isReconnecting = values[0]! as bool; + final isReconnecting = values.first as bool; final reconnectErrorText = values[1] as String?; return Center( child: Column( From 9b02e564a3e6281c8dd12cdd00f0af90a17ddacb Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Mon, 6 Apr 2026 11:41:06 -0700 Subject: [PATCH 08/10] sort directives --- .../test/framework/observer/disconnect_observer_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 07caf0668ec..c8706fc0441 100644 --- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart +++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart @@ -4,8 +4,8 @@ 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/src/shared/primitives/query_parameters.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; From 76b4774d55a3d0971b858f283b4d620af69246f6 Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Tue, 7 Apr 2026 10:16:13 +0530 Subject: [PATCH 09/10] Fix overlay stacking --- .../lib/src/framework/observer/disconnect_observer.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 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 2b16b2d6e7e..bd8f19297bd 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -139,8 +139,8 @@ class DisconnectObserverState extends State OverlayEntry _createDisconnectedOverlay() { final theme = Theme.of(context); currentDisconnectedOverlay = OverlayEntry( - builder: (context) => Material( - child: Container( + builder: (context) => Positioned.fill( + child: Material( color: theme.colorScheme.surface, child: MultiValueListenableBuilder( listenables: [_isReconnecting, _reconnectErrorText], From 0bb0e496d3b07cb8396a7bba783916e151c29c3d Mon Sep 17 00:00:00 2001 From: rishika0212 Date: Wed, 8 Apr 2026 09:47:07 +0530 Subject: [PATCH 10/10] Update goldens for run 24064811951 --- .../test_infra/goldens/memory_diff_empty1.png | Bin 21479 -> 21479 bytes .../test_infra/goldens/memory_diff_empty2.png | Bin 21479 -> 21479 bytes .../disconnect_observer_disconnected.png | Bin 26823 -> 30844 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png index 869ff8e2767845a4486f68563a4b0d70dda61100..a4790c896d8de4b051371d28939ebfd697d7c8df 100644 GIT binary patch delta 39 tcmaF9obmZ`#tAA?EX7WqAsj$Z!;#X#z`(#*9OUlAuU|=bB@(kesf*OvLj*WIJg8>c042}Q* diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png index 869ff8e2767845a4486f68563a4b0d70dda61100..a4790c896d8de4b051371d28939ebfd697d7c8df 100644 GIT binary patch delta 39 tcmaF9obmZ`#tAA?EX7WqAsj$Z!;#X#z`(#*9OUlAuU|=bB@(kesf*OvLj*WIJg8>c042}Q* diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png index 7d39dd2dd4236b0d3a048903dd6cf6e1a3db97d4..cb6f84f38d494fdbee6247c0b565a832e68e3e89 100644 GIT binary patch literal 30844 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 SB}l_6RlcHeIp;64`~M#bfvPqD literal 26823 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 UaYuznheoNQq)tdUZF2iR02Tb_AOHXW