From 7eef09cd6eccd60d66fc75a0435d2979385614d0 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Thu, 1 Aug 2024 14:29:59 +0000 Subject: [PATCH] [ package:dds ] Add DartDevelopmentServiceLauncher class Provides shared logic for launching DDS using `dart development-service`. Change-Id: I934c777ada23206b498731ffb0611ed3bd383f1c Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/378320 Commit-Queue: Ben Konyi Reviewed-by: Derek Xu --- pkg/dds/CHANGELOG.md | 1 + pkg/dds/lib/dds.dart | 33 +++++ pkg/dds/lib/dds_launcher.dart | 198 ++++++++++++++++++++++++++ pkg/dds/lib/src/dds_impl.dart | 8 +- pkg/dds/test/launcher_smoke_test.dart | 72 ++++++++++ 5 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 pkg/dds/lib/dds_launcher.dart create mode 100644 pkg/dds/test/launcher_smoke_test.dart diff --git a/pkg/dds/CHANGELOG.md b/pkg/dds/CHANGELOG.md index 499429d1ff4..cd0d88424de 100644 --- a/pkg/dds/CHANGELOG.md +++ b/pkg/dds/CHANGELOG.md @@ -1,6 +1,7 @@ # 4.2.5 - Fixed DevTools URI not including a trailing '/' before the query parameters, which could prevent DevTools from loading properly. - [DAP] Fixed an issue where format specifiers and `format.hex` in `variablesRequest` would not apply to values from lists such as `Uint8List` from `dart:typed_data`. +- Added `package:dds/dds_launcher.dart`, a library which can be used to launch DDS instances using `dart development-service`. # 4.2.4+1 - Added missing type to `Event` in `postEvent`. diff --git a/pkg/dds/lib/dds.dart b/pkg/dds/lib/dds.dart index 8b2231330d1..7a6ed1458c9 100644 --- a/pkg/dds/lib/dds.dart +++ b/pkg/dds/lib/dds.dart @@ -169,6 +169,8 @@ abstract class DartDevelopmentService { static const String protocolVersion = '2.0'; } +/// Thrown by DDS during initialization failures, unexpected connection issues, +/// and when attempting to spawn DDS when an existing DDS instance exists. class DartDevelopmentServiceException implements Exception { /// Set when `DartDeveloperService.startDartDevelopmentService` is called and /// the target VM service already has a Dart Developer Service instance @@ -182,6 +184,33 @@ class DartDevelopmentServiceException implements Exception { /// Set when a connection error has occurred after startup. static const int connectionError = 3; + factory DartDevelopmentServiceException.fromJson(Map json) { + if (json + case { + 'error_code': final int errorCode, + 'message': final String message, + 'uri': final String? uri + }) { + return switch (errorCode) { + existingDdsInstanceError => + DartDevelopmentServiceException.existingDdsInstance( + message, + ddsUri: Uri.parse(uri!), + ), + failedToStartError => DartDevelopmentServiceException.failedToStart(), + connectionError => + DartDevelopmentServiceException.connectionIssue(message), + _ => throw StateError( + 'Invalid DartDevelopmentServiceException error_code: $errorCode', + ), + }; + } + throw StateError('Invalid DartDevelopmentServiceException JSON: $json'); + } + + /// Thrown when `DartDeveloperService.startDartDevelopmentService` is called + /// and the target VM service already has a Dart Developer Service instance + /// connected. factory DartDevelopmentServiceException.existingDdsInstance( String message, { Uri? ddsUri, @@ -192,11 +221,14 @@ class DartDevelopmentServiceException implements Exception { ); } + /// Thrown when the connection to the remote VM service terminates unexpectedly + /// during Dart Development Service startup. factory DartDevelopmentServiceException.failedToStart() { return DartDevelopmentServiceException._( failedToStartError, 'Failed to start Dart Development Service'); } + /// Thrown when a connection error has occurred after startup. factory DartDevelopmentServiceException.connectionIssue(String message) { return DartDevelopmentServiceException._(connectionError, message); } @@ -215,6 +247,7 @@ class DartDevelopmentServiceException implements Exception { final String message; } +/// Thrown when attempting to start a new DDS instance when one already exists. class ExistingDartDevelopmentServiceException extends DartDevelopmentServiceException { ExistingDartDevelopmentServiceException._( diff --git a/pkg/dds/lib/dds_launcher.dart b/pkg/dds/lib/dds_launcher.dart new file mode 100644 index 00000000000..2e8ad80f0a3 --- /dev/null +++ b/pkg/dds/lib/dds_launcher.dart @@ -0,0 +1,198 @@ +// Copyright (c) 2024, 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 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'dds.dart' hide DartDevelopmentService; +import 'src/arg_parser.dart'; +import 'src/dds_impl.dart'; + +/// Spawns a Dart Development Service instance which will communicate with a +/// VM service. Requires the target VM service to have no other connected +/// clients. +/// +/// [remoteVmServiceUri] is the address of the VM service that this +/// development service will communicate with. +/// +/// If provided, [serviceUri] will determine the address and port of the +/// spawned Dart Development Service. +/// +/// [enableAuthCodes] controls whether or not an authentication code must +/// be provided by clients when communicating with this instance of +/// DDS. Authentication codes take the form of a base64 +/// encoded string provided as the first element of the DDS path and is meant +/// to make it more difficult for unintended clients to connect to this +/// service. Authentication codes are enabled by default. +/// +/// If [serveDevTools] is enabled, DDS will serve a DevTools instance and act +/// as a DevTools Server. If not specified, [devToolsServerAddress] is ignored. +/// +/// If provided, DDS will redirect DevTools requests to an existing DevTools +/// server hosted at [devToolsServerAddress]. Ignored if [serveDevTools] is not +/// true. +/// +/// If [enableServicePortFallback] is enabled, DDS will attempt to bind to any +/// available port if the specified port is unavailable. +/// +/// If set, the set of [cachedUserTags] will be used to determine which CPU +/// samples should be cached by DDS. +/// +/// If provided, [dartExecutable] is the path to the 'dart' executable that +/// should be used to spawn the DDS instance. By default, `Platform.executable` +/// is used. +class DartDevelopmentServiceLauncher { + static Future start({ + required Uri remoteVmServiceUri, + Uri? serviceUri, + bool enableAuthCodes = true, + bool serveDevTools = false, + Uri? devToolsServerAddress, + bool enableServicePortFallback = false, + List cachedUserTags = const [], + String? dartExecutable, + }) async { + final process = await Process.start( + dartExecutable ?? Platform.executable, + [ + 'development-service', + '--${DartDevelopmentServiceOptions.vmServiceUriOption}=$remoteVmServiceUri', + if (serviceUri != null) ...[ + '--${DartDevelopmentServiceOptions.bindAddressOption}=${serviceUri.host}', + '--${DartDevelopmentServiceOptions.bindPortOption}=${serviceUri.port}', + ], + if (!enableAuthCodes) + '--${DartDevelopmentServiceOptions.disableServiceAuthCodesFlag}', + if (serveDevTools) + '--${DartDevelopmentServiceOptions.serveDevToolsFlag}', + if (devToolsServerAddress != null) + '--${DartDevelopmentServiceOptions.devToolsServerAddressOption}=$devToolsServerAddress', + if (enableServicePortFallback) + '--${DartDevelopmentServiceOptions.enableServicePortFallbackFlag}', + for (final String tag in cachedUserTags) + '--${DartDevelopmentServiceOptions.cachedUserTagsOption}=$tag', + ], + ); + final completer = Completer(); + late StreamSubscription stderrSub; + stderrSub = process.stderr + .transform(utf8.decoder) + .transform(json.decoder) + .listen((Object? result) { + if (result + case { + 'state': 'started', + 'ddsUri': final String ddsUriStr, + }) { + final ddsUri = Uri.parse(ddsUriStr); + final devToolsUriStr = result['devToolsUri'] as String?; + final devToolsUri = + devToolsUriStr == null ? null : Uri.parse(devToolsUriStr); + final dtdUriStr = + (result['dtd'] as Map?)?['uri'] as String?; + final dtdUri = dtdUriStr == null ? null : Uri.parse(dtdUriStr); + + completer.complete( + DartDevelopmentServiceLauncher._( + process: process, + uri: ddsUri, + devToolsUri: devToolsUri, + dtdUri: dtdUri, + ), + ); + } else if (result + case { + 'state': 'error', + 'error': final String error, + }) { + final Map? exceptionDetails = + result['ddsExceptionDetails'] as Map?; + completer.completeError( + exceptionDetails != null + ? DartDevelopmentServiceException.fromJson(exceptionDetails) + : StateError(error), + ); + } else { + throw StateError('Unexpected result from DDS: $result'); + } + stderrSub.cancel(); + }); + return completer.future; + } + + DartDevelopmentServiceLauncher._({ + required Process process, + required this.uri, + required this.devToolsUri, + required this.dtdUri, + }) : _ddsInstance = process; + + final Process _ddsInstance; + + /// The [Uri] VM service clients can use to communicate with this + /// DDS instance via HTTP. + final Uri uri; + + /// The HTTP [Uri] of the hosted DevTools instance. + /// + /// Returns `null` if DevTools is not running. + final Uri? devToolsUri; + + /// The [Uri] of the Dart Tooling Daemon instance that is hosted by DevTools. + /// + /// This will be null if DTD was not started by the DevTools server. For + /// example, it may have been started by an IDE. + final Uri? dtdUri; + + /// The [Uri] VM service clients can use to communicate with this + /// DDS instance via server-sent events (SSE). + Uri get sseUri => _toSse(uri)!; + + /// The [Uri] VM service clients can use to communicate with this + /// DDS instance via a [WebSocket]. + Uri get wsUri => _toWebSocket(uri)!; + + List _cleanupPathSegments(Uri uri) { + final pathSegments = []; + if (uri.pathSegments.isNotEmpty) { + pathSegments.addAll( + uri.pathSegments.where( + // Strip out the empty string that appears at the end of path segments. + // Empty string elements will result in an extra '/' being added to the + // URI. + (s) => s.isNotEmpty, + ), + ); + } + return pathSegments; + } + + Uri? _toWebSocket(Uri? uri) { + if (uri == null) { + return null; + } + final pathSegments = _cleanupPathSegments(uri); + pathSegments.add('ws'); + return uri.replace(scheme: 'ws', pathSegments: pathSegments); + } + + Uri? _toSse(Uri? uri) { + if (uri == null) { + return null; + } + final pathSegments = _cleanupPathSegments(uri); + pathSegments.add(DartDevelopmentServiceImpl.kSseHandlerPath); + return uri.replace(scheme: 'sse', pathSegments: pathSegments); + } + + /// Completes when the DDS instance has shutdown. + Future get done => _ddsInstance.exitCode; + + /// Shutdown the DDS instance. + Future shutdown() { + _ddsInstance.kill(); + return _ddsInstance.exitCode; + } +} diff --git a/pkg/dds/lib/src/dds_impl.dart b/pkg/dds/lib/src/dds_impl.dart index 376855e5c66..69dcfd44118 100644 --- a/pkg/dds/lib/src/dds_impl.dart +++ b/pkg/dds/lib/src/dds_impl.dart @@ -347,8 +347,8 @@ class DartDevelopmentServiceImpl implements DartDevelopmentService { Handler _sseHandler() { final handler = SseHandler( authCodesEnabled - ? Uri.parse('/$authCode/$_kSseHandlerPath') - : Uri.parse('/$_kSseHandlerPath'), + ? Uri.parse('/$authCode/$kSseHandlerPath') + : Uri.parse('/$kSseHandlerPath'), keepAlive: sseKeepAlive, ); @@ -441,7 +441,7 @@ class DartDevelopmentServiceImpl implements DartDevelopmentService { return null; } final pathSegments = _cleanupPathSegments(uri); - pathSegments.add(_kSseHandlerPath); + pathSegments.add(kSseHandlerPath); return uri.replace(scheme: 'sse', pathSegments: pathSegments); } @@ -552,7 +552,7 @@ class DartDevelopmentServiceImpl implements DartDevelopmentService { StreamManager get streamManager => _streamManager; late StreamManager _streamManager; - static const _kSseHandlerPath = '\$debugHandler'; + static const kSseHandlerPath = '\$debugHandler'; late json_rpc.Peer vmServiceClient; late WebSocketChannel _vmServiceSocket; diff --git a/pkg/dds/test/launcher_smoke_test.dart b/pkg/dds/test/launcher_smoke_test.dart new file mode 100644 index 00000000000..80fc54be958 --- /dev/null +++ b/pkg/dds/test/launcher_smoke_test.dart @@ -0,0 +1,72 @@ +// Copyright (c) 2024, 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 'dart:convert'; +import 'dart:io'; + +import 'package:dds/dds_launcher.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service_io.dart'; + +import 'common/test_helper.dart'; + +void main() { + group('DDS', () { + late Process process; + late DartDevelopmentServiceLauncher dds; + + setUp(() async { + process = await spawnDartProcess('smoke.dart'); + }); + + tearDown(() async { + await dds.shutdown(); + process.kill(); + }); + + void createSmokeTest(bool useAuthCodes) { + test( + 'Launcher Smoke Test with ${useAuthCodes ? "" : "no"} authentication codes', + () async { + dds = await DartDevelopmentServiceLauncher.start( + remoteVmServiceUri: remoteVmServiceUri, + enableAuthCodes: useAuthCodes, + ); + + // Ensure basic websocket requests are forwarded correctly to the VM service. + final service = await vmServiceConnectUri(dds.wsUri.toString()); + final version = await service.getVersion(); + expect(version.major! > 0, true); + expect(version.minor! >= 0, true); + + expect( + dds.uri.pathSegments, + useAuthCodes ? isNotEmpty : isEmpty, + ); + + // Ensure we can still make requests of the VM service via HTTP. + HttpClient client = HttpClient(); + final request = await client.getUrl(remoteVmServiceUri.replace( + pathSegments: [ + if (remoteVmServiceUri.pathSegments.isNotEmpty) + remoteVmServiceUri.pathSegments.first, + 'getVersion', + ], + )); + final response = await request.close(); + final Map jsonResponse = (await response + .transform(utf8.decoder) + .transform(json.decoder) + .single) as Map; + expect(jsonResponse['result']['type'], 'Version'); + expect(jsonResponse['result']['major'] > 0, true); + expect(jsonResponse['result']['minor'] >= 0, true); + }, + ); + } + + createSmokeTest(true); + createSmokeTest(false); + }); +}