Fix disconnect reconnect (#9878)
* reconnect to VM service after disconnect without app restart
* Add release note
* Apply code review suggestions
* Apply code review suggestions
* Format disconnect_observer.dart
* Update disconnect observer golden images for Reconnect button
* Remove redundant argument in initVmService call
* fix issue
diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart
index 28bb4d4..7d48749 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 @@
final uri = normalizeVmServiceUri(serviceUriAsString);
if (uri != null) {
vmServiceInitializationInProgress = true;
+ _lastServiceUriAsString = serviceUriAsString;
final finishedCompleter = Completer<void>();
try {
@@ -187,6 +188,32 @@
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<bool> 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<void> _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 bf415e4..8a46105 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/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 DisconnectObserverState extends State<DisconnectObserver>
with AutoDisposeMixin {
OverlayEntry? currentDisconnectedOverlay;
-
late ConnectedState currentConnectionState;
+ bool _isReconnecting = false;
+
@override
void initState() {
super.initState();
@@ -52,6 +54,7 @@
if (currentConnectionState.connected &&
currentDisconnectedOverlay != null) {
setState(() {
+ _isReconnecting = false;
hideDisconnectedOverlay();
});
} else if (!currentConnectionState.connected) {
@@ -95,6 +98,38 @@
currentDisconnectedOverlay = null;
}
+ Future<void> _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<void> _reviewHistory() async {
assert(offlineDataController.offlineDataJson.isNotEmpty);
@@ -124,16 +159,29 @@
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 14cf2ab..16954fd 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 @@
## 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 1678744..1c186ad 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 @@
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 @@
// 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 0bdf4f1..3b55ee8 100644
--- 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
Binary files 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 7d39dd2..1c97e3e 100644
--- 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
Binary files differ