// Copyright 2014 The Flutter Authors. 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:math' as math; import 'package:dds/dap.dart' hide PidTracker; import 'package:vm_service/vm_service.dart' as vm; import '../base/io.dart'; import '../cache.dart'; import '../convert.dart'; import '../globals.dart' as globals show fs; import 'error_formatter.dart'; import 'flutter_adapter_args.dart'; import 'flutter_base_adapter.dart'; /// A DAP Debug Adapter for running and debugging Flutter applications. class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFileUtils { FlutterDebugAdapter( super.channel, { required super.fileSystem, required super.platform, super.ipv6, super.enableFlutterDds = true, super.enableAuthCodes, super.logger, super.onError, }); /// A completer that completes when the app.started event has been received. final Completer<void> _appStartedCompleter = Completer<void>(); /// Whether or not the app.started event has been received. bool get _receivedAppStarted => _appStartedCompleter.isCompleted; /// The appId of the current running Flutter app. String? _appId; /// A progress reporter for the applications launch progress. /// /// `null` if a launch is not in progress (or has completed). DapProgressReporter? launchProgress; /// The ID to use for the next request sent to the Flutter run daemon. int _flutterRequestId = 1; /// Outstanding requests that have been sent to the Flutter run daemon and /// their handlers. final Map<int, Completer<Object?>> _flutterRequestCompleters = <int, Completer<Object?>>{}; /// A list of reverse-requests from `flutter run --machine` that should be forwarded to the client. static const Set<String> _requestsToForwardToClient = <String>{ // The 'app.exposeUrl' request is sent by Flutter to request the client // exposes a URL to the user and return the public version of that URL. // // This supports some web scenarios where the `flutter` tool may be running // on a different machine to the user (for example a cloud IDE or in VS Code // remote workspace) so we cannot just use the raw URL because the hostname // and/or port might not be available to the machine the user is using. // Instead, the IDE/infrastructure can set up port forwarding/proxying and // return a user-facing URL that will map to the original (localhost) URL // Flutter provided. 'app.exposeUrl', }; /// A list of events from `flutter run --machine` that should be forwarded to the client. static const Set<String> _eventsToForwardToClient = <String>{ // The 'app.webLaunchUrl' event is sent to the client to tell it about a URL // that should be launched (including a flag for whether it has been // launched by the tool or needs launching by the editor). 'app.webLaunchUrl', }; /// Completers for reverse requests from Flutter that may need to be handled by the client. final Map<Object, Completer<Object?>> _reverseRequestCompleters = <Object, Completer<Object?>>{}; /// Whether or not the user requested debugging be enabled. /// /// For debugging to be enabled, the user must have chosen "Debug" (and not /// "Run") in the editor (which maps to the DAP `noDebug` field) _and_ must /// not have requested to run in Profile or Release mode. Profile/Release /// modes will always disable debugging. /// /// This is always `true` for attach requests. /// /// When not debugging, we will not connect to the VM Service so some /// functionality (breakpoints, evaluation, etc.) will not be available. /// Functionality provided via the daemon (hot reload/restart) will still be /// available. @override bool get enableDebugger => super.enableDebugger && !profileMode && !releaseMode; /// Whether the launch configuration arguments specify `--profile`. /// /// Always `false` for attach requests. bool get profileMode { final DartCommonLaunchAttachRequestArguments args = this.args; if (args is FlutterLaunchRequestArguments) { return args.toolArgs?.contains('--profile') ?? false; } // Otherwise (attach), always false. return false; } /// Whether the launch configuration arguments specify `--release`. /// /// Always `false` for attach requests. bool get releaseMode { final DartCommonLaunchAttachRequestArguments args = this.args; if (args is FlutterLaunchRequestArguments) { return args.toolArgs?.contains('--release') ?? false; } // Otherwise (attach), always false. return false; } /// Called by [attachRequest] to request that we actually connect to the app to be debugged. @override Future<void> attachImpl() async { final FlutterAttachRequestArguments args = this.args as FlutterAttachRequestArguments; String? vmServiceUri = args.vmServiceUri; final String? vmServiceInfoFile = args.vmServiceInfoFile; if (vmServiceUri != null && vmServiceInfoFile != null) { sendConsoleOutput( 'To attach, provide only one (or neither) of vmServiceUri/vmServiceInfoFile', ); handleSessionTerminate(); return; } launchProgress = startProgressNotification( 'launch', 'Flutter', message: 'Attaching…', ); if (vmServiceUri == null && vmServiceInfoFile != null) { final Uri uriFromFile = await waitForVmServiceInfoFile(logger, globals.fs.file(vmServiceInfoFile)); vmServiceUri = uriFromFile.toString(); } final List<String> toolArgs = <String>[ 'attach', '--machine', if (!enableFlutterDds) '--no-dds', if (vmServiceUri != null) ...<String>['--debug-uri', vmServiceUri], ]; await _startProcess( toolArgs: toolArgs, customTool: args.customTool, customToolReplacesArgs: args.customToolReplacesArgs, userToolArgs: args.toolArgs, targetProgram: args.program, ); } /// [customRequest] handles any messages that do not match standard messages in the spec. /// /// This is used to allow a client/DA to have custom methods outside of the /// spec. It is up to the client/DA to negotiate which custom messages are /// allowed. /// /// [sendResponse] must be called when handling a message, even if it is with /// a null response. Otherwise the client will never be informed that the /// request has completed. /// /// Any requests not handled must call super which will respond with an error /// that the message was not supported. /// /// Unless they start with _ to indicate they are private, custom messages /// should not change in breaking ways if client IDEs/editors may be calling /// them. @override Future<void> customRequest( Request request, RawRequestArguments? args, void Function(Object?) sendResponse, ) async { switch (request.command) { case 'hotRestart': case 'hotReload': // This convention is for the internal IDE client. case r'$/hotReload': final bool isFullRestart = request.command == 'hotRestart'; await _performRestart(isFullRestart, args?.args['reason'] as String?); sendResponse(null); // Handle requests (from the client) that provide responses to reverse-requests // that we forwarded from `flutter run --machine`. case 'flutter.sendForwardedRequestResponse': _handleForwardedResponse(args); sendResponse(null); default: await super.customRequest(request, args, sendResponse); } } @override Future<void> handleExtensionEvent(vm.Event event) async { await super.handleExtensionEvent(event); switch (event.kind) { case vm.EventKind.kExtension: switch (event.extensionKind) { case 'Flutter.ServiceExtensionStateChanged': _sendServiceExtensionStateChanged(event.extensionData); case 'Flutter.Error': _handleFlutterErrorEvent(event.extensionData); } } } /// Sends OutputEvents to the client for a Flutter.Error event. void _handleFlutterErrorEvent(vm.ExtensionData? data) { final Map<String, Object?>? errorData = data?.data; if (errorData == null) { return; } FlutterErrorFormatter() ..formatError(errorData) ..sendOutput(sendOutput); } /// Called by [launchRequest] to request that we actually start the app to be run/debugged. /// /// For debugging, this should start paused, connect to the VM Service, set /// breakpoints, and resume. @override Future<void> launchImpl() async { final FlutterLaunchRequestArguments args = this.args as FlutterLaunchRequestArguments; launchProgress = startProgressNotification( 'launch', 'Flutter', message: 'Launching…', ); final List<String> toolArgs = <String>[ 'run', '--machine', if (!enableFlutterDds) '--no-dds', if (enableDebugger) '--start-paused', // Structured errors are enabled by default, but since we don't connect // the VM Service for noDebug, we need to disable them so that error text // is sent to stderr. Otherwise the user will not see any exception text // (because nobody is listening for Flutter.Error events). if (!enableDebugger) '--dart-define=flutter.inspector.structuredErrors=false', ]; await _startProcess( toolArgs: toolArgs, customTool: args.customTool, customToolReplacesArgs: args.customToolReplacesArgs, targetProgram: args.program, userToolArgs: args.toolArgs, userArgs: args.args, ); } /// Starts the `flutter` process to run/attach to the required app. Future<void> _startProcess({ required String? customTool, required int? customToolReplacesArgs, required List<String> toolArgs, required List<String>? userToolArgs, String? targetProgram, List<String>? userArgs, }) async { // Handle customTool and deletion of any arguments for it. final String executable = customTool ?? fileSystem.path.join(Cache.flutterRoot!, 'bin', platform.isWindows ? 'flutter.bat' : 'flutter'); final int? removeArgs = customToolReplacesArgs; if (customTool != null && removeArgs != null) { toolArgs.removeRange(0, math.min(removeArgs, toolArgs.length)); } final List<String> processArgs = <String>[ ...toolArgs, ...?userToolArgs, if (targetProgram != null) ...<String>[ '--target', targetProgram, ], ...?userArgs, ]; await launchAsProcess( executable: executable, processArgs: processArgs, env: args.env, ); } /// restart is called by the client when the user invokes a restart (for example with the button on the debug toolbar). /// /// For Flutter, we handle this ourselves be sending a Hot Restart request /// to the running app. @override Future<void> restartRequest( Request request, RestartArguments? args, void Function() sendResponse, ) async { await _performRestart(true); sendResponse(); } /// Sends a request to the Flutter run daemon that is running/attaching to the app and waits for a response. /// /// If there is no process, the message will be silently ignored (this is /// common during the application being stopped, where async messages may be /// processed). Future<Object?> sendFlutterRequest( String method, Map<String, Object?>? params, ) async { final Completer<Object?> completer = Completer<Object?>(); final int id = _flutterRequestId++; _flutterRequestCompleters[id] = completer; sendFlutterMessage(<String, Object?>{ 'id': id, 'method': method, 'params': params, }); return completer.future; } /// Sends a message to the Flutter run daemon. /// /// Throws `DebugAdapterException` if a Flutter process is not yet running. void sendFlutterMessage(Map<String, Object?> message) { final Process? process = this.process; if (process == null) { throw DebugAdapterException('Flutter process has not yet started'); } final String messageString = jsonEncode(message); // Flutter requests are always wrapped in brackets as an array. final String payload = '[$messageString]\n'; _logTraffic('==> [Flutter] $payload'); process.stdin.writeln(payload); } /// Called by [terminateRequest] to request that we gracefully shut down the app being run (or in the case of an attach, disconnect). @override Future<void> terminateImpl() async { if (isAttach) { await handleDetach(); } // Send a request to stop/detach to give Flutter chance to do some cleanup. // It's possible the Flutter process will terminate before we process the // response, so accept either a response or the process exiting. if (_appId != null) { final String method = isAttach ? 'app.detach' : 'app.stop'; await Future.any<void>(<Future<void>>[ sendFlutterRequest(method, <String, Object?>{'appId': _appId}), process?.exitCode ?? Future<void>.value(), ]); } terminatePids(ProcessSignal.sigterm); await process?.exitCode; } /// Connects to the VM Service if the app.started event has fired, and a VM Service URI is available. Future<void> _connectDebugger(Uri vmServiceUri) async { if (enableDebugger) { await connectDebugger(vmServiceUri); } else { // Usually, `connectDebugger` (in the base Dart adapter) will send this // event when it connects a debugger. Since we're not connecting a // debugger we send this ourselves, to allow clients to connect to the // VM Service for things like starting DevTools, even if debugging is // not available. // TODO(dantup): Switch this to call `sendDebuggerUris()` on the base // adapter once rolled into Flutter. sendEvent( RawEventBody(<String, Object?>{ 'vmServiceUri': vmServiceUri.toString(), }), eventType: 'dart.debuggerUris', ); } } /// Handles the app.start event from Flutter. void _handleAppStart(Map<String, Object?> params) { _appId = params['appId'] as String?; if (_appId == null) { throw DebugAdapterException('Unexpected null `appId` in app.start event'); } // Notify the client whether it can call 'restartRequest' when the user // clicks restart, instead of terminating and re-starting its own debug // session (which is much slower, but required for profile/release mode). final bool supportsRestart = (params['supportsRestart'] as bool?) ?? false; sendEvent(CapabilitiesEventBody(capabilities: Capabilities(supportsRestartRequest: supportsRestart))); // Send a custom event so the editor has info about the app starting. // // This message contains things like the `deviceId` and `mode` that the // client might not know about if they were inferred or set by users custom // args. sendEvent( RawEventBody(params), eventType: 'flutter.appStart', ); } /// Handles the app.started event from Flutter. Future<void> _handleAppStarted() async { launchProgress?.end(); launchProgress = null; _appStartedCompleter.complete(); // Send a custom event so the editor knows the app has started. // // This may be useful when there's no VM Service (for example Profile mode) // but the editor still wants to know that startup has finished. if (enableDebugger) { await debuggerInitialized; // Ensure we're fully initialized before sending. } sendEvent( RawEventBody(<String, Object?>{}), eventType: 'flutter.appStarted', ); } /// Handles the daemon.connected event, recording the pid of the flutter_tools process. void _handleDaemonConnected(Map<String, Object?> params) { // On Windows, the pid from the process we spawn is the shell running // flutter.bat and terminating it may not be reliable, so we also take the // pid provided from the VM running flutter_tools. final int? pid = params['pid'] as int?; if (pid != null) { pidsToTerminate.add(pid); } } /// Handles the app.debugPort event from Flutter, connecting to the VM Service if everything else is ready. Future<void> _handleDebugPort(Map<String, Object?> params) async { // Capture the VM Service URL which we'll connect to when we get app.started. final String? wsUri = params['wsUri'] as String?; if (wsUri != null) { final Uri vmServiceUri = Uri.parse(wsUri); // Also wait for app.started before we connect, to ensure Flutter's // initialization is all complete. await _appStartedCompleter.future; await _connectDebugger(vmServiceUri); } } /// Handles the Flutter process exiting, terminating the debug session if it has not already begun terminating. @override void handleExitCode(int code) { final String codeSuffix = code == 0 ? '' : ' ($code)'; _logTraffic('<== [Flutter] Process exited ($code)'); handleSessionTerminate(codeSuffix); } /// Handles incoming JSON events from `flutter run --machine`. void handleJsonEvent(String event, Map<String, Object?>? params) { params ??= <String, Object?>{}; switch (event) { case 'daemon.connected': _handleDaemonConnected(params); case 'app.debugPort': _handleDebugPort(params); case 'app.start': _handleAppStart(params); case 'app.started': _handleAppStarted(); } if (_eventsToForwardToClient.contains(event)) { // Forward the event to the client. sendEvent( RawEventBody(<String, Object?>{ 'event': event, 'params': params, }), eventType: 'flutter.forwardedEvent', ); } } /// Handles incoming reverse requests from `flutter run --machine`. /// /// These requests are usually just forwarded to the client via an event /// (`flutter.forwardedRequest`) and responses are provided by the client in a /// custom event (`flutter.forwardedRequestResponse`). void _handleJsonRequest( Object id, String method, Map<String, Object?>? params, ) { /// A helper to send a client response to Flutter. void sendResponseToFlutter(Object? id, Object? value, { bool error = false }) { sendFlutterMessage(<String, Object?>{ 'id': id, if (error) 'error': value else 'result': value }); } // Set up a completer to forward the response back to `flutter` when it arrives. final Completer<Object?> completer = Completer<Object?>(); _reverseRequestCompleters[id] = completer; completer.future .then( (Object? value) => sendResponseToFlutter(id, value), onError: (Object? e) => sendResponseToFlutter(id, e.toString(), error: true), ); if (_requestsToForwardToClient.contains(method)) { // Forward the request to the client in an event. sendEvent( RawEventBody(<String, Object?>{ 'id': id, 'method': method, 'params': params, }), eventType: 'flutter.forwardedRequest', ); } else { completer.completeError(ArgumentError.value(method, 'Unknown request method.')); } } /// Handles client responses to reverse-requests that were forwarded from Flutter. void _handleForwardedResponse(RawRequestArguments? args) { final Object? id = args?.args['id']; final Object? result = args?.args['result']; final Object? error = args?.args['error']; final Completer<Object?>? completer = _reverseRequestCompleters[id]; if (error != null) { completer?.completeError(DebugAdapterException('Client reported an error handling reverse-request $error')); } else { completer?.complete(result); } } /// Handles incoming JSON messages from `flutter run --machine` that are responses to requests that we sent. void _handleJsonResponse(int id, Map<String, Object?> response) { final Completer<Object?>? handler = _flutterRequestCompleters.remove(id); if (handler == null) { logger?.call( 'Received response from Flutter run daemon with ID $id ' 'but had not matching handler', ); return; } final Object? error = response['error']; final Object? result = response['result']; if (error != null) { handler.completeError(DebugAdapterException('$error')); } else { handler.complete(result); } } @override void handleStderr(List<int> data) { _logTraffic('<== [Flutter] [stderr] $data'); sendOutput('stderr', utf8.decode(data)); } /// Handles stdout from the `flutter run --machine` process, decoding the JSON and calling the appropriate handlers. @override void handleStdout(String data) { // Output intended for us to parse is JSON wrapped in brackets: // [{"event":"app.foo","params":{"bar":"baz"}}] // However, it's also possible a user printed things that look a little like // this so try to detect only things we're interested in: // - parses as JSON // - is a List of only a single item that is a Map<String, Object?> // - the item has an "event" field that is a String // - the item has a "params" field that is a Map<String, Object?>? _logTraffic('<== [Flutter] $data'); // Output is sent as console (eg. output from tooling) until the app has // started, then stdout (users output). This is so info like // "Launching lib/main.dart on Device foo" is formatted differently to // general output printed by the user. final String outputCategory = _receivedAppStarted ? 'stdout' : 'console'; // Output in stdout can include both user output (eg. print) and Flutter // daemon output. Since it's not uncommon for users to print JSON while // debugging, we must try to detect which messages are likely Flutter // messages as reliably as possible, as trying to process users output // as a Flutter message may result in an unhandled error that will // terminate the debug adapter in a way that does not provide feedback // because the standard crash violates the DAP protocol. Object? jsonData; try { jsonData = jsonDecode(data); } on FormatException { // If the output wasn't valid JSON, it was standard stdout that should // be passed through to the user. sendOutput(outputCategory, data); // Detect if the output contains a prompt about using the Dart Debug // extension and also update the progress notification to make it clearer // we're waiting for the user to do something. if (data.contains('Waiting for connection from Dart debug extension')) { launchProgress?.update( message: 'Please click the Dart Debug extension button in the spawned browser window', ); } return; } final Map<String, Object?>? payload = jsonData is List && jsonData.length == 1 && jsonData.first is Map<String, Object?> ? jsonData.first as Map<String, Object?> : null; if (payload == null) { // JSON didn't match expected format for Flutter responses, so treat as // standard user output. sendOutput(outputCategory, data); return; } final Object? event = payload['event']; final Object? method = payload['method']; final Object? params = payload['params']; final Object? id = payload['id']; if (event is String && params is Map<String, Object?>?) { handleJsonEvent(event, params); } else if (id != null && method is String && params is Map<String, Object?>?) { _handleJsonRequest(id, method, params); } else if (id is int && _flutterRequestCompleters.containsKey(id)) { _handleJsonResponse(id, payload); } else { // If it wasn't processed above, sendOutput(outputCategory, data); } } /// Logs JSON traffic to aid debugging. /// /// If `sendLogsToClient` was `true` in the launch/attach config, logs will /// also be sent back to the client in a "dart.log" event to simplify /// capturing logs from the IDE (such as using the **Dart: Capture Logs** /// command in VS Code). void _logTraffic(String message) { logger?.call(message); if (sendLogsToClient) { sendEvent( RawEventBody(<String, String>{'message': message}), eventType: 'dart.log', ); } } /// Performs a restart/reload by sending the `app.restart` message to the `flutter run --machine` process. Future<void> _performRestart( bool fullRestart, [ String? reason, ]) async { // Don't do anything if the app hasn't started yet, as restarts and reloads // can only operate on a running app. if (_appId == null) { return; } final String progressId = fullRestart ? 'hotRestart' : 'hotReload'; final String progressMessage = fullRestart ? 'Hot restarting…' : 'Hot reloading…'; final DapProgressReporter progress = startProgressNotification( progressId, 'Flutter', message: progressMessage, ); try { await sendFlutterRequest('app.restart', <String, Object?>{ 'appId': _appId, 'fullRestart': fullRestart, 'pause': enableDebugger, 'reason': reason, 'debounce': true, }); } on DebugAdapterException catch (error) { final String action = fullRestart ? 'Hot Restart' : 'Hot Reload'; sendOutput('console', 'Failed to $action: $error'); } finally { progress.end(); } } void _sendServiceExtensionStateChanged(vm.ExtensionData? extensionData) { final Map<String, dynamic>? data = extensionData?.data; if (data != null) { sendEvent( RawEventBody(data), eventType: 'flutter.serviceExtensionStateChanged', ); } } }