diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart index 1343db20c33..a66f36d39d0 100644 --- a/pkg/dds/lib/src/dap/adapters/dart.dart +++ b/pkg/dds/lib/src/dap/adapters/dart.dart @@ -67,22 +67,17 @@ var _subscribeToOutputStreams = false; /// Pattern for a trailing semicolon. final _trailingSemicolonPattern = RegExp(r';$'); -/// An implementation of [LaunchRequestArguments] that includes all fields used +/// An implementation of [AttachRequestArguments] that includes all fields used /// by the base Dart debug adapter. /// /// This class represents the data passed from the client editor to the debug -/// adapter in launchRequest, which is a request to start debugging an +/// adapter in attachRequest, which is a request to start debugging an /// application. /// -/// Specialised adapters (such as Flutter) will likely extend this class with -/// their own additional fields. +/// Specialised adapters (such as Flutter) will likely have their own versions +/// of this class. class DartAttachRequestArguments extends DartCommonLaunchAttachRequestArguments implements AttachRequestArguments { - /// Optional data from the previous, restarted session. - /// The data is sent as the 'restart' attribute of the 'terminated' event. - /// The client should leave the data intact. - final Object? restart; - /// The VM Service URI to attach to. /// /// Either this or [vmServiceInfoFile] must be supplied. @@ -94,9 +89,9 @@ class DartAttachRequestArguments extends DartCommonLaunchAttachRequestArguments final String? vmServiceInfoFile; DartAttachRequestArguments({ - this.restart, this.vmServiceUri, this.vmServiceInfoFile, + Object? restart, String? name, String? cwd, List? additionalProjectPaths, @@ -108,6 +103,7 @@ class DartAttachRequestArguments extends DartCommonLaunchAttachRequestArguments }) : super( name: name, cwd: cwd, + restart: restart, additionalProjectPaths: additionalProjectPaths, debugSdkLibraries: debugSdkLibraries, debugExternalPackageLibraries: debugExternalPackageLibraries, @@ -117,15 +113,13 @@ class DartAttachRequestArguments extends DartCommonLaunchAttachRequestArguments ); DartAttachRequestArguments.fromMap(Map obj) - : restart = obj['restart'], - vmServiceUri = obj['vmServiceUri'] as String?, + : vmServiceUri = obj['vmServiceUri'] as String?, vmServiceInfoFile = obj['vmServiceInfoFile'] as String?, super.fromMap(obj); @override Map toJson() => { ...super.toJson(), - if (restart != null) 'restart': restart, if (vmServiceUri != null) 'vmServiceUri': vmServiceUri, if (vmServiceInfoFile != null) 'vmServiceInfoFile': vmServiceInfoFile, }; @@ -137,6 +131,11 @@ class DartAttachRequestArguments extends DartCommonLaunchAttachRequestArguments /// A common base for [DartLaunchRequestArguments] and /// [DartAttachRequestArguments] for fields that are common to both. class DartCommonLaunchAttachRequestArguments extends RequestArguments { + /// Optional data from the previous, restarted session. + /// The data is sent as the 'restart' attribute of the 'terminated' event. + /// The client should leave the data intact. + final Object? restart; + final String? name; final String? cwd; @@ -187,6 +186,7 @@ class DartCommonLaunchAttachRequestArguments extends RequestArguments { final bool? sendLogsToClient; DartCommonLaunchAttachRequestArguments({ + required this.restart, required this.name, required this.cwd, required this.additionalProjectPaths, @@ -198,7 +198,8 @@ class DartCommonLaunchAttachRequestArguments extends RequestArguments { }); DartCommonLaunchAttachRequestArguments.fromMap(Map obj) - : name = obj['name'] as String?, + : restart = obj['restart'], + name = obj['name'] as String?, cwd = obj['cwd'] as String?, additionalProjectPaths = (obj['additionalProjectPaths'] as List?)?.cast(), @@ -212,6 +213,7 @@ class DartCommonLaunchAttachRequestArguments extends RequestArguments { sendLogsToClient = obj['sendLogsToClient'] as bool?; Map toJson() => { + if (restart != null) 'restart': restart, if (name != null) 'name': name, if (cwd != null) 'cwd': cwd, if (additionalProjectPaths != null) @@ -263,8 +265,8 @@ class DartCommonLaunchAttachRequestArguments extends RequestArguments { /// an expression into an evaluation console) or to events sent by the server /// (for example when the server sends a `StoppedEvent` it may cause the client /// to then send a `stackTraceRequest` or `scopesRequest` to get variables). -abstract class DartDebugAdapter extends BaseDebugAdapter { +abstract class DartDebugAdapter extends BaseDebugAdapter { late final DartCommonLaunchAttachRequestArguments args; final _debuggerInitializedCompleter = Completer(); final _configurationDoneCompleter = Completer(); @@ -378,6 +380,14 @@ abstract class DartDebugAdapter _initializeArgs; + /// Whether or not this adapter can handle the restartRequest. + /// + /// If false, the editor will just terminate the debug session and start a new + /// one when the user asks to restart. If true, the adapter must implement + /// the [restartRequest] method and handle its own restart (for example the + /// Flutter adapter will perform a Hot Restart). + bool get supportsRestartRequest => false; + /// Whether the VM Service closing should be used as a signal to terminate the /// debug session. /// @@ -407,7 +417,7 @@ abstract class DartDebugAdapter _handleVmServiceClosed())); _subscriptions.addAll([ - vmService.onIsolateEvent.listen(_handleIsolateEvent), - vmService.onDebugEvent.listen(_handleDebugEvent), - vmService.onLoggingEvent.listen(_handleLoggingEvent), - // TODO(dantup): Implement these. - // vmService.onExtensionEvent.listen(_handleExtensionEvent), - // vmService.onServiceEvent.listen(_handleServiceEvent), + vmService.onIsolateEvent.listen(handleIsolateEvent), + vmService.onDebugEvent.listen(handleDebugEvent), + vmService.onLoggingEvent.listen(handleLoggingEvent), + vmService.onExtensionEvent.listen(handleExtensionEvent), + vmService.onServiceEvent.listen(handleServiceEvent), if (_subscribeToOutputStreams) vmService.onStdoutEvent.listen(_handleStdoutEvent), if (_subscribeToOutputStreams) @@ -530,8 +544,8 @@ abstract class DartDebugAdapter?; + final response = await vmService?.callServiceExtension( + method, + args: params, + ); + sendResponse(response?.json); + break; + default: await super.customRequest(request, args, sendResponse); } @@ -848,6 +880,7 @@ abstract class DartDebugAdapter _converter.resolvePackageUri(uri); + /// restart is called by the client when the user invokes a restart (for + /// example with the button on the debug toolbar). + /// + /// The base implementation of this method throws. It is up to a debug adapter + /// that advertises `supportsRestartRequest` to override this method. + @override + Future restartRequest( + Request request, + RestartArguments? args, + void Function() sendResponse, + ) async { + throw DebugAdapterException( + 'restartRequest was called on an adapter that ' + 'does not provide an implementation', + ); + } + /// [scopesRequest] is called by the client to request all of the variables /// scopes available for a given stack frame. @override @@ -1417,11 +1467,25 @@ abstract class DartDebugAdapter ws, https -> wss). final isSecure = uri.isScheme('https') || uri.isScheme('wss'); @@ -1468,7 +1532,9 @@ abstract class DartDebugAdapter _handleDebugEvent(vm.Event event) async { + @protected + @mustCallSuper + Future handleDebugEvent(vm.Event event) async { // Delay processing any events until the debugger initialization has // finished running, as events may arrive (for ex. IsolateRunnable) while // it's doing is own initialization that this may interfere with. @@ -1477,17 +1543,42 @@ abstract class DartDebugAdapter _handleIsolateEvent(vm.Event event) async { + @protected + @mustCallSuper + Future handleExtensionEvent(vm.Event event) async { + await debuggerInitialized; + + // Base Dart does not do anything here, but other DAs (like Flutter) may + // override it to do their own handling. + } + + @protected + @mustCallSuper + Future handleIsolateEvent(vm.Event event) async { // Delay processing any events until the debugger initialization has // finished running, as events may arrive (for ex. IsolateRunnable) while // it's doing is own initialization that this may interfere with. await debuggerInitialized; + // Allow IsolateManager to handle any state-related events. await _isolateManager.handleEvent(event); + + switch (event.kind) { + // Pass any Service Extension events on to the client so they can enable + // functionality based upon them. + case vm.EventKind.kServiceExtensionAdded: + this._sendServiceExtensionAdded( + event.extensionRPC!, + event.isolate!.id!, + ); + break; + } } /// Handles a dart:developer log() event, sending output to the client. - Future _handleLoggingEvent(vm.Event event) async { + @protected + @mustCallSuper + Future handleLoggingEvent(vm.Event event) async { final record = event.logRecord; final thread = _isolateManager.threadForIsolate(event.isolate); if (record == null || thread == null) { @@ -1501,7 +1592,8 @@ abstract class DartDebugAdapter handleServiceEvent(vm.Event event) async { + await debuggerInitialized; + + switch (event.kind) { + // Service registrations are passed to the client so they can toggle + // behaviour based on their presence. + case vm.EventKind.kServiceRegistered: + this._sendServiceRegistration(event.service!, event.method!); + break; + case vm.EventKind.kServiceUnregistered: + this._sendServiceUnregistration(event.service!, event.method!); + break; + } + } + void _handleStderrEvent(vm.Event event) { _sendOutputStreamEvent('stderr', event); } @@ -1585,6 +1701,27 @@ abstract class DartDebugAdapter? additionalProjectPaths, @@ -1698,6 +1830,7 @@ class DartLaunchRequestArguments extends DartCommonLaunchAttachRequestArguments bool? evaluateToStringInDebugViews, bool? sendLogsToClient, }) : super( + restart: restart, name: name, cwd: cwd, additionalProjectPaths: additionalProjectPaths, @@ -1709,8 +1842,7 @@ class DartLaunchRequestArguments extends DartCommonLaunchAttachRequestArguments ); DartLaunchRequestArguments.fromMap(Map obj) - : restart = obj['restart'], - noDebug = obj['noDebug'] as bool?, + : noDebug = obj['noDebug'] as bool?, program = obj['program'] as String, args = (obj['args'] as List?)?.cast(), toolArgs = (obj['toolArgs'] as List?)?.cast(), @@ -1722,7 +1854,6 @@ class DartLaunchRequestArguments extends DartCommonLaunchAttachRequestArguments @override Map toJson() => { ...super.toJson(), - if (restart != null) 'restart': restart, if (noDebug != null) 'noDebug': noDebug, 'program': program, if (args != null) 'args': args, diff --git a/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart b/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart index 2a3f351aef7..a0d23d812ad 100644 --- a/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart +++ b/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart @@ -145,6 +145,11 @@ class DartCliDebugAdapter extends DartDebugAdapter terminateImpl() async { - terminatePids(ProcessSignal.sigint); + terminatePids(ProcessSignal.sigterm); await _process?.exitCode; } diff --git a/pkg/dds/lib/src/dap/adapters/dart_test_adapter.dart b/pkg/dds/lib/src/dap/adapters/dart_test_adapter.dart index 534166d3315..04172455330 100644 --- a/pkg/dds/lib/src/dap/adapters/dart_test_adapter.dart +++ b/pkg/dds/lib/src/dap/adapters/dart_test_adapter.dart @@ -159,7 +159,7 @@ class DartTestDebugAdapter extends DartDebugAdapter terminateImpl() async { - terminatePids(ProcessSignal.sigint); + terminatePids(ProcessSignal.sigterm); await _process?.exitCode; } diff --git a/pkg/dds/lib/src/dap/base_debug_adapter.dart b/pkg/dds/lib/src/dap/base_debug_adapter.dart index 3f64d981d8d..2d82bbdbca6 100644 --- a/pkg/dds/lib/src/dap/base_debug_adapter.dart +++ b/pkg/dds/lib/src/dap/base_debug_adapter.dart @@ -77,7 +77,7 @@ abstract class BaseDebugAdapter disconnectRequest( @@ -167,6 +167,12 @@ abstract class BaseDebugAdapter restartRequest( + Request request, + RestartArguments? args, + void Function() sendResponse, + ); + Future scopesRequest( Request request, ScopesArguments args, @@ -280,6 +286,12 @@ abstract class BaseDebugAdapter(newBreakpoints, (bp) async { - final vmBp = await service.addBreakpointWithScriptUri( - isolateId, uri, bp.line, - column: bp.column); - existingBreakpointsForIsolateAndUri.add(vmBp); - _clientBreakpointsByVmId[vmBp.id!] = bp; + try { + final vmBp = await service.addBreakpointWithScriptUri( + isolateId, uri, bp.line, + column: bp.column); + existingBreakpointsForIsolateAndUri.add(vmBp); + _clientBreakpointsByVmId[vmBp.id!] = bp; + } catch (e) { + // Swallow errors setting breakpoints rather than failing the whole + // request as it's very easy for editors to send us breakpoints that + // aren't valid any more. + _adapter.logger?.call('Failed to add breakpoint $e'); + } }); } } diff --git a/pkg/dds/test/dap/integration/debug_services.dart b/pkg/dds/test/dap/integration/debug_services.dart new file mode 100644 index 00000000000..98f698ed609 --- /dev/null +++ b/pkg/dds/test/dap/integration/debug_services.dart @@ -0,0 +1,98 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:vm_service/vm_service_io.dart'; + +import 'test_client.dart'; +import 'test_scripts.dart'; +import 'test_support.dart'; + +main() { + late DapTestSession dap; + setUp(() async { + dap = await DapTestSession.setUp(); + }); + tearDown(() => dap.tearDown()); + + group('debug mode', () { + test('reports the VM Service URI to the client', () async { + final client = dap.client; + final testFile = await dap.createTestFile(simpleBreakpointProgram); + final breakpointLine = lineWith(testFile, breakpointMarker); + + await client.hitBreakpoint(testFile, breakpointLine); + final vmServiceUri = (await client.vmServiceUri)!; + expect(vmServiceUri.scheme, anyOf('ws', 'wss')); + + await client.terminate(); + }); + + test('exposes VM services to the client', () async { + final client = dap.client; + final testFile = await dap.createTestFile(simpleBreakpointProgram); + final breakpointLine = lineWith(testFile, breakpointMarker); + + // Capture our test service registration. + final myServiceRegistrationFuture = client.serviceRegisteredEvents + .firstWhere((event) => event['service'] == 'myService'); + await client.hitBreakpoint(testFile, breakpointLine); + final vmServiceUri = await client.vmServiceUri; + + // Register a service that echos back its params. + final vmService = await vmServiceConnectUri(vmServiceUri.toString()); + // A service seems mandatory for this to work, even though it's unused. + await vmService.registerService('myService', 'myServiceAlias'); + vmService.registerServiceCallback('myService', (params) async { + return {'result': params}; + }); + + // Ensure the service registration event is emitted and includes the + // method to call it. + final myServiceRegistration = await myServiceRegistrationFuture; + final myServiceRegistrationMethod = + myServiceRegistration['method'] as String; + + // Call the method and expect it to return the same values. + final response = await client.callService( + myServiceRegistrationMethod, + {'foo': 'bar'}, + ); + final result = response.body as Map; + expect(result['foo'], equals('bar')); + + await vmService.dispose(); + await client.terminate(); + }); + + test('exposes VM service extensions to the client', () async { + final client = dap.client; + final testFile = await dap.createTestFile(serviceExtensionProgram); + + // Capture our test service registration. + final serviceExtensionAddedFuture = client.serviceExtensionAddedEvents + .firstWhere( + (event) => event['extensionRPC'] == 'ext.service.extension'); + await client.start(file: testFile); + + // Ensure the service registration event is emitted and includes the + // method to call it. + final serviceExtensionAdded = await serviceExtensionAddedFuture; + final extensionRPC = serviceExtensionAdded['extensionRPC'] as String; + final isolateId = serviceExtensionAdded['isolateId'] as String; + + // Call the method and expect it to return the same values. + final response = await client.callService( + extensionRPC, + { + 'isolateId': isolateId, + 'foo': 'bar', + }, + ); + final result = response.body as Map; + expect(result['foo'], equals('bar')); + }); + // These tests can be slow due to starting up the external server process. + }, timeout: Timeout.none); +} diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart index 0850b5c40ed..e4e5010d04a 100644 --- a/pkg/dds/test/dap/integration/test_client.dart +++ b/pkg/dds/test/dap/integration/test_client.dart @@ -36,11 +36,20 @@ class DapTestClient { final _serverRequestHandlers = Function(Object?)>{}; + late final Future vmServiceUri; + DapTestClient._( this._channel, this._logger, { this.captureVmServiceTraffic = false, }) { + // Set up a future that will complete when the 'dart.debuggerUris' event is + // emitted by the debug adapter so tests have easy access to it. + vmServiceUri = event('dart.debuggerUris').then((event) { + final body = event.body as Map; + return Uri.parse(body['vmServiceUri'] as String); + }).catchError((e) => null); + _subscription = _channel.listen( _handleMessage, onDone: () { @@ -59,6 +68,16 @@ class DapTestClient { Stream get outputEvents => events('output') .map((e) => OutputEventBody.fromJson(e.body as Map)); + /// Returns a stream of custom 'dart.serviceExtensionAdded' events. + Stream> get serviceExtensionAddedEvents => + events('dart.serviceExtensionAdded') + .map((e) => e.body as Map); + + /// Returns a stream of custom 'dart.serviceRegistered' events. + Stream> get serviceRegisteredEvents => + events('dart.serviceRegistered') + .map((e) => e.body as Map); + /// Returns a stream of 'dart.testNotification' custom events from the /// package:test JSON reporter. Stream> get testNotificationEvents => @@ -116,6 +135,11 @@ class DapTestClient { return attachResponse; } + /// Calls a service method via a custom request. + Future callService(String name, Object? params) { + return custom('callService', {'method': name, 'params': params}); + } + /// Sends a continue request for the given thread. /// /// Returns a Future that completes when the server returns a corresponding diff --git a/pkg/dds/test/dap/integration/test_scripts.dart b/pkg/dds/test/dap/integration/test_scripts.dart index 0b70626ca9f..a382781d35c 100644 --- a/pkg/dds/test/dap/integration/test_scripts.dart +++ b/pkg/dds/test/dap/integration/test_scripts.dart @@ -20,6 +20,28 @@ const sdkStackFrameProgram = ''' } '''; +/// A simple Dart script that registers a simple service extension that returns +/// its params and waits until it is called before exiting. +const serviceExtensionProgram = ''' + import 'dart:async'; + import 'dart:convert'; + import 'dart:developer'; + + void main(List args) async { + // Using a completer here causes the VM to quit when the extension is called + // so use a flag. + // https://github.com/dart-lang/sdk/issues/47279 + var wasCalled = false; + registerExtension('ext.service.extension', (method, params) async { + wasCalled = true; + return ServiceExtensionResponse.result(jsonEncode(params)); + }); + while (!wasCalled) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } +'''; + /// A simple Dart script that prints its arguments. const simpleArgPrintingProgram = r''' void main(List args) async {