| // 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:collection'; |
| |
| import 'package:analysis_server/lsp_protocol/protocol.dart' as lsp; |
| import 'package:analysis_server/protocol/protocol.dart' as legacy; |
| import 'package:analysis_server/protocol/protocol_constants.dart' as legacy; |
| import 'package:analysis_server/protocol/protocol_generated.dart' as legacy; |
| import 'package:analysis_server/src/analysis_server.dart'; |
| import 'package:analysis_server/src/legacy_analysis_server.dart'; |
| import 'package:analysis_server/src/lsp/constants.dart'; |
| import 'package:analysis_server/src/lsp/lsp_analysis_server.dart'; |
| import 'package:analysis_server/src/server/error_notifier.dart'; |
| import 'package:analyzer/src/util/performance/operation_performance.dart'; |
| import 'package:analyzer/src/utilities/cancellation.dart'; |
| import 'package:language_server_protocol/json_parsing.dart'; |
| import 'package:language_server_protocol/protocol_custom_generated.dart'; |
| |
| /// Represents a message from DTD (Dart Tooling Daemon). |
| final class DtdMessage extends MessageObject { |
| final lsp.IncomingMessage message; |
| final Completer<Map<String, Object?>> completer; |
| final OperationPerformanceImpl performance; |
| |
| DtdMessage({ |
| required this.message, |
| required this.completer, |
| required this.performance, |
| }); |
| |
| @override |
| String toString() => message.method.toString(); |
| } |
| |
| /// Represents a message in the Legacy protocol format. |
| final class LegacyMessage extends MessageObject { |
| final legacy.Request request; |
| CancelableToken? cancellationToken; |
| |
| LegacyMessage({required this.request, this.cancellationToken}); |
| |
| @override |
| String toString() => request.method; |
| } |
| |
| /// Represents a message in the LSP protocol format. |
| final class LspMessage extends MessageObject { |
| final lsp.Message message; |
| CancelableToken? cancellationToken; |
| |
| LspMessage({required this.message, this.cancellationToken}); |
| |
| bool get isRequest => message is lsp.RequestMessage; |
| |
| @override |
| String toString() { |
| var msg = message; |
| return switch (msg) { |
| RequestMessage() => msg.method.toString(), |
| NotificationMessage() => msg.method.toString(), |
| ResponseMessage() => 'ResponseMessage', |
| Message() => 'Message', |
| }; |
| } |
| } |
| |
| /// Represents a message from a client, can be an IDE, DTD etc. |
| sealed class MessageObject {} |
| |
| /// The [MessageScheduler] receives messages from all clients of the |
| /// [AnalysisServer]. Clients can include IDE's (LSP and Legacy protocol), DTD, |
| /// and the Diagnostic server. The [MessageScheduler] acts as a hub for all |
| /// incoming messages and forwards the messages to the appropriate handlers. |
| final class MessageScheduler { |
| /// A flag to allow disabling overlapping message handlers. |
| static bool allowOverlappingHandlers = true; |
| |
| /// The [AnalysisServer] associated with the scheduler. |
| late final AnalysisServer server; |
| |
| /// A queue of incoming messages from all the clients of the [AnalysisServer]. |
| final ListQueue<MessageObject> _incomingMessages = ListQueue<MessageObject>(); |
| |
| /// Whether the [MessageScheduler] is idle or is processing messages. |
| bool isActive = false; |
| |
| /// The completer used to indicate that message handling has been completed. |
| Completer<void> completer = Completer(); |
| |
| /// A view into the [MessageScheduler] used for testing. |
| MessageSchedulerTestView? testView; |
| |
| /// The message that is currently being processed, and null when there is |
| /// no message being processed. |
| MessageObject? _currentMessage; |
| |
| MessageScheduler({this.testView}) { |
| testView?.messageScheduler = this; |
| } |
| |
| /// Add a message to the end of the incoming messages queue. |
| /// |
| /// Some of the incoming messages are used to cancel other messages before |
| /// they are added to the queue. |
| /// |
| /// LSP Messages |
| /// - Cancellation notifications are handled by the [MessageScheduler]. |
| /// The current message and queued messages are looked through and the |
| /// [CancelableToken] for the request to be canceled is set. |
| /// - Document change notifications are used to cancel refactors, renames |
| /// (if any) that are currently in progress or on the queue for the document |
| /// that was changed. |
| /// - Incoming completion and refactor requests cancel out current and |
| /// queued requests of the same. |
| /// |
| /// Legacy Messages |
| /// - Cancellation requests are sent immediately to the [LegacyAnalysisServer] |
| /// for processing. |
| /// |
| /// LSP over Legacy |
| /// - The incoming [legacy.ANALYSIS_REQUEST_UPDATE_CONTENT] message cancels |
| /// any rename files request that is in progress. |
| void add(MessageObject message) { |
| testView?.logAddMessage(message); |
| if (message is LegacyMessage) { |
| var request = message.request; |
| if (request.method == legacy.SERVER_REQUEST_CANCEL_REQUEST) { |
| var id = |
| legacy.ServerCancelRequestParams.fromRequest( |
| request, |
| clientUriConverter: server.uriConverter, |
| ).id; |
| (server as LegacyAnalysisServer).cancelRequest(id); |
| } |
| if (request.method == legacy.ANALYSIS_REQUEST_UPDATE_CONTENT) { |
| if (_currentMessage is LegacyMessage) { |
| var current = _currentMessage as LegacyMessage; |
| var request = current.request; |
| if (request.method == legacy.LSP_REQUEST_HANDLE) { |
| var method = _getLspOverLegacyParams(request)?['method']; |
| if (method == lsp.Method.workspace_willRenameFiles.toString()) { |
| current.cancellationToken?.cancel( |
| code: lsp.ErrorCodes.ContentModified.toJson(), |
| reason: 'File content was modified', |
| ); |
| testView?.messageLog.add( |
| 'Canceled current request ${request.method}', |
| ); |
| } |
| } |
| } |
| } |
| } |
| if (message is LspMessage) { |
| var msg = message.message; |
| // Responses do not go on the queue. |
| if (msg is lsp.ResponseMessage) { |
| (server as LspAnalysisServer).handleMessage(msg, null); |
| return; |
| } |
| // If a cancellation is requested, check to see if the |
| // the current request is cancelled. If not, also check |
| // to see if a cancelled request is on the queue. |
| if (msg is lsp.NotificationMessage) { |
| if (msg.method == lsp.Method.cancelRequest) { |
| lsp.CancelParams? params; |
| try { |
| params = _getCancelParams(msg); |
| } catch (error, stackTrace) { |
| (server as LspAnalysisServer).logException( |
| 'An error occured while parsing cancel parameters', |
| error, |
| stackTrace, |
| ); |
| } |
| if (params == null) { |
| return; |
| } |
| if (_currentMessage is LspMessage && |
| (_currentMessage! as LspMessage).isRequest) { |
| var current = _currentMessage as LspMessage; |
| var request = current.message as lsp.RequestMessage; |
| if (request.id == params.id) { |
| current.cancellationToken?.cancel(); |
| testView?.messageLog.add( |
| 'Canceled current request ${request.method}', |
| ); |
| return; |
| } |
| } |
| var lspRequests = _incomingMessages.whereType<LspMessage>().where( |
| (m) => m.isRequest, |
| ); |
| if (lspRequests.isNotEmpty) { |
| for (var request in lspRequests) { |
| var req = request.message as lsp.RequestMessage; |
| if (req.id == params.id) { |
| request.cancellationToken?.cancel(); |
| testView?.messageLog.add( |
| 'Canceled request on queue ${req.method}', |
| ); |
| return; |
| } |
| } |
| } |
| } else if (msg.method == lsp.Method.textDocument_didChange) { |
| _processDocumentChange(msg); |
| } |
| } |
| if (msg is lsp.RequestMessage) { |
| // Cancel in progress completion and refactoring requests. |
| var incomingMsgMethod = msg.method; |
| if (_isCancelableRequest(msg)) { |
| var reason = |
| incomingMsgMethod == lsp.Method.workspace_executeCommand |
| ? 'Another workspace/executeCommand request for a refactor was started' |
| : 'Another textDocument/completion request was started'; |
| var current = _currentMessage; |
| if (current is LspMessage && current.isRequest) { |
| var message = current.message as lsp.RequestMessage; |
| if (message.method == incomingMsgMethod) { |
| current.cancellationToken?.cancel(reason: reason); |
| testView?.messageLog.add( |
| 'Canceled in progress request ${message.method}', |
| ); |
| } |
| } |
| // Cancel any other similar requests that are in the queue. |
| var lspRequests = _incomingMessages.whereType<LspMessage>().where( |
| (m) => m.isRequest, |
| ); |
| for (var queueMsg in lspRequests) { |
| if ((queueMsg.message as lsp.RequestMessage).method == msg.method) { |
| queueMsg.cancellationToken?.cancel(reason: reason); |
| testView?.messageLog.add( |
| 'Canceled request on queue ${msg.method}', |
| ); |
| } |
| } |
| } |
| } |
| } |
| _incomingMessages.addLast(message); |
| if (_currentMessage == null) { |
| testView?.messageLog.add('Entering process messages loop'); |
| _currentMessage = _incomingMessages.removeFirst(); |
| processMessages(); |
| } |
| } |
| |
| /// Dispatch the first message in the queue to be executed. |
| void processMessages() async { |
| try { |
| while (_currentMessage != null) { |
| completer = Completer<void>(); |
| var message = _currentMessage!; |
| testView?.logHandleMessage(message); |
| switch (message) { |
| case LspMessage(): |
| var lspMessage = message.message; |
| (server as LspAnalysisServer).handleMessage( |
| lspMessage, |
| cancellationToken: message.cancellationToken, |
| completer, |
| ); |
| case LegacyMessage(): |
| var request = message.request; |
| (server as LegacyAnalysisServer).handleRequest( |
| request, |
| completer, |
| message.cancellationToken, |
| ); |
| case DtdMessage(): |
| server.dtd!.processMessage( |
| message.message, |
| message.performance, |
| message.completer, |
| completer, |
| ); |
| } |
| |
| // Blocking here with an await on the future was intended to prevent unwanted |
| // interleaving but was found to cause a significant performance regression. |
| // For more context see: https://github.com/dart-lang/sdk/issues/60440. |
| // To re-disable interleaving, set [allowOverlappingHandlers] to `false`. |
| if (!allowOverlappingHandlers) { |
| await completer.future; |
| } |
| |
| // NOTE that this message is not accurate if [allowOverlappingHandlers] is `true` |
| // as in that case we're not blocking anymore and the future may not be complete. |
| // TODO(pq): if not awaited, consider adding a `then` so we can track when the future completes |
| // but note that we may see some flakiness in tests as message handling gets |
| // non-deterministically interleaved. |
| testView?.messageLog.add( |
| ' Complete ${message.runtimeType}: ${message.toString()}', |
| ); |
| if (_incomingMessages.isEmpty) { |
| _currentMessage = null; |
| testView?.messageLog.add('Exit process messages loop'); |
| } else { |
| _currentMessage = _incomingMessages.removeFirst(); |
| } |
| } |
| } catch (error, stackTrace) { |
| server.instrumentationService.logException( |
| FatalException('Failed to process message', error, stackTrace), |
| null, |
| server.crashReportingAttachmentsBuilder.forException(error), |
| ); |
| } |
| } |
| |
| /// Set the [AnalysisServer]. |
| void setServer(AnalysisServer analysisServer) { |
| server = analysisServer; |
| } |
| |
| lsp.CancelParams? _getCancelParams(lsp.IncomingMessage message) { |
| var cancelJsonHandler = lsp.CancelParams.jsonHandler; |
| var reporter = LspJsonReporter('params'); |
| var paramsJson = message.params as Map<String, Object?>?; |
| if (!cancelJsonHandler.validateParams(paramsJson, reporter)) { |
| return null; |
| } |
| return paramsJson != null |
| ? cancelJsonHandler.convertParams(paramsJson) |
| : null; |
| } |
| |
| lsp.DidChangeTextDocumentParams? _getChangeTextParams( |
| lsp.NotificationMessage message, |
| ) { |
| var changeTextJsonHandler = lsp.DidChangeTextDocumentParams.jsonHandler; |
| |
| var msg = message as lsp.IncomingMessage; |
| var reporter = LspJsonReporter('params'); |
| var paramsJson = msg.params as Map<String, Object?>?; |
| if (!changeTextJsonHandler.validateParams(paramsJson, reporter)) { |
| return null; |
| } |
| return paramsJson != null |
| ? changeTextJsonHandler.convertParams(paramsJson) |
| : null; |
| } |
| |
| lsp.ExecuteCommandParams? _getCommandParams(lsp.RequestMessage message) { |
| var commandJsonHandler = lsp.ExecuteCommandParams.jsonHandler; |
| var reporter = LspJsonReporter('params'); |
| var paramsJson = message.params as Map<String, Object?>?; |
| if (!commandJsonHandler.validateParams(paramsJson, reporter)) { |
| return null; |
| } |
| return paramsJson != null |
| ? commandJsonHandler.convertParams(paramsJson) |
| : null; |
| } |
| |
| Map<String, Object?>? _getLspOverLegacyParams(legacy.Request request) { |
| var params = legacy.LspHandleParams.fromRequest( |
| request, |
| clientUriConverter: server.uriConverter, |
| ); |
| return params.lspMessage as Map<String, Object?>; |
| } |
| |
| lsp.RenameParams? _getRenameParams(lsp.RequestMessage message) { |
| var jsonHandler = lsp.RenameParams.jsonHandler; |
| var reporter = LspJsonReporter('params'); |
| var paramsJson = message.params as Map<String, Object?>?; |
| if (!jsonHandler.validateParams(paramsJson, reporter)) { |
| return null; |
| } |
| return paramsJson != null ? jsonHandler.convertParams(paramsJson) : null; |
| } |
| |
| bool _isCancelableRequest(lsp.RequestMessage message) { |
| if (message.method == lsp.Method.textDocument_completion) { |
| return true; |
| } |
| if (message.method == lsp.Method.workspace_executeCommand) { |
| lsp.ExecuteCommandParams? params; |
| params = _getCommandParams(message); |
| if (params?.command == Commands.performRefactor) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /// Cancel current refactor, if any, for the document changed. |
| /// Also check for any refactors in the queue. |
| void _processDocumentChange(lsp.NotificationMessage msg) { |
| lsp.DidChangeTextDocumentParams? params; |
| params = _getChangeTextParams(msg); |
| if (params == null) { |
| return; |
| } |
| var documentChangeUri = params.textDocument.uri; |
| |
| Uri? getRefactorUri(List<lsp.LSPAny?> args) { |
| // TODO(keertip): extract method in AbstractRefactorCommandHandler |
| // and use that instead. |
| String? path; |
| if (args.length == 6) { |
| path = args[1] as String?; |
| } else if (args.length == 1 && args[0] is Map<String, Object?>) { |
| path = (args.single as Map<String, Object?>)['path'] as String?; |
| } |
| |
| return path != null ? Uri.file(path) : null; |
| } |
| |
| void checkAndCancelRefactor(LspMessage lspMessage) { |
| var request = lspMessage.message as lsp.RequestMessage; |
| var execParams = _getCommandParams(request); |
| if (execParams != null && |
| execParams.command == Commands.performRefactor) { |
| var args = execParams.arguments ?? []; |
| var refactorUri = getRefactorUri(args); |
| if (refactorUri == documentChangeUri) { |
| lspMessage.cancellationToken?.cancel( |
| code: lsp.ErrorCodes.ContentModified.toJson(), |
| ); |
| testView?.messageLog.add( |
| 'Canceled in progress request ${request.method}', |
| ); |
| } |
| } |
| } |
| |
| void checkAndCancelRename(LspMessage lspMessage) { |
| var request = lspMessage.message as lsp.RequestMessage; |
| var renameParams = _getRenameParams(request); |
| if (renameParams != null) { |
| var renameUri = renameParams.textDocument.uri; |
| if (renameUri == documentChangeUri) { |
| lspMessage.cancellationToken?.cancel( |
| code: lsp.ErrorCodes.ContentModified.toJson(), |
| ); |
| testView?.messageLog.add( |
| 'Canceled in progress request ${request.method}', |
| ); |
| } |
| } |
| } |
| |
| var current = _currentMessage; |
| if (current is LspMessage && current.isRequest) { |
| var request = current.message as lsp.RequestMessage; |
| if (request.method == lsp.Method.workspace_executeCommand) { |
| checkAndCancelRefactor(current); |
| } else if (request.method == lsp.Method.textDocument_rename) { |
| checkAndCancelRename(current); |
| } |
| } |
| // Cancel any other refactor requests that are in the queue. |
| var lspRequests = _incomingMessages.whereType<LspMessage>().where( |
| (m) => |
| m.isRequest && |
| (m.message as lsp.RequestMessage).method == |
| lsp.Method.workspace_executeCommand, |
| ); |
| for (var queueMsg in lspRequests) { |
| checkAndCancelRefactor(queueMsg); |
| } |
| var renameRequests = _incomingMessages.whereType<LspMessage>().where( |
| (m) => |
| m.isRequest && |
| (m.message as lsp.RequestMessage).method == |
| lsp.Method.textDocument_rename, |
| ); |
| for (var queueMsg in renameRequests) { |
| checkAndCancelRename(queueMsg); |
| } |
| } |
| } |
| |
| class MessageSchedulerTestView { |
| late final MessageScheduler messageScheduler; |
| |
| List<String> messageLog = <String>[]; |
| |
| void logAddMessage(MessageObject message) { |
| messageLog.add( |
| 'Incoming ${message is LspMessage ? message.message.runtimeType : message.runtimeType}: ${message.toString()}', |
| ); |
| } |
| |
| void logHandleMessage(MessageObject message) { |
| messageLog.add(' Start ${message.runtimeType}: ${message.toString()}'); |
| } |
| } |