diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerClientTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerClientTests.cs index 3a10d43b220f9..1821b02ac4eb8 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerClientTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerClientTests.cs @@ -14,6 +14,7 @@ using Roslyn.LanguageServer.Protocol; using Roslyn.Test.Utilities; using Roslyn.Utilities; +using StreamJsonRpc; using Xunit.Abstractions; using LSP = Roslyn.LanguageServer.Protocol; @@ -74,9 +75,10 @@ await File.WriteAllTextAsync(projectPath, $""" documents: files, locations: annotatedLocations); - // Perform restore and mock up project restore client handler + // Perform restore ProcessUtilities.Run("dotnet", $"restore --project {projectPath}"); - lspClient.AddClientLocalRpcTarget(ProjectDependencyHelper.ProjectNeedsRestoreName, (string[] projectFilePaths) => { }); + + lspClient.AddClientLocalRpcTarget(new WorkDoneProgressTarget()); // Listen for project initialization var projectInitialized = new TaskCompletionSource(); @@ -92,6 +94,15 @@ await File.WriteAllTextAsync(projectPath, $""" return lspClient; } + private class WorkDoneProgressTarget + { + [JsonRpcMethod(Methods.WindowWorkDoneProgressCreateName, UseSingleObjectParameterDeserialization = true)] + public Task HandleCreateWorkDoneProgress(WorkDoneProgressCreateParams _1, CancellationToken _2) => Task.CompletedTask; + + [JsonRpcMethod(Methods.ProgressNotificationName, UseSingleObjectParameterDeserialization = true)] + public Task HandleProgress((string token, object value) _1, CancellationToken _2) => Task.CompletedTask; + } + private protected static Dictionary> GetAnnotatedLocations(DocumentUri codeUri, SourceText text, ImmutableDictionary> spanMap) { var locations = new Dictionary>(); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs index 74eed1693aa54..65db521767f37 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CanonicalMiscFilesProjectLoader.cs @@ -29,6 +29,12 @@ internal sealed class CanonicalMiscFilesProjectLoader : LanguageServerProjectLoa { private readonly Lazy _canonicalDocumentPath; + /// + /// Avoid showing restore notifications for misc files - it ends up being noisy and confusing + /// as every file is a misc file on first open until we detect a project for it. + /// + protected override bool EnableProgressReporting => false; + public CanonicalMiscFilesProjectLoader( LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, @@ -37,7 +43,8 @@ public CanonicalMiscFilesProjectLoader( IAsynchronousOperationListenerProvider listenerProvider, ProjectLoadTelemetryReporter projectLoadTelemetry, ServerConfigurationFactory serverConfigurationFactory, - IBinLogPathProvider binLogPathProvider) + IBinLogPathProvider binLogPathProvider, + DotnetCliHelper dotnetCliHelper) : base( workspaceFactory, fileChangeWatcher, @@ -46,7 +53,8 @@ public CanonicalMiscFilesProjectLoader( listenerProvider, projectLoadTelemetry, serverConfigurationFactory, - binLogPathProvider) + binLogPathProvider, + dotnetCliHelper) { _canonicalDocumentPath = new Lazy(() => { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index 91facb2a902ed..c7454755629fa 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -44,7 +44,8 @@ public FileBasedProgramsProjectSystem( IAsynchronousOperationListenerProvider listenerProvider, ProjectLoadTelemetryReporter projectLoadTelemetry, ServerConfigurationFactory serverConfigurationFactory, - IBinLogPathProvider binLogPathProvider) + IBinLogPathProvider binLogPathProvider, + DotnetCliHelper dotnetCliHelper) : base( workspaceFactory, fileChangeWatcher, @@ -53,7 +54,8 @@ public FileBasedProgramsProjectSystem( listenerProvider, projectLoadTelemetry, serverConfigurationFactory, - binLogPathProvider) + binLogPathProvider, + dotnetCliHelper) { _lspServices = lspServices; _logger = loggerFactory.CreateLogger(); @@ -66,7 +68,8 @@ public FileBasedProgramsProjectSystem( listenerProvider, projectLoadTelemetry, serverConfigurationFactory, - binLogPathProvider); + binLogPathProvider, + dotnetCliHelper); } private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs index 56dc2a9a3b657..69f3b965c7a41 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs @@ -34,7 +34,8 @@ internal sealed class FileBasedProgramsWorkspaceProviderFactory( IAsynchronousOperationListenerProvider listenerProvider, ProjectLoadTelemetryReporter projectLoadTelemetry, ServerConfigurationFactory serverConfigurationFactory, - IBinLogPathProvider binLogPathProvider) : ILspMiscellaneousFilesWorkspaceProviderFactory + IBinLogPathProvider binLogPathProvider, + DotnetCliHelper dotnetCliHelper) : ILspMiscellaneousFilesWorkspaceProviderFactory { public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices) { @@ -48,6 +49,7 @@ public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorksp listenerProvider, projectLoadTelemetry, serverConfigurationFactory, - binLogPathProvider); + binLogPathProvider, + dotnetCliHelper); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index d0ff16d0b192d..5a151fb85b0d0 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -36,6 +36,7 @@ internal abstract class LanguageServerProjectLoader private readonly ILogger _logger; private readonly ProjectLoadTelemetryReporter _projectLoadTelemetryReporter; private readonly IBinLogPathProvider _binLogPathProvider; + private readonly DotnetCliHelper _dotnetCliHelper; protected readonly ImmutableDictionary AdditionalProperties; /// @@ -84,6 +85,11 @@ public sealed record Primordial(ProjectSystemProjectFactory PrimordialProjectFac public sealed record LoadedTargets(ImmutableArray LoadedProjectTargets) : ProjectLoadState; } + /// + /// Indicates whether loads should report UI progress to the client for this loader. + /// + protected virtual bool EnableProgressReporting => true; + protected LanguageServerProjectLoader( LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, @@ -92,7 +98,8 @@ protected LanguageServerProjectLoader( IAsynchronousOperationListenerProvider listenerProvider, ProjectLoadTelemetryReporter projectLoadTelemetry, ServerConfigurationFactory serverConfigurationFactory, - IBinLogPathProvider binLogPathProvider) + IBinLogPathProvider binLogPathProvider, + DotnetCliHelper dotnetCliHelper) { _workspaceFactory = workspaceFactory; _fileChangeWatcher = fileChangeWatcher; @@ -101,6 +108,7 @@ protected LanguageServerProjectLoader( _logger = loggerFactory.CreateLogger(nameof(LanguageServerProjectLoader)); _projectLoadTelemetryReporter = projectLoadTelemetry; _binLogPathProvider = binLogPathProvider; + _dotnetCliHelper = dotnetCliHelper; AdditionalProperties = BuildAdditionalProperties(serverConfigurationFactory.ServerConfiguration); @@ -176,12 +184,8 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList projectPaths, CancellationToken cancellationToken) + internal static async Task RestoreProjectsAsync(ImmutableArray projectPaths, bool enableProgressReporting, DotnetCliHelper dotnetCliHelper, ILogger logger, CancellationToken cancellationToken) { if (projectPaths.IsEmpty) return; Contract.ThrowIfNull(LanguageServerHost.Instance, "We don't have an LSP channel yet to send this request through."); - var languageServerManager = LanguageServerHost.Instance.GetRequiredLspService(); + var workDoneProgressManager = LanguageServerHost.Instance.GetRequiredLspService(); - // Ensure we only pass unique paths back to be restored. - var unresolvedParams = new UnresolvedDependenciesParams([.. projectPaths.Distinct()]); - await languageServerManager.SendRequestAsync(ProjectNeedsRestoreName, unresolvedParams, cancellationToken); + try + { + await RestoreHandler.RestoreAsync(projectPaths, workDoneProgressManager, dotnetCliHelper, logger, enableProgressReporting, cancellationToken); + } + catch (OperationCanceledException) + { + // Restore was cancelled. This is not a failure, it just leaves the project unrestored or partially restored (same as if the user cancelled a CLI restore). + // We don't want this exception to bubble up to the project load queue however as it may need to additional work after this call. + logger.LogWarning("Project restore was canceled."); + } } - - private sealed record UnresolvedDependenciesParams( - [property: JsonPropertyName("projectFilePaths")] string[] ProjectFilePaths); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreHandler.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreHandler.cs index b94afeddea3d8..e79bef9ab1c4d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreHandler.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreHandler.cs @@ -6,6 +6,8 @@ using System.Composition; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Threading; +using Roslyn.LanguageServer.Protocol; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.LanguageServer.Handler; @@ -18,7 +20,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; [Method(MethodName)] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class RestoreHandler(DotnetCliHelper dotnetCliHelper, ILoggerFactory loggerFactory) : ILspServiceRequestHandler +internal sealed class RestoreHandler(DotnetCliHelper dotnetCliHelper, ILoggerFactory loggerFactory) : ILspServiceRequestHandler { internal const string MethodName = "workspace/_roslyn_restore"; @@ -28,26 +30,24 @@ internal sealed class RestoreHandler(DotnetCliHelper dotnetCliHelper, ILoggerFac private readonly ILogger _logger = loggerFactory.CreateLogger(); - public async Task HandleRequestAsync(RestoreParams request, RequestContext context, CancellationToken cancellationToken) + public async Task HandleRequestAsync(RestoreParams request, RequestContext context, CancellationToken cancellationToken) { Contract.ThrowIfNull(context.Solution); - using var progress = BufferedProgress.Create(request.PartialResultToken); - - progress.Report(new RestorePartialResult(LanguageServerResources.Restore, LanguageServerResources.Restore_started)); var restorePaths = GetRestorePaths(request, context.Solution, context); if (restorePaths.IsEmpty) { _logger.LogDebug($"Restore was requested but no paths were provided."); - progress.Report(new RestorePartialResult(LanguageServerResources.Restore, LanguageServerResources.Nothing_found_to_restore)); - return progress.GetValues() ?? []; + return new RestoreResult(true); } + var workDoneProgressManager = context.GetRequiredService(); _logger.LogDebug($"Running restore on {restorePaths.Length} paths, starting with '{restorePaths.First()}'."); - bool success = await RestoreAsync(restorePaths, progress, cancellationToken); - progress.Report(new RestorePartialResult(LanguageServerResources.Restore, $"{LanguageServerResources.Restore_complete}{Environment.NewLine}")); - if (success) + // We let cancellation here bubble up to the client as this is a client initiated operation. + var didSucceed = await RestoreAsync(restorePaths, workDoneProgressManager, dotnetCliHelper, _logger, enableProgressReporting: true, cancellationToken); + + if (didSucceed) { _logger.LogDebug($"Restore completed successfully."); } @@ -56,13 +56,44 @@ public async Task HandleRequestAsync(RestoreParams reque _logger.LogError($"Restore completed with errors."); } - return progress.GetValues() ?? []; + return new RestoreResult(didSucceed); } /// True if all restore invocations exited with code 0. Otherwise, false. - private async Task RestoreAsync(ImmutableArray pathsToRestore, BufferedProgress progress, CancellationToken cancellationToken) + public static async Task RestoreAsync( + ImmutableArray pathsToRestore, + WorkDoneProgressManager workDoneProgressManager, + DotnetCliHelper dotnetCliHelper, + ILogger logger, + bool enableProgressReporting, + CancellationToken cancellationToken) { - bool success = true; + using var progress = await workDoneProgressManager.CreateWorkDoneProgressAsync(reportProgressToClient: enableProgressReporting, cancellationToken); + // Ensure we're observing cancellation token from the work done progress (to allow client cancellation). + cancellationToken = progress.CancellationToken; + return await RestoreCoreAsync(pathsToRestore, progress, dotnetCliHelper, logger, cancellationToken); + + } + + private static async Task RestoreCoreAsync( + ImmutableArray pathsToRestore, + IWorkDoneProgressReporter progress, + DotnetCliHelper dotnetCliHelper, + ILogger logger, + CancellationToken cancellationToken) + { + // Report the start of the work done progress to the client. + progress.Report(new WorkDoneProgressBegin() + { + Title = LanguageServerResources.Restore, + // Adds a cancel button to the client side progress UI. + // Cancellation here is fine, it just means the restore will be incomplete (same as a cntrl+C for a CLI restore). + Cancellable = true, + Message = LanguageServerResources.Restore_started, + Percentage = 0, + }); + + var success = true; foreach (var path in pathsToRestore) { var arguments = new string[] { "restore", path }; @@ -77,8 +108,8 @@ private async Task RestoreAsync(ImmutableArray pathsToRestore, Buf process?.Kill(); }); - process.OutputDataReceived += (sender, args) => ReportProgress(progress, stageName, args.Data); - process.ErrorDataReceived += (sender, args) => ReportProgress(progress, stageName, args.Data); + process.OutputDataReceived += (sender, args) => ReportProgressInEvent(progress, stageName, args.Data); + process.ErrorDataReceived += (sender, args) => ReportProgressInEvent(progress, stageName, args.Data); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -91,14 +122,43 @@ private async Task RestoreAsync(ImmutableArray pathsToRestore, Buf } } + // Report work done progress completion + progress.Report( + new WorkDoneProgressEnd() + { + Message = LanguageServerResources.Restore_complete + }); + + logger.LogInformation(LanguageServerResources.Restore_complete); return success; - static void ReportProgress(BufferedProgress progress, string stage, string? restoreOutput) + void ReportProgressInEvent(IWorkDoneProgressReporter progress, string stage, string? restoreOutput) { - if (restoreOutput != null) + if (restoreOutput == null) + return; + + try { - progress.Report(new RestorePartialResult(stage, restoreOutput)); + ReportProgress(progress, stage, restoreOutput); } + catch (Exception) + { + // Catch everything to ensure the exception doesn't escape the event handler. + // Errors already reported via ReportNonFatalErrorUnlessCancelledAsync. + } + } + + void ReportProgress(IWorkDoneProgressReporter progress, string stage, string message) + { + logger.LogInformation("{stage}: {Output}", stage, message); + var report = new WorkDoneProgressReport() + { + Message = stage, + Percentage = null, + Cancellable = true, + }; + + progress.Report(report); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreParams.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreParams.cs index 2717d9c21f953..d8d97b8f8a781 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreParams.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreParams.cs @@ -10,9 +10,4 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; internal sealed record RestoreParams( // An empty set of project file paths means restore all projects in the workspace. [property: JsonPropertyName("projectFilePaths")] string[] ProjectFilePaths -) : IPartialResultParams -{ - [JsonPropertyName(Methods.PartialResultTokenName)] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IProgress? PartialResultToken { get; set; } -} +); \ No newline at end of file diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestorePartialResult.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreResult.cs similarity index 65% rename from src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestorePartialResult.cs rename to src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreResult.cs index 52a26da7932ca..ebbb9066bc929 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestorePartialResult.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/LanguageServer/Handler/Restore/RestoreResult.cs @@ -6,7 +6,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; -internal sealed record RestorePartialResult( - [property: JsonPropertyName("stage")] string Stage, - [property: JsonPropertyName("message")] string Message +internal sealed record RestoreResult( + [property: JsonPropertyName("success")] bool Success ); diff --git a/src/LanguageServer/Protocol/Handler/WorkDoneProgress/IWorkDoneProgressReporter.cs b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/IWorkDoneProgressReporter.cs new file mode 100644 index 0000000000000..88317774965fa --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/IWorkDoneProgressReporter.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Roslyn.LanguageServer.Protocol; + +interface IWorkDoneProgressReporter : IDisposable, IProgress +{ + /// + /// Cancellation token that can be monitored to know when work done progress has been cancelled, + /// either by the client or the server. + /// + CancellationToken CancellationToken { get; } +} \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressCancelledHandler.cs b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressCancelledHandler.cs new file mode 100644 index 0000000000000..84fe68cc65bfe --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressCancelledHandler.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host.Mef; +using Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +[ExportCSharpVisualBasicStatelessLspService(typeof(WorkDoneProgressCancelledHandler)), Shared] +[Method(Methods.WindowWorkDoneProgressCancelName)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class WorkDoneProgressCancelledHandler() : ILspServiceNotificationHandler +{ + public bool MutatesSolutionState => false; + + public bool RequiresLSPSolution => false; + + public Task HandleNotificationAsync(WorkDoneProgressCancelParams request, RequestContext requestContext, CancellationToken cancellationToken) + { + var manager = requestContext.GetRequiredLspService(); + // We always create guid tokens, so use the string sumtype directly. + manager.CancelWorkDoneProgress(request.Token.Second); + return Task.CompletedTask; + } +} diff --git a/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManager.cs b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManager.cs new file mode 100644 index 0000000000000..b835a6ce7415f --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManager.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ErrorReporting; +using Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +/// +/// Manages server initiated work done progress reporting to the client. +/// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverInitiatedProgress +/// +class WorkDoneProgressManager(IClientLanguageServerManager clientLanguageServerManager) : ILspService +{ + private readonly IClientLanguageServerManager _clientLanguageServerManager = clientLanguageServerManager; + + /// + /// Guards access to . + /// While generally a single thread acts on a single , + /// a single reporter may get concurrent requests to cancel while the server is disposing of it. + /// Additionally multiple threads may create separate reporters concurrently. + /// + private readonly object _progressLock = new(); + + /// + /// Tracks active work done progress reporters by their token. + /// Required so we can cancel them when the client requests us to. + /// Guarded by + /// + /// + /// A singe entry is added once to the dictionary when a new work done progress session is initiated. + /// Multiple threads may create new sessions concurrently. Additionally, a single entry may have + /// have a concurrent request from the client to cancel while the server is disposing of it. + /// + private readonly Dictionary _progressReporters = []; + + /// + /// Initiates a new work done progress reporting session with the client. + /// This sends the initial `window/workDoneProgress/create` request, but callers are responsible for sending the + /// begin and end reports. + /// In the case of server side cancellation, an end report will be sent automatically with a "Cancelled" message. + /// + /// a cancellation token that signals when the server wants to cancel the operation + public async Task CreateWorkDoneProgressAsync(bool reportProgressToClient, CancellationToken serverCancellationToken) + { + var token = Guid.NewGuid().ToString(); + IWorkDoneProgressReporter reporter; + if (reportProgressToClient) + { + var clientReporter = new WorkDoneProgressReporter(token, this, serverCancellationToken); + await clientReporter.SendCreateRequestAsync().ConfigureAwait(false); + lock (_progressLock) + { + _progressReporters[token] = clientReporter; + } + + return clientReporter; + } + else + { + reporter = new NoOpProgressReporter(serverCancellationToken); + } + + return reporter; + } + + public void CancelWorkDoneProgress(string token) + { + lock (_progressLock) + { + // We may be handling a client cancellation request after the server already completed and disposed of the progress. + // Check that we still have a non-disposed reporter for this token. + if (_progressReporters.TryGetValue(token, out var reporter)) + { + reporter.CancelSource_NoLock(); + } + } + } + + private class WorkDoneProgressReporter : IWorkDoneProgressReporter + { + private readonly WorkDoneProgressManager _manager; + + /// + /// The token sent to the client identifying this work done progress session. + /// + private readonly string _token; + + private readonly CancellationTokenSource _cancellationTokenSource; + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public WorkDoneProgressReporter(string token, WorkDoneProgressManager manager, CancellationToken serverCancellationToken) + { + _token = token; + _manager = manager; + // Link the server cancellation token to the source handling client side cancellation. + _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(serverCancellationToken); + + // Tell the client to end the work done progress if the server cancels the request. + // This needs to not observe the linked cancellation token as it will already be cancelled. + serverCancellationToken.Register(() => + { + // the reporter is already cancelled (linked cancellation token) - but we need to ensure the client is notified of the server requested cancellation. + // this report should not be cancellable so report with no cancellation token. + ReportProgressAsync(new WorkDoneProgressEnd() + { + Message = "Cancelled" + }, CancellationToken.None).ReportNonFatalErrorAsync(); + }); + } + + public async Task SendCreateRequestAsync() + { + var workDoneParams = new WorkDoneProgressCreateParams() + { + Token = _token + }; + + CancellationToken.ThrowIfCancellationRequested(); + await _manager._clientLanguageServerManager.SendRequestAsync(Methods.WindowWorkDoneProgressCreateName, workDoneParams, CancellationToken).ConfigureAwait(false); + } + + public void Report(WorkDoneProgress progress) + { + ReportProgressAsync(progress, CancellationToken).ReportNonFatalErrorUnlessCancelledAsync(CancellationToken); + } + + private async Task ReportProgressAsync(WorkDoneProgress progress, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await _manager._clientLanguageServerManager.SendNotificationAsync(Methods.ProgressNotificationName, new ProgressReportType(_token, progress), cancellationToken).ConfigureAwait(false); + } + + /// + /// Expected to be called under + /// + public void CancelSource_NoLock() + { + _cancellationTokenSource.Cancel(); + } + + public void Dispose() + { + // Take the lock here to ensure we don't run this concurrently with Cancel. + lock (_manager._progressLock) + { + _manager._progressReporters.Remove(_token); + _cancellationTokenSource.Dispose(); + } + } + + private record struct ProgressReportType( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("value")] WorkDoneProgress Value); + } + + private record struct NoOpProgressReporter(CancellationToken cancellationToken) : IWorkDoneProgressReporter + { + public readonly CancellationToken CancellationToken => cancellationToken; + public readonly void Dispose() + { + } + + public readonly void Report(WorkDoneProgress value) + { + } + } +} diff --git a/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManagerFactory.cs b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManagerFactory.cs new file mode 100644 index 0000000000000..8cf293aeb9fea --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/WorkDoneProgress/WorkDoneProgressManagerFactory.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Composition; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer; +using Microsoft.CodeAnalysis.LanguageServer.Handler; + +[ExportCSharpVisualBasicLspServiceFactory(typeof(WorkDoneProgressManager)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class WorkDoneProgressManagerFactory() : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + var clientLanguageServerManager = lspServices.GetRequiredService(); + return new WorkDoneProgressManager(clientLanguageServerManager); + } +} \ No newline at end of file