From d0e298c62862b549a616e16e296b1277521a693e Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:05:10 -0800 Subject: [PATCH 1/7] DTDManager handles the subscription to the service stream --- .../lib/src/shared/editor/editor_client.dart | 18 ++--- .../property_editor_panel.dart | 8 +- .../src/standalone_ui/standalone_screen.dart | 8 +- .../standalone_ui/vs_code/flutter_panel.dart | 8 +- .../shared/editor/editor_client_test.dart | 6 +- .../lib/src/service/dtd_manager.dart | 74 +++++++++++++++++-- 6 files changed, 91 insertions(+), 31 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index 2c2136abda0..4194fcfff3b 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:dtd/dtd.dart'; @@ -20,24 +21,20 @@ import 'api_classes.dart'; /// ensure they are not breaking changes to already-shipped editors. class EditorClient extends DisposableController with AutoDisposeControllerMixin { - EditorClient(this._dtd) { + EditorClient(this._dtdManager) { unawaited(initialized); // Trigger async initialization. } - final DartToolingDaemon _dtd; + final DTDManager _dtdManager; late final initialized = _initialize(); + DartToolingDaemon get _dtd => _dtdManager.connection.value!; + String get gaId => EditorSidebar.id; Future _initialize() async { autoDisposeStreamSubscription( - _dtd.onEvent(CoreDtdServiceConstants.servicesStreamId).listen((data) { - final kind = data.kind; - if (kind != CoreDtdServiceConstants.serviceRegisteredKind && - kind != CoreDtdServiceConstants.serviceUnregisteredKind) { - return; - } - + _dtdManager.serviceRegistrationBroadcastStream.listen((data) { final service = data.data[DtdParameters.service] as String?; if (service == null || (service != editorServiceName && service != lspServiceName)) { @@ -45,7 +42,7 @@ class EditorClient extends DisposableController } final isRegistered = - kind == CoreDtdServiceConstants.serviceRegisteredKind; + data.kind == CoreDtdServiceConstants.serviceRegisteredKind; final method = data.data[DtdParameters.method] as String; final capabilities = data.data[DtdParameters.capabilities] as Map?; @@ -127,7 +124,6 @@ class EditorClient extends DisposableController }), ); await [ - _dtd.streamListen(CoreDtdServiceConstants.servicesStreamId), _dtd.streamListen(editorStreamName).catchError((_) { // Because we currently call streamListen in two places (here and // ThemeManager) this can fail. It doesn't matter if this happens, diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart index bf6e8893bb3..65586529671 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart @@ -4,9 +4,9 @@ import 'dart:async'; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:dtd/dtd.dart'; import 'package:flutter/material.dart'; import '../../../framework/scaffold/report_feedback_button.dart'; @@ -20,9 +20,9 @@ import 'property_editor_view.dart'; /// The side panel for the Property Editor. class PropertyEditorPanel extends StatefulWidget { - const PropertyEditorPanel(this.dtd, {super.key}); + const PropertyEditorPanel(this.dtdManager, {super.key}); - final DartToolingDaemon dtd; + final DTDManager dtdManager; @override State createState() => _PropertyEditorPanelState(); @@ -38,7 +38,7 @@ class _PropertyEditorPanelState extends State { void initState() { super.initState(); - final editor = EditorClient(widget.dtd); + final editor = EditorClient(widget.dtdManager); ga.screen(gac.PropertyEditorSidebar.id); unawaited( _editor = editor.initialized.then((_) { diff --git a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart index 0072eed0310..c57f778c93b 100644 --- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart +++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart @@ -37,11 +37,11 @@ enum StandaloneScreenType { ), StandaloneScreenType.editorSidebar => _DtdConnectedScreen( dtdManager: dtdManager, - builder: (dtd) => EditorSidebarPanel(dtd), + builder: (dtdManager) => EditorSidebarPanel(dtdManager), ), StandaloneScreenType.propertyEditor => _DtdConnectedScreen( dtdManager: dtdManager, - builder: (dtd) => PropertyEditorPanel(dtd), + builder: (dtdManager) => PropertyEditorPanel(dtdManager), ), }; } @@ -58,7 +58,7 @@ class _DtdConnectedScreen extends StatelessWidget { const _DtdConnectedScreen({required this.dtdManager, required this.builder}); final DTDManager dtdManager; - final Widget Function(DartToolingDaemon) builder; + final Widget Function(DTDManager) builder; @override Widget build(BuildContext context) { @@ -80,7 +80,7 @@ class _DtdConnectedScreen extends StatelessWidget { // reconnect occurs. KeyedSubtree( key: ValueKey(connection), - child: builder(connection), + child: builder(dtdManager), ), if (connectionState is! ConnectedDTDState) NotConnectedOverlay(connectionState), diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart index abfba5e72b3..a540ca799ee 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart @@ -4,9 +4,9 @@ import 'dart:async'; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:dtd/dtd.dart'; import 'package:flutter/material.dart'; import '../../shared/analytics/analytics.dart' as ga; @@ -22,9 +22,9 @@ import 'devtools/devtools_view.dart'; /// Provides some basic functionality to improve discoverability of features /// such as creation of new projects, device selection and DevTools features. class EditorSidebarPanel extends StatefulWidget { - const EditorSidebarPanel(this.dtd, {super.key}); + const EditorSidebarPanel(this.dtdManager, {super.key}); - final DartToolingDaemon dtd; + final DTDManager dtdManager; @override State createState() => _EditorSidebarPanelState(); @@ -39,7 +39,7 @@ class _EditorSidebarPanelState extends State { void initState() { super.initState(); - final editor = EditorClient(widget.dtd); + final editor = EditorClient(widget.dtdManager); ga.screen(editor.gaId); unawaited(_editor = editor.initialized.then((_) => editor)); } diff --git a/packages/devtools_app/test/shared/editor/editor_client_test.dart b/packages/devtools_app/test/shared/editor/editor_client_test.dart index 69526c3153b..364d40f7583 100644 --- a/packages/devtools_app/test/shared/editor/editor_client_test.dart +++ b/packages/devtools_app/test/shared/editor/editor_client_test.dart @@ -8,10 +8,12 @@ import 'package:devtools_app/src/shared/editor/api_classes.dart'; import 'package:devtools_app/src/shared/editor/editor_client.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:dtd/dtd.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; void main() { + late MockDTDManager mockDTDManager; late MockDartToolingDaemon mockDtd; late EditorClient editorClient; @@ -23,7 +25,9 @@ void main() { }; setUp(() { + mockDTDManager = MockDTDManager(); mockDtd = MockDartToolingDaemon(); + when(mockDTDManager.connection).thenReturn(ValueNotifier(mockDtd)); for (final MapEntry(key: method, value: responseJson) in methodToResponseJson.entries) { @@ -40,7 +44,7 @@ void main() { method.isRegistered = true; } - editorClient = EditorClient(mockDtd); + editorClient = EditorClient(mockDTDManager); }); group('getRefactors', () { diff --git a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart index 183ca530e13..c7ae029a05a 100644 --- a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart @@ -32,6 +32,19 @@ class DTDManager { Uri? get uri => _uri; Uri? _uri; + /// A stream of [CoreDtdServiceConstants.serviceRegisteredKind] and + /// [CoreDtdServiceConstants.serviceUnregisteredKind] events. + /// + /// Since this is a broadcast stream, it supports multiple subscribers. + Stream get serviceRegistrationBroadcastStream => + _serviceRegistrationController.stream; + final _serviceRegistrationController = StreamController.broadcast(); + + /// The subscription to the current service registration stream. + /// + /// This is canceled and reset with the DTD connection changes. + StreamSubscription? _currentServiceRegistrationSubscription; + /// Whether or not to automatically reconnect if disconnected. /// /// This will happen by default as long as the disconnect wasn't @@ -74,7 +87,7 @@ class DTDManager { } /// Triggers a reconnect to the last connected URI if the current state is - /// [ConnectionFailedDTDState] (and there was a pervious connection). + /// [ConnectionFailedDTDState] (and there was a previous connection). Future reconnect() { final reconnectFunc = _lastConnectFunc; if (_connectionState.value is! ConnectionFailedDTDState || @@ -149,11 +162,19 @@ class DTDManager { try { final connection = await _connectWithRetries(uri, maxRetries: maxRetries); + await _listenForServiceRegistrationEvents(connection); + // Save the previous connection so that we can close it after the new + // connection is reestablished. + final previousConnection = _connection.value; _uri = uri; // Set this after setting the value of [_uri] so that [_uri] can be used // by any listeners of the [_connection] notifier. _connection.value = connection; + // Close the previous connection. + if (previousConnection != null) { + await previousConnection.close(); + } _connectionState.value = ConnectedDTDState(); _log.info('Successfully connected to DTD at: $uri'); @@ -230,16 +251,17 @@ class DTDManager { // an explicit disconnect. _automaticallyReconnect = false; - // We only clear the connection if we are explicitly disconnecting. In the - // case where the connection just dropped, we leave it so that we can - // continue to render a page (usually with an overlay). + // We only close and clear the connection if we are explicitly + // disconnecting. In the case where the connection just dropped, we leave + // it so that we can continue to render a page (usually with an overlay). + // We only close it once the new connection is established. + if (_connection.value case final connection?) { + await connection.close(); + } _connection.value = null; } _periodicConnectionCheck?.cancel(); - if (_connection.value case final connection?) { - await connection.close(); - } _connectionState.value = NotConnectedDTDState(); _uri = null; @@ -249,9 +271,47 @@ class DTDManager { Future dispose() async { await disconnect(); + await _currentServiceRegistrationSubscription?.cancel(); + await _serviceRegistrationController.close(); _connection.dispose(); } + /// Listens for service registration events on the [dtd] connection. + Future _listenForServiceRegistrationEvents( + DartToolingDaemon dtd) async { + // Note: We immediately begin listening for service registration events on + // on the new DTD connection before canceling the previous subscription. + // This guarantees that we don't miss any events across reconnects. + // ignore: cancel_subscriptions, false positive, it is canceled below. + final nextServiceRegistrationSubscription = dtd + .onEvent(CoreDtdServiceConstants.servicesStreamId) + .listen(_forwardServiceRegistrationEvents, + onError: _logServiceStreamError); + await dtd.streamListen(CoreDtdServiceConstants.servicesStreamId); + + // Cancel the previous subscription. + await _currentServiceRegistrationSubscription?.cancel(); + _currentServiceRegistrationSubscription = + nextServiceRegistrationSubscription; + } + + /// Forwards service registration events to the + /// [_serviceRegistrationController]. + void _forwardServiceRegistrationEvents(DTDEvent event) { + final kind = event.kind; + final isRegistrationEvent = + kind == CoreDtdServiceConstants.serviceRegisteredKind || + kind == CoreDtdServiceConstants.serviceUnregisteredKind; + + if (isRegistrationEvent) { + _serviceRegistrationController.add(event); + } + } + + void _logServiceStreamError(Object error) { + _log.warning('Error in DTD service stream', error); + } + /// Returns the workspace roots for the Dart Tooling Daemon connection. /// /// These roots are set by the tool that started DTD, which may be the IDE, From 1cf14ff6d97e361b88312e16dafde6c3cd4f3e26 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:08:35 -0800 Subject: [PATCH 2/7] One more test case and formatting --- .../src/standalone_ui/standalone_screen.dart | 4 +- .../test/service/dtd_manager_test.dart | 171 ++++++++++++++++++ .../lib/src/service/dtd_manager.dart | 14 +- 3 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 packages/devtools_app/test/service/dtd_manager_test.dart diff --git a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart index c57f778c93b..7cb0792c671 100644 --- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart +++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart @@ -37,11 +37,11 @@ enum StandaloneScreenType { ), StandaloneScreenType.editorSidebar => _DtdConnectedScreen( dtdManager: dtdManager, - builder: (dtdManager) => EditorSidebarPanel(dtdManager), + builder: EditorSidebarPanel.new, ), StandaloneScreenType.propertyEditor => _DtdConnectedScreen( dtdManager: dtdManager, - builder: (dtdManager) => PropertyEditorPanel(dtdManager), + builder: PropertyEditorPanel.new, ), }; } diff --git a/packages/devtools_app/test/service/dtd_manager_test.dart b/packages/devtools_app/test/service/dtd_manager_test.dart new file mode 100644 index 00000000000..981e0902302 --- /dev/null +++ b/packages/devtools_app/test/service/dtd_manager_test.dart @@ -0,0 +1,171 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:async/async.dart'; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:dtd/dtd.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + final fakeDtdUri = Uri.parse('ws://127.0.0.1:65314/KKXNgPdXnFk='); + + group('serviceRegistrationBroadcastStream', () { + final fooBarRegisteredEvent = DTDEvent('Service', 'ServiceRegistered', { + 'service': 'foo', + 'method': 'bar', + }, 1); + final bazQuxRegisteredEvent = DTDEvent('Service', 'ServiceRegistered', { + 'service': 'baz', + 'method': 'qux', + }, 2); + final fooBarUnregisteredEvent = DTDEvent('Service', 'ServiceUnregistered', { + 'service': 'foo', + 'method': 'bar', + }, 4); + final invalidEvent = DTDEvent('Service', 'Invalid', {}, 3); + + late TestDTDManager manager; + late MockDartToolingDaemon mockDtd1; + late MockDartToolingDaemon mockDtd2; + + /// Sets up the [mockDTD] to return a [StreamController] so that events can + /// be added to the stream during the test. + StreamController setUpEventStream(MockDartToolingDaemon mockDTD) { + final streamController = StreamController(); + when(mockDTD.streamListen(any)).thenAnswer((_) async => const Success()); + when(mockDTD.onEvent(any)).thenAnswer((_) => streamController.stream); + return streamController; + } + + setUp(() { + mockDtd1 = MockDartToolingDaemon(); + mockDtd2 = MockDartToolingDaemon(); + manager = TestDTDManager(); + }); + + tearDown(() async { + await manager.dispose(); + }); + + test('supports multiple subscribers', () async { + // Connect to DTD. + final streamController = setUpEventStream(mockDtd1); + manager.mockDtd = mockDtd1; + await manager.connect(fakeDtdUri); + + // Create two subscribers. + final eventQueue1 = StreamQueue( + manager.serviceRegistrationBroadcastStream, + ); + final eventQueue2 = StreamQueue( + manager.serviceRegistrationBroadcastStream, + ); + + // Add an event. + streamController.add(fooBarRegisteredEvent); + + // Verify both subscribers received the event. + expect(await eventQueue1.next, equals(fooBarRegisteredEvent)); + expect(await eventQueue2.next, equals(fooBarRegisteredEvent)); + + await eventQueue1.cancel(); + await eventQueue2.cancel(); + }); + + test( + 'only forwards ServiceRegistered and ServiceUnregistered events', + () async { + // Connect to DTD. + final streamController = setUpEventStream(mockDtd1); + manager.mockDtd = mockDtd1; + await manager.connect(fakeDtdUri); + + // Subscribe to the service registration stream. + final eventQueue = StreamQueue( + manager.serviceRegistrationBroadcastStream, + ); + + // The manager only forwards registered and unregistered events. + streamController.add(fooBarRegisteredEvent); + streamController.add(fooBarUnregisteredEvent); + streamController.add(invalidEvent); + expect(await eventQueue.next, equals(fooBarRegisteredEvent)); + expect(await eventQueue.next, equals(fooBarUnregisteredEvent)); + + await eventQueue.cancel(); + }, + ); + + test('forwards events across multiple DTD connections', () async { + // Connect to the first DTD instance. + final streamController1 = setUpEventStream(mockDtd1); + manager.mockDtd = mockDtd1; + await manager.connect(fakeDtdUri); + + // The manager forwards events from the first DTD instance. + final eventQueue = StreamQueue( + manager.serviceRegistrationBroadcastStream, + ); + streamController1.add(fooBarRegisteredEvent); + expect(await eventQueue.next, equals(fooBarRegisteredEvent)); + + // Connect to the second DTD instance: + final streamController2 = setUpEventStream(mockDtd2); + manager.mockDtd = mockDtd2; + await manager.connect(fakeDtdUri); + + // The manager forwards events from the second DTD instance. + streamController2.add(bazQuxRegisteredEvent); + expect(await eventQueue.next, equals(bazQuxRegisteredEvent)); + + await eventQueue.cancel(); + }); + + test('continues to forward events while DTD is reconnecting', () async { + // Connect to DTD. + final streamController = setUpEventStream(mockDtd1); + final dtdDoneCompleter = Completer(); + when(mockDtd1.done).thenAnswer((_) => dtdDoneCompleter.future); + manager.mockDtd = mockDtd1; + await manager.connect(fakeDtdUri); + + // Subscribe to the service registration stream. + final eventQueue = StreamQueue( + manager.serviceRegistrationBroadcastStream, + ); + + // Send events while DTD is reconnecting. + manager.connectionState.addListener(() { + if (manager.connectionState.value is NotConnectedDTDState) { + streamController.add(fooBarRegisteredEvent); + } + if (manager.connectionState.value is ConnectingDTDState) { + streamController.add(bazQuxRegisteredEvent); + } + }); + + // Trigger a done event to force DTD to reconnect. + dtdDoneCompleter.complete(); + + // Verify the events sent during reconneciton were received. + expect(await eventQueue.next, equals(fooBarRegisteredEvent)); + expect(await eventQueue.next, equals(bazQuxRegisteredEvent)); + + await eventQueue.cancel(); + }); + }); +} + +class TestDTDManager extends DTDManager { + DartToolingDaemon? mockDtd; + + @override + Future connectDtdImpl(Uri uri) async { + return mockDtd!; + } +} diff --git a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart index c7ae029a05a..d477073c3fb 100644 --- a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart @@ -252,9 +252,11 @@ class DTDManager { _automaticallyReconnect = false; // We only close and clear the connection if we are explicitly - // disconnecting. In the case where the connection just dropped, we leave - // it so that we can continue to render a page (usually with an overlay). - // We only close it once the new connection is established. + // disconnecting. + // + // In the case where the connection just dropped, we leave it so + // that we can continue to render a page (usually with an overlay), then + // only close it once the new connection is established. if (_connection.value case final connection?) { await connection.close(); } @@ -279,9 +281,9 @@ class DTDManager { /// Listens for service registration events on the [dtd] connection. Future _listenForServiceRegistrationEvents( DartToolingDaemon dtd) async { - // Note: We immediately begin listening for service registration events on - // on the new DTD connection before canceling the previous subscription. - // This guarantees that we don't miss any events across reconnects. + // We immediately begin listening for service registration events on the new + // DTD connection before canceling the previous subscription. This + // guarantees that we don't miss any events across reconnects. // ignore: cancel_subscriptions, false positive, it is canceled below. final nextServiceRegistrationSubscription = dtd .onEvent(CoreDtdServiceConstants.servicesStreamId) From c64931f9dfdd9e2c3ea01dceaf0718fb88fb8740 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:57:29 -0800 Subject: [PATCH 3/7] Fix editor client not loading --- .../lib/src/shared/editor/editor_client.dart | 125 +++++++++++------- .../shared/editor/editor_client_test.dart | 24 ++++ 2 files changed, 103 insertions(+), 46 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index 4194fcfff3b..ef03c446f47 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -10,11 +10,14 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:dtd/dtd.dart'; import 'package:flutter/foundation.dart'; import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:logging/logging.dart'; import '../analytics/constants.dart'; import '../framework/app_error_handling.dart'; import 'api_classes.dart'; +final _log = Logger('editor_client'); + /// A client wrapper that connects to an editor over DTD. /// /// Changes made to the editor services/events should be considered carefully to @@ -36,50 +39,19 @@ class EditorClient extends DisposableController autoDisposeStreamSubscription( _dtdManager.serviceRegistrationBroadcastStream.listen((data) { final service = data.data[DtdParameters.service] as String?; - if (service == null || - (service != editorServiceName && service != lspServiceName)) { - return; - } - + if (service == null) return; final isRegistered = data.kind == CoreDtdServiceConstants.serviceRegisteredKind; final method = data.data[DtdParameters.method] as String; final capabilities = data.data[DtdParameters.capabilities] as Map?; - final lspMethod = LspMethod.fromMethodName(method); - if (lspMethod != null) { - lspMethod.isRegistered = isRegistered; - if (lspMethod == LspMethod.editableArguments) { - // Update the notifier so that the Property Editor is aware that the - // editableArguments API is registered. - _editableArgumentsApiIsRegistered.value = isRegistered; - } - } else if (method == EditorMethod.getDevices.name) { - _supportsGetDevices = isRegistered; - } else if (method == EditorMethod.getDebugSessions.name) { - _supportsGetDebugSessions = isRegistered; - } else if (method == EditorMethod.selectDevice.name) { - _supportsSelectDevice = isRegistered; - } else if (method == EditorMethod.hotReload.name) { - _supportsHotReload = isRegistered; - } else if (method == EditorMethod.hotRestart.name) { - _supportsHotRestart = isRegistered; - } else if (method == EditorMethod.openDevToolsPage.name) { - _supportsOpenDevToolsPage = isRegistered; - _supportsOpenDevToolsForceExternal = - capabilities?[Field.supportsForceExternal] == true; - } else { - return; - } - final info = isRegistered - ? ServiceRegistered( - service: service, - method: method, - capabilities: capabilities, - ) - : ServiceUnregistered(service: service, method: method); - _editorServiceChangedController.add(info); + _handleServiceRegistration( + service: service, + method: method, + capabilities: capabilities, + isRegistered: isRegistered, + ); }), ); @@ -123,14 +95,75 @@ class EditorClient extends DisposableController } }), ); - await [ - _dtd.streamListen(editorStreamName).catchError((_) { - // Because we currently call streamListen in two places (here and - // ThemeManager) this can fail. It doesn't matter if this happens, - // however we should refactor this code to better support using the DTD - // connection in multiple places without them having to coordinate. - }), - ].wait; + + await _dtd.streamListen(editorStreamName).catchError((_) { + // Because we currently call streamListen in two places (here and + // ThemeManager) this can fail. It doesn't matter if this happens, + // however we should refactor this code to better support using the DTD + // connection in multiple places without them having to coordinate. + }); + + // Check if any client services have already been registered against DTD. + try { + final response = await _dtd.getRegisteredServices(); + for (final service in response.clientServices) { + for (final method in service.methods.values) { + _handleServiceRegistration( + service: service.name, + method: method.name, + capabilities: method.capabilities, + ); + } + } + } catch (e) { + _log.warning('Failed to fetch registered services: $e'); + } + } + + void _handleServiceRegistration({ + required String service, + required String method, + Map? capabilities, + bool isRegistered = true, + }) { + if (service != editorServiceName && service != lspServiceName) { + return; + } + + final lspMethod = LspMethod.fromMethodName(method); + if (lspMethod != null) { + lspMethod.isRegistered = isRegistered; + if (lspMethod == LspMethod.editableArguments) { + // Update the notifier so that the Property Editor is aware that the + // editableArguments API is registered. + _editableArgumentsApiIsRegistered.value = isRegistered; + } + } else if (method == EditorMethod.getDevices.name) { + _supportsGetDevices = isRegistered; + } else if (method == EditorMethod.getDebugSessions.name) { + _supportsGetDebugSessions = isRegistered; + } else if (method == EditorMethod.selectDevice.name) { + _supportsSelectDevice = isRegistered; + } else if (method == EditorMethod.hotReload.name) { + _supportsHotReload = isRegistered; + } else if (method == EditorMethod.hotRestart.name) { + _supportsHotRestart = isRegistered; + } else if (method == EditorMethod.openDevToolsPage.name) { + _supportsOpenDevToolsPage = isRegistered; + _supportsOpenDevToolsForceExternal = + capabilities?[Field.supportsForceExternal] == true; + } else { + return; + } + + final info = isRegistered + ? ServiceRegistered( + service: service, + method: method, + capabilities: capabilities, + ) + : ServiceUnregistered(service: service, method: method); + _editorServiceChangedController.add(info); } /// Close the connection to DTD. diff --git a/packages/devtools_app/test/shared/editor/editor_client_test.dart b/packages/devtools_app/test/shared/editor/editor_client_test.dart index 364d40f7583..765c0baeba2 100644 --- a/packages/devtools_app/test/shared/editor/editor_client_test.dart +++ b/packages/devtools_app/test/shared/editor/editor_client_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:dart_service_protocol_shared/dart_service_protocol_shared.dart'; import 'package:devtools_app/src/shared/editor/api_classes.dart'; import 'package:devtools_app/src/shared/editor/editor_client.dart'; import 'package:devtools_test/devtools_test.dart'; @@ -238,6 +239,29 @@ void main() { expect(result.errorMessage, 'API is unavailable.'); }); }); + + group('initialization', () { + test('checks for existing services registered on DTD', () async { + // Add getDevices service to registered services. + final getDevicesService = ClientServiceInfo(editorServiceName, { + EditorMethod.getDevices.name: ClientServiceMethodInfo( + EditorMethod.getDevices.name, + ), + }); + final response = RegisteredServicesResponse( + dtdServices: [], + clientServices: [getDevicesService], + ); + when(mockDtd.getRegisteredServices()).thenAnswer((_) async => response); + + // Initialize the client. + final client = EditorClient(mockDTDManager); + await client.initialized; + + // Check that it supports getDevices. + expect(client.supportsGetDevices, isTrue); + }); + }); } const _fakeScreenId = 'DevToolsScreen'; From a523eb6aa8710cd19901669cc5e5ace427d25920 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:38:35 -0800 Subject: [PATCH 4/7] Trying to get integration tests to pass --- .../devtools_shared/lib/src/test/test_utils.dart | 4 +++- .../integration_test/integration_test_utils.dart | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/devtools_shared/lib/src/test/test_utils.dart b/packages/devtools_shared/lib/src/test/test_utils.dart index 581ee495923..23b66c1bd3c 100644 --- a/packages/devtools_shared/lib/src/test/test_utils.dart +++ b/packages/devtools_shared/lib/src/test/test_utils.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; + Future waitFor( - Future Function() condition, { + FutureOr Function() condition, { Duration timeout = const Duration(seconds: 10), String timeoutMessage = 'condition not satisfied', Duration delay = _shortDelay, diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index 605df1e5e47..f5e8a0827a6 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -7,7 +7,9 @@ import 'dart:ui' as ui; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/main.dart' as app; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -101,6 +103,11 @@ Future connectToTestApp(WidgetTester tester, TestApp testApp) async { ), ); await tester.pumpAndSettle(longPumpDuration); + logStatus('waiting for DTD connection...'); + await waitFor( + () => dtdManager.connectionState.value is ConnectedDTDState, + timeoutMessage: 'Timed out waiting for DTD to connect.', + ); expect(find.byType(ConnectInput), findsNothing); expect(find.byType(ConnectedAppSummary), findsOneWidget); _verifyFooterColor(tester, darkColorScheme.primary); @@ -112,8 +119,12 @@ Future disconnectFromTestApp(WidgetTester tester) async { await findTab(tester, icon: null, iconAsset: ScreenMetaData.home.iconAsset), ); await tester.pumpAndSettle(); + await waitFor( + () => dtdManager.connectionState.value is NotConnectedDTDState, + timeoutMessage: 'Timed out waiting for DTD to disconnect.', + ); await tester.tap(find.byType(ConnectToNewAppButton)); - await tester.pump(safePumpDuration); + await tester.pumpAndSettle(safePumpDuration); } class TestApp { From dcd312f836d0fc8b88913b4087268f88446d2454 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:29:51 -0800 Subject: [PATCH 5/7] Revert integration test changes --- .../lib/src/integration_test/integration_test_utils.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index f5e8a0827a6..f0b3bb92c22 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -103,11 +103,6 @@ Future connectToTestApp(WidgetTester tester, TestApp testApp) async { ), ); await tester.pumpAndSettle(longPumpDuration); - logStatus('waiting for DTD connection...'); - await waitFor( - () => dtdManager.connectionState.value is ConnectedDTDState, - timeoutMessage: 'Timed out waiting for DTD to connect.', - ); expect(find.byType(ConnectInput), findsNothing); expect(find.byType(ConnectedAppSummary), findsOneWidget); _verifyFooterColor(tester, darkColorScheme.primary); From 1e0f8a14d071f8506351208ac083792e26f5d24b Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:41:22 -0800 Subject: [PATCH 6/7] Revert the rest of the integration test changes --- packages/devtools_shared/lib/src/test/test_utils.dart | 2 +- .../lib/src/integration_test/integration_test_utils.dart | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/devtools_shared/lib/src/test/test_utils.dart b/packages/devtools_shared/lib/src/test/test_utils.dart index 23b66c1bd3c..40d76e6e1ef 100644 --- a/packages/devtools_shared/lib/src/test/test_utils.dart +++ b/packages/devtools_shared/lib/src/test/test_utils.dart @@ -5,7 +5,7 @@ import 'dart:async'; Future waitFor( - FutureOr Function() condition, { + Future Function() condition, { Duration timeout = const Duration(seconds: 10), String timeoutMessage = 'condition not satisfied', Duration delay = _shortDelay, diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index f0b3bb92c22..605df1e5e47 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -7,9 +7,7 @@ import 'dart:ui' as ui; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/main.dart' as app; -import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -114,12 +112,8 @@ Future disconnectFromTestApp(WidgetTester tester) async { await findTab(tester, icon: null, iconAsset: ScreenMetaData.home.iconAsset), ); await tester.pumpAndSettle(); - await waitFor( - () => dtdManager.connectionState.value is NotConnectedDTDState, - timeoutMessage: 'Timed out waiting for DTD to disconnect.', - ); await tester.tap(find.byType(ConnectToNewAppButton)); - await tester.pumpAndSettle(safePumpDuration); + await tester.pump(safePumpDuration); } class TestApp { From d24a06c288c6d2d48b52b7c7d1e19ea6b3f8c88b Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:07:31 -0800 Subject: [PATCH 7/7] Remove unused import --- packages/devtools_shared/lib/src/test/test_utils.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/devtools_shared/lib/src/test/test_utils.dart b/packages/devtools_shared/lib/src/test/test_utils.dart index 40d76e6e1ef..581ee495923 100644 --- a/packages/devtools_shared/lib/src/test/test_utils.dart +++ b/packages/devtools_shared/lib/src/test/test_utils.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'dart:async'; - Future waitFor( Future Function() condition, { Duration timeout = const Duration(seconds: 10),