blob: 6ca9a423d38a2cbeb5be59a658db37884eb406f2 [file] [edit]
// Copyright (c) 2023, 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 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:dart_data_home/dart_data_home.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:sse/server/sse_handler.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'constants.dart';
import 'dtd_client.dart';
import 'dtd_client_manager.dart';
import 'dtd_connection_info.dart';
import 'dtd_stream_manager.dart';
import 'service/connected_app_service.dart';
import 'service/file_system_service.dart';
import 'service/internal_service.dart';
import 'service/unified_analytics_service.dart';
/// The default value for the --ping-interval option.
///
/// This value was initially 15 seconds so that all DTD clients had the ping
/// enabled to avoid issues like Norton Antivirus dropping proxied connections
/// if there was no traffic for 30s (see
/// https://github.com/Dart-Code/Dart-Code/issues/5794).
///
/// However, IntelliJ's WebSocket client turns out to not handle pings (see
/// https://github.com/flutter/dart-intellij-third-party/issues/205) and this
/// resulted in dropped connections. Since we can't change already-shipped IJ
/// plugins we now default to no pings (because breaking IJ for all users is
/// worse than breaking users on non-VSCode/IJ clients that happen to have
/// something like Norton).
///
/// We should consider changing this back to 15s once IJ has been fixed and that
/// fix is in all versions of the IJ plugins we support/care about.
const _pingIntervalSecondsDefault = 0;
/// The directory name within the user's Dart data home directory where the
/// Dart Tooling Daemon stores its connection info (pid-files).
const String dtdDirName = 'dtd';
/// The message printed when no running Dart Tooling Daemon instances are found
/// after a user queries the list of running DTD processes with the `--list`
/// argument.
const String noInstancesMessage =
'No running debug processes or DTD process were found, try '
'starting up your app from your IDE, `flutter run`, `dart run`, '
'or connecting to it with `flutter attach`.';
/// Contains all the flags and options used by the DTD argument parser.
enum DartToolingDaemonOptions {
// Used when executing a training run while generating an AppJIT snapshot as
// part of an SDK build.
train.flag('train', negatable: false, hide: true),
machine.flag(
'machine',
negatable: false,
help: 'Sets output format to JSON for consumption in tools.',
),
port.option(
'port',
defaultsTo: '0',
help: 'Sets the port to bind DTD to (0 for automatic port).',
),
unrestricted.flag(
'unrestricted',
negatable: false,
help: 'Disables restrictions on services registered by DTD.',
),
disableServiceAuthCodes.flag(
'disable-service-auth-codes',
negatable: false,
// This text mirrors what's in dartdev/commands/run for VM Service.
help:
'Disables the requirement for an authentication code to '
'communicate with DTD. Authentication codes help '
'protect against CSRF attacks, so it is not recommended to '
'disable them unless behind a firewall on a secure device.',
verbose: true,
),
fakeAnalytics.flag(
'fakeAnalytics',
negatable: false,
help: 'Uses fake analytics instances for the UnifiedAnalytics service.',
hide: true,
),
list.flag(
'list',
negatable: false,
help: 'Lists all running Dart Tooling Daemon instances on this machine.',
),
pingInterval.option(
'ping-interval',
defaultsTo: '$_pingIntervalSecondsDefault',
help:
'Sets the WebSocket ping interval in seconds (0 to disable). '
'Enabling ping helps avoid connections being dropped by some proxies/'
'antivirus products if a connection has no traffic for some period.',
);
const DartToolingDaemonOptions.flag(
this.name, {
this.negatable = true,
this.verbose = false,
this.hide = false,
this.help,
}) : _kind = _DartToolingDaemonOptionKind.flag,
defaultsTo = null;
const DartToolingDaemonOptions.option(this.name, {this.defaultsTo, this.help})
: _kind = _DartToolingDaemonOptionKind.option,
negatable = false,
verbose = false,
hide = false;
final String name;
final _DartToolingDaemonOptionKind _kind;
final String? defaultsTo;
final bool negatable;
final bool hide;
final String? help;
/// Show in help only when --verbose passed.
final bool verbose;
/// Populates an argument parser that can be used to configure the daemon.
static void populateArgOptions(ArgParser argParser, {bool verbose = false}) {
for (final entry in DartToolingDaemonOptions.values) {
final hide = entry.hide || (entry.verbose && !verbose);
switch (entry._kind) {
case _DartToolingDaemonOptionKind.flag:
argParser.addFlag(
entry.name,
negatable: entry.negatable,
hide: hide,
help: entry.help,
);
case _DartToolingDaemonOptionKind.option:
argParser.addOption(
entry.name,
hide: hide,
help: entry.help,
defaultsTo: entry.defaultsTo,
);
}
}
}
}
/// The kind of command line argument.
enum _DartToolingDaemonOptionKind { flag, option }
/// TODO(https://github.com/dart-lang/sdk/issues/54429): Add shutdown behavior.
/// A service that facilitates communication between dart tools.
class DartToolingDaemon {
/// Used to override the environment variables for `dart_data_home` during
/// tests.
@visibleForTesting
static Map<String, String>? environmentOverride;
DartToolingDaemon._({
required this.secret,
required bool unrestrictedMode,
bool disableServiceAuthCodes = false,
this._ipv6 = false,
this._shouldLogRequests = false,
bool useFakeAnalytics = false,
this.pingInterval,
}) : _uriAuthCode = disableServiceAuthCodes ? null : _generateSecret() {
streamManager = DTDStreamManager(this);
clientManager = DTDClientManager();
internalServices = Map.fromEntries(
[
ConnectedAppService(secret: secret, unrestrictedMode: unrestrictedMode),
FileSystemService(secret: secret, unrestrictedMode: unrestrictedMode),
UnifiedAnalyticsService(fake: useFakeAnalytics),
].map((service) => MapEntry(service.serviceName, service)),
);
}
static const _kSseHandlerPath = '\$debugHandler';
/// The ping interval to be set on any WebSocket connections.
///
/// Having ping enabled can prevent proxies (including antivirus) from
/// dropping connections to DTD because they appear idle.
final Duration? pingInterval;
/// Manages the streams for the current [DartToolingDaemon] service.
late final DTDStreamManager streamManager;
/// Manages the connected clients of the current [DartToolingDaemon] service.
late final DTDClientManager clientManager;
final bool _ipv6;
late HttpServer _server;
final bool _shouldLogRequests;
/// A map of internal DTD services, keyed by the service name.
late final Map<String, InternalService> internalServices;
final String secret;
/// If non-null, any requests to DTD must have this code as the first element
/// of the uri path.
///
/// This provides an obfuscation step to prevent bad actors from stumbling
/// onto the dtd address.
final String? _uriAuthCode;
/// The uri of the current [DartToolingDaemon] service.
Uri? get uri => _uri;
Uri? _uri;
/// The path to the file containing this daemon's connection info, if started.
String? _pidFilePath;
Future<void> _startService({required int port}) async {
final host =
(_ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4)
.host;
// Start the DTD server. Run in an error Zone to ensure that asynchronous
// exceptions encountered during request handling are handled, as exceptions
// thrown during request handling shouldn't take down the entire service.
late String errorMessage;
final tmpServer = await runZonedGuarded(
() async {
Future<HttpServer?> startServer() async {
try {
return await io.serve(_handlers(), host, port);
} on SocketException catch (e) {
errorMessage = e.message;
if (e.osError != null) {
errorMessage += ' (${e.osError!.message})';
}
errorMessage += ': ${e.address?.host}:${e.port}';
return null;
}
}
return await startServer();
},
(error, stack) {
if (_shouldLogRequests) {
print('Asynchronous error: $error\n$stack');
}
},
);
if (tmpServer == null) {
throw DartToolingDaemonException.connectionIssue(errorMessage);
}
_server = tmpServer;
_uri = Uri(
scheme: 'ws',
host: host,
port: _server.port,
path: _uriAuthCode != null ? '/$_uriAuthCode' : '',
);
}
/// Starts a [DartToolingDaemon] service.
///
/// Set [ipv6] to true to have the service use ipv6 instead of ipv4.
///
/// Set [shouldLogRequests] to true to enable logging.
///
/// When [sendPort] is non-null, information about the DTD connection will be
/// sent over [sendPort] instead of being printed to stdout.
static Future<DartToolingDaemon?> startService(
List<String> args, {
bool ipv6 = false,
bool shouldLogRequests = false,
SendPort? sendPort,
}) async {
final argParser = ArgParser();
DartToolingDaemonOptions.populateArgOptions(argParser);
final parsedArgs = argParser.parse(args);
if (parsedArgs.wasParsed(DartToolingDaemonOptions.train.name)) {
return null;
}
final machineMode =
parsedArgs[DartToolingDaemonOptions.machine.name] as bool;
final listMode = parsedArgs[DartToolingDaemonOptions.list.name] as bool;
if (listMode) {
_listRunningDtdInstances(machineMode: machineMode);
return null;
}
final unrestrictedMode =
parsedArgs[DartToolingDaemonOptions.unrestricted.name] as bool;
final disableServiceAuthCodes =
parsedArgs[DartToolingDaemonOptions.disableServiceAuthCodes.name]
as bool;
final useFakeAnalytics =
parsedArgs[DartToolingDaemonOptions.fakeAnalytics.name] as bool;
final port =
int.tryParse(
parsedArgs[DartToolingDaemonOptions.port.name] as String? ?? '',
) ??
0;
final pingIntervalSeconds =
int.tryParse(
parsedArgs[DartToolingDaemonOptions.pingInterval.name] as String? ??
'',
) ??
_pingIntervalSecondsDefault;
final pingInterval = pingIntervalSeconds == 0
? null
: Duration(seconds: pingIntervalSeconds);
final secret = _generateSecret();
final dtd = DartToolingDaemon._(
secret: secret,
unrestrictedMode: unrestrictedMode,
disableServiceAuthCodes: disableServiceAuthCodes,
ipv6: ipv6,
shouldLogRequests: shouldLogRequests,
useFakeAnalytics: useFakeAnalytics,
pingInterval: pingInterval,
);
await dtd._startService(port: port);
dtd._recordDtdConnectionInfo();
if (machineMode) {
final encoded = jsonEncode({
'tooling_daemon_details': {
'uri': dtd.uri.toString(),
...(!unrestrictedMode
? {'trusted_client_secret': secret}
: <String, Object?>{}),
},
});
if (sendPort == null) {
print(encoded);
} else {
sendPort.send(encoded);
}
} else {
print(
'The Dart Tooling Daemon is listening on '
'${dtd.uri.toString()}',
);
if (!unrestrictedMode) {
print('Trusted Client Secret: $secret');
}
}
return dtd;
}
// Attempt to upgrade HTTP requests to a websocket before processing them as
// standard HTTP requests. The websocket handler will fail quickly if the
// request doesn't appear to be a websocket upgrade request.
Handler _handlers() {
return const Pipeline()
.addMiddleware(_uriTokenHandler)
.addHandler(
Cascade().add(_webSocketHandler()).add(_sseHandler()).handler,
);
}
Handler _uriTokenHandler(Handler innerHandler) => (Request request) {
if (_uriAuthCode != null) {
final forbidden = Response.forbidden(
'missing or invalid authentication code',
);
final pathSegments = request.url.pathSegments;
if (pathSegments.isEmpty) {
return forbidden;
}
final clientProvidedCode = pathSegments[0];
if (clientProvidedCode != _uriAuthCode) {
return forbidden;
}
}
return innerHandler(request);
};
// Note: the WebSocketChannel type below is needed for compatibility with
// package:shelf_web_socket v2.
Handler _webSocketHandler() => webSocketHandler((WebSocketChannel ws, _) {
final client = DTDClient.fromWebSocket(this, ws);
_registerInternalServiceMethods(client);
clientManager.addClient(client);
}, pingInterval: pingInterval);
Handler _sseHandler() {
final handler = SseHandler(
Uri.parse('/$_kSseHandlerPath'),
keepAlive: sseKeepAlive,
);
handler.connections.rest.listen((sseConnection) {
final client = DTDClient.fromSSEConnection(this, sseConnection);
_registerInternalServiceMethods(client);
clientManager.addClient(client);
});
return handler.handler;
}
void _registerInternalServiceMethods(DTDClient client) {
for (final service in internalServices.values) {
service.register(client);
}
}
static String _generateSecret() {
final kTokenByteSize = 8;
var bytes = Uint8List(kTokenByteSize);
// Use a secure random number generator.
var rand = Random.secure();
for (var i = 0; i < kTokenByteSize; i++) {
bytes[i] = rand.nextInt(256);
}
return base64Url.encode(bytes);
}
Future<void> close() async {
// Delete the connection info file on graceful shutdown.
if (_pidFilePath != null) {
try {
final file = File(_pidFilePath!);
if (file.existsSync()) {
file.deleteSync();
}
} catch (_) {
// Ignore errors during file deletion.
}
}
await clientManager.shutdown();
for (final service in internalServices.values) {
service.shutdown();
}
await _server.close(force: true);
}
static String? _getDtdDataHome() {
try {
final dir = getDartDataHome(dtdDirName, environment: environmentOverride);
// createSync(recursive: true) is a no-op if the directory already exists.
Directory(dir).createSync(recursive: true);
return dir;
} catch (_) {
// Ignore errors when creating local data home directory.
return null;
}
}
/// Writes out DTD connection information to a file named with the DTD process
/// PID in the [dtdDirName] directory within the Dart data home directory.
void _recordDtdConnectionInfo() {
final dataHome = _getDtdDataHome();
if (dataHome == null) return;
try {
final info = DTDConnectionInfo(
wsUri: uri.toString(),
epoch: DateTime.now().millisecondsSinceEpoch,
pid: pid,
dartVersion: Platform.version,
workspaceRoot: Directory.current.path,
ideName: Platform.environment['DASH__IDE_NAME'],
);
if (createPidFile(dataHome, jsonEncode(info.toJson()))) {
_pidFilePath = p.join(dataHome, pid.toString());
}
} catch (_) {
// Ignore errors when writing the pid file.
}
}
/// Lists all running DTD instances on the local system.
static void _listRunningDtdInstances({bool machineMode = false}) {
final dataHome = _getDtdDataHome();
if (dataHome == null) return;
final instances = <DTDConnectionInfo>[];
final pidFiles = listPidFiles(dataHome);
for (final value in pidFiles.values) {
try {
final json = jsonDecode(value) as Map<String, Object?>;
instances.add(DTDConnectionInfo.fromJson(json));
} catch (_) {
// ignore
}
}
// Sort instances by epoch time descending so the most recently
// launched processes are listed first.
instances.sort((a, b) => b.epoch.compareTo(a.epoch));
if (machineMode) {
print(jsonEncode(instances.map((e) => e.toJson()).toList()));
return;
}
if (instances.isEmpty) {
print(noInstancesMessage);
} else {
print('Found ${instances.length} Dart Tooling Daemon instance(s):');
for (final info in instances) {
print(info);
}
}
}
}
// TODO(danchevalier): clean up these exceptions so they are more relevant to
// DTD. Also add docs to the factories that remain.
class DartToolingDaemonException implements Exception {
// TODO(danchevalier): add a relevant dart doc here
static const int existingDtdInstanceError = 1;
/// Set when the connection to the remote VM service terminates unexpectedly
/// during Dart Development Service startup.
static const int failedToStartError = 2;
/// Set when a connection error has occurred after startup.
static const int connectionError = 3;
factory DartToolingDaemonException.existingDtdInstance(
String message, {
Uri? dtdUri,
}) {
return ExistingDTDImplException._(message, dtdUri: dtdUri);
}
factory DartToolingDaemonException.failedToStart() {
return DartToolingDaemonException._(
failedToStartError,
'Failed to start Dart Development Service',
);
}
factory DartToolingDaemonException.connectionIssue(String message) {
return DartToolingDaemonException._(connectionError, message);
}
DartToolingDaemonException._(this.errorCode, this.message);
@override
String toString() => 'DartDevelopmentServiceException: $message';
final int errorCode;
final String message;
}
class ExistingDTDImplException extends DartToolingDaemonException {
ExistingDTDImplException._(String message, {this.dtdUri})
: super._(DartToolingDaemonException.existingDtdInstanceError, message);
/// The URI of the existing DTD instance, if available.
///
/// This URL is the base HTTP URI such as `http://127.0.0.1:1234/AbcDefg=/`,
/// not the WebSocket URI (which can be obtained by mapping the scheme to
/// `ws` (or `wss`) and appending `ws` to the path segments).
final Uri? dtdUri;
}