| // 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; |
| } |