diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart index 28bb4d4e18d..7d48749b1a9 100644 --- a/packages/devtools_app/lib/src/framework/framework_core.dart +++ b/packages/devtools_app/lib/src/framework/framework_core.dart @@ -136,6 +136,7 @@ extension FrameworkCore on Never { final uri = normalizeVmServiceUri(serviceUriAsString); if (uri != null) { vmServiceInitializationInProgress = true; + _lastServiceUriAsString = serviceUriAsString; final finishedCompleter = Completer(); try { @@ -187,6 +188,32 @@ extension FrameworkCore on Never { static void _defaultErrorReporter(String title, Object error) { notificationService.pushError('$title, $error', isReportable: false); } + + /// The URI of the last VM service connection, used for reconnection. + static String? _lastServiceUriAsString; + + /// Attempts to reconnect to the last known VM service. + /// + /// Returns true if reconnection was successful. + static Future reconnectVmService() async { + if (vmServiceInitializationInProgress) { + _log.warning( + 'Reconnection attempt ignored: initialization already in progress.', + ); + return false; + } + final lastUri = _lastServiceUriAsString; + if (lastUri == null) return false; + + _log.info('Attempting to reconnect to VM service at: $lastUri'); + final success = await initVmService(serviceUriAsString: lastUri); + if (success) { + _log.info('VM service reconnection successful'); + } else { + _log.warning('VM service reconnection failed'); + } + return success; + } } Future _initDTDConnection() async { 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..8a46105c6d2 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -16,6 +16,7 @@ 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 '../framework_core.dart'; class DisconnectObserver extends StatefulWidget { const DisconnectObserver({ @@ -34,9 +35,10 @@ class DisconnectObserver extends StatefulWidget { class DisconnectObserverState extends State with AutoDisposeMixin { OverlayEntry? currentDisconnectedOverlay; - late ConnectedState currentConnectionState; + bool _isReconnecting = false; + @override void initState() { super.initState(); @@ -52,6 +54,7 @@ class DisconnectObserverState extends State if (currentConnectionState.connected && currentDisconnectedOverlay != null) { setState(() { + _isReconnecting = false; hideDisconnectedOverlay(); }); } else if (!currentConnectionState.connected) { @@ -95,6 +98,38 @@ class DisconnectObserverState extends State currentDisconnectedOverlay = null; } + Future _attemptReconnect() async { + if (_isReconnecting) return; + _isReconnecting = true; + currentDisconnectedOverlay?.markNeedsBuild(); + + bool success = false; + try { + success = await FrameworkCore.reconnectVmService().timeout( + const Duration(seconds: 5), + ); + } catch (_) { + success = false; + } + + if (mounted) { + _isReconnecting = false; + if (success) { + final uri = serviceConnection.serviceManager.serviceUri; + if (uri != null) { + unawaited( + widget.routerDelegate.updateArgsIfChanged({ + DevToolsQueryParams.vmServiceUriKey: uri, + }), + ); + } + hideDisconnectedOverlay(); + } else { + currentDisconnectedOverlay?.markNeedsBuild(); + } + } + } + Future _reviewHistory() async { assert(offlineDataController.offlineDataJson.isNotEmpty); @@ -124,16 +159,29 @@ class DisconnectObserverState extends State 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.'), + if (_isReconnecting) ...[ + const CircularProgressIndicator(), + const SizedBox(height: defaultSpacing), + Text( + 'Reconnecting...', + style: theme.textTheme.headlineMedium, + ), + ] else ...[ + Text('Disconnected', style: theme.textTheme.headlineMedium), + const SizedBox(height: defaultSpacing), + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + if (!isEmbedded()) ...[ + const SizedBox(height: denseSpacing), + ConnectToNewAppButton( + routerDelegate: widget.routerDelegate, + onPressed: hideDisconnectedOverlay, + gaScreen: gac.devToolsMain, + ), + ], + ], const Spacer(), if (offlineDataController.offlineDataJson.isNotEmpty) ...[ ElevatedButton( diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 14cf2ab1991..16954fd6b58 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -15,6 +15,8 @@ To learn more about DevTools, check out the ## General updates +* Fixed an issue where DevTools could get stuck in a disconnected state (e.g., after a Mac goes to sleep) by adding a manual "Reconnect" button to the disconnected screen. - + [#9838](https://github.com/flutter/devtools/issues/9838) * Resolve several memory leaks. - [#9857](https://github.com/flutter/devtools/pull/9857) * Fixed a bug where highlighted search matches in tables were unreadable in dark mode because the highlight color had become fully opaque. - 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..1c186ad4964 100644 --- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart +++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart @@ -67,9 +67,10 @@ void main() { find.byType(ConnectToNewAppButton), showingOverlay && !isEmbedded() ? findsOneWidget : findsNothing, ); + // The Reconnect button should be shown in all modes when disconnected. expect( - find.text('Run a new debug session to reconnect.'), - showingOverlay && isEmbedded() ? findsOneWidget : findsNothing, + find.text('Reconnect'), + showingOverlay ? findsOneWidget : findsNothing, ); expect( find.text('Review recent data (offline)'), @@ -170,5 +171,58 @@ void main() { // TODO(kenz): test navigation that occurs by clicking on buttons. This will // require either modifying the test wrappers to take a set of routes or // writing an integration test for this user journey. + + group('reconnect button', () { + testWidgets('shows Reconnect button in embedded mode', ( + WidgetTester tester, + ) async { + setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedOne)); + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Verify the Reconnect button is present in embedded mode. + expect(find.text('Reconnect'), findsOneWidget); + // ConnectToNewAppButton should NOT be shown in embedded mode. + expect(find.byType(ConnectToNewAppButton), findsNothing); + }); + + testWidgets('shows Reconnect button in non-embedded mode', ( + WidgetTester tester, + ) async { + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Both Reconnect and ConnectToNewAppButton should be shown. + expect(find.text('Reconnect'), findsOneWidget); + expect(find.byType(ConnectToNewAppButton), findsOneWidget); + }); + + testWidgets('hides overlay when reconnection succeeds', ( + WidgetTester tester, + ) async { + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Simulate a successful reconnection by setting connected state. + fakeServiceConnectionManager.serviceManager.setConnectedState(true); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: true, showingOverlay: false); + }); + }); }); } diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png index 0bdf4f1e717..3b55ee8bf0f 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png and b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png differ 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 7d39dd2dd42..1c97e3efcfa 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png and b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png differ