File: Handler\WorkDoneProgress\WorkDoneProgressManager.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// 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;
 
/// <summary>
/// Manages server initiated work done progress reporting to the client.
/// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverInitiatedProgress
/// </summary>
class WorkDoneProgressManager(IClientLanguageServerManager clientLanguageServerManager) : ILspService
{
    private readonly IClientLanguageServerManager _clientLanguageServerManager = clientLanguageServerManager;
 
    /// <summary>
    /// Guards access to <see cref="_progressReporters"/>.
    /// While generally a single thread acts on a single <see cref="WorkDoneProgressReporter"/>,
    /// a single reporter may get concurrent requests to cancel while the server is disposing of it.
    /// Additionally multiple threads may create separate reporters concurrently.
    /// </summary>
    private readonly object _progressLock = new();
 
    /// <summary>
    /// Tracks active work done progress reporters by their token.
    /// Required so we can cancel them when the client requests us to.
    /// Guarded by <see cref="_progressLock"/> 
    /// </summary>
    /// <remarks>
    /// 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.
    /// </remarks>
    private readonly Dictionary<string, WorkDoneProgressReporter> _progressReporters = [];
 
    /// <summary>
    /// 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.
    /// </summary>
    /// <param name="serverCancellationToken">a cancellation token that signals when the server wants to cancel the operation</param>
    public async Task<IWorkDoneProgressReporter> 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;
 
        /// <summary>
        /// The token sent to the client identifying this work done progress session.
        /// </summary>
        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);
        }
 
        /// <summary>
        /// Expected to be called under <see cref="_progressLock"/> 
        /// </summary>
        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)
        {
        }
    }
}