[ 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 <bkonyi@google.com>
Reviewed-by: Derek Xu <derekx@google.com>
diff --git a/pkg/dds/CHANGELOG.md b/pkg/dds/CHANGELOG.md
index 499429d..cd0d884 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 8b22313..7a6ed14 100644
--- a/pkg/dds/lib/dds.dart
+++ b/pkg/dds/lib/dds.dart
@@ -169,6 +169,8 @@
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 @@
/// Set when a connection error has occurred after startup.
static const int connectionError = 3;
+ factory DartDevelopmentServiceException.fromJson(Map<String, Object?> 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 @@
);
}
+ /// 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 @@
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 0000000..2e8ad80
--- /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<DartDevelopmentServiceLauncher> start({
+ required Uri remoteVmServiceUri,
+ Uri? serviceUri,
+ bool enableAuthCodes = true,
+ bool serveDevTools = false,
+ Uri? devToolsServerAddress,
+ bool enableServicePortFallback = false,
+ List<String> cachedUserTags = const <String>[],
+ String? dartExecutable,
+ }) async {
+ final process = await Process.start(
+ dartExecutable ?? Platform.executable,
+ <String>[
+ 'development-service',
+ '--${DartDevelopmentServiceOptions.vmServiceUriOption}=$remoteVmServiceUri',
+ if (serviceUri != null) ...<String>[
+ '--${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<DartDevelopmentServiceLauncher>();
+ late StreamSubscription<Object?> 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<String, Object?>?)?['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<String, Object?>? exceptionDetails =
+ result['ddsExceptionDetails'] as Map<String, Object?>?;
+ 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<String> _cleanupPathSegments(Uri uri) {
+ final pathSegments = <String>[];
+ 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<void> get done => _ddsInstance.exitCode;
+
+ /// Shutdown the DDS instance.
+ Future<void> 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 376855e..69dcfd4 100644
--- a/pkg/dds/lib/src/dds_impl.dart
+++ b/pkg/dds/lib/src/dds_impl.dart
@@ -347,8 +347,8 @@
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 @@
return null;
}
final pathSegments = _cleanupPathSegments(uri);
- pathSegments.add(_kSseHandlerPath);
+ pathSegments.add(kSseHandlerPath);
return uri.replace(scheme: 'sse', pathSegments: pathSegments);
}
@@ -552,7 +552,7 @@
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 0000000..80fc54b
--- /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<String, dynamic> jsonResponse = (await response
+ .transform(utf8.decoder)
+ .transform(json.decoder)
+ .single) as Map<String, dynamic>;
+ expect(jsonResponse['result']['type'], 'Version');
+ expect(jsonResponse['result']['major'] > 0, true);
+ expect(jsonResponse['result']['minor'] >= 0, true);
+ },
+ );
+ }
+
+ createSmokeTest(true);
+ createSmokeTest(false);
+ });
+}