[ 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>
This commit is contained in:
Ben Konyi
2024-08-01 14:29:59 +00:00
committed by Commit Queue
parent d47288f3c0
commit 7eef09cd6e
5 changed files with 308 additions and 4 deletions
+1
View File
@@ -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`.
+33
View File
@@ -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<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 @@ 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._(
+198
View File
@@ -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;
}
}
+4 -4
View File
@@ -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;
+72
View File
@@ -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);
});
}