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..453ffd0bb22 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,14 @@ 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; + + final _isReconnecting = ValueNotifier(false); + + final _reconnectErrorText = ValueNotifier(null); + @override void initState() { super.initState(); @@ -59,8 +68,14 @@ 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). + _lastVmServiceUri = + widget.routerDelegate.currentConfiguration?.params.vmServiceUri ?? + serviceConnection.serviceManager.serviceUri; unawaited(widget.routerDelegate.clearUriParameter()); showDisconnectedOverlay(); } @@ -71,6 +86,8 @@ class DisconnectObserverState extends State @override void dispose() { hideDisconnectedOverlay(); + _isReconnecting.dispose(); + _reconnectErrorText.dispose(); super.dispose(); } @@ -120,34 +137,130 @@ 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 (!isEmbedded()) - ConnectToNewAppButton( - routerDelegate: widget.routerDelegate, - onPressed: hideDisconnectedOverlay, - gaScreen: gac.devToolsMain, - ) - else - const Text('Run a new debug session to reconnect.'), - 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(), - ], - ], - ), + ), ), ), ), ); return currentDisconnectedOverlay!; } + + Future _attemptReconnect() async { + _isReconnecting.value = true; + _reconnectErrorText.value = null; + + var reconnectionSuccess = false; + + 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). + 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(); + } + } + } } 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..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 @@ -52,7 +52,7 @@ class _NotConnectedOverlayState extends State { if (showReconnectButton) ElevatedButton( onPressed: () => dtdManager.reconnect(), - child: const Text('Retry'), + child: const Text('Reconnect'), ), ], ), 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 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..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(); @@ -67,8 +76,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 connect to it.'), showingOverlay && isEmbedded() ? findsOneWidget : findsNothing, ); expect( @@ -134,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,