From 20bc1a4239318acc25834674c5c530422e9a07b9 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Tue, 12 Nov 2024 17:41:15 -0800 Subject: [PATCH 1/2] Add server side support for refreshing source generated files --- .../Diagnostics/DiagnosticsPullCache.cs | 2 +- .../VersionedPullCache.CacheItem.cs | 2 +- .../PullHandlers/VersionedPullCache.cs | 8 +- .../SourceGeneratedDocumentCache.cs | 60 ++++++ .../SourceGeneratedDocumentGetTextHandler.cs | 45 +++- .../SourceGeneratedDocumentText.cs | 6 +- .../SourceGeneratorGetTextParams.cs | 8 +- .../SourceGeneratorRefreshQueue.cs | 140 +++++++++++++ .../SourceGeneratorRefreshQueueFactory.cs | 25 +++ .../Handler/SpellCheck/SpellCheckPullCache.cs | 2 +- .../SourceGeneratedDocumentTests.cs | 198 +++++++++++++++++- 11 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs diff --git a/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs b/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs index 41e00efaeae5..2d1b001f6f09 100644 --- a/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs @@ -24,7 +24,7 @@ internal record struct DiagnosticsRequestState(Project Project, int GlobalStateV /// and works well for us in the normal case. The latter still allows us to reuse diagnostics when changes happen that /// update the version stamp but not the content (for example, forking LSP text). /// - private sealed class DiagnosticsPullCache(string uniqueKey) : VersionedPullCache<(int globalStateVersion, VersionStamp? dependentVersion), (int globalStateVersion, Checksum dependentChecksum), DiagnosticsRequestState, DiagnosticData>(uniqueKey) + private sealed class DiagnosticsPullCache(string uniqueKey) : VersionedPullCache<(int globalStateVersion, VersionStamp? dependentVersion), (int globalStateVersion, Checksum dependentChecksum), DiagnosticsRequestState, ImmutableArray>(uniqueKey) { public override async Task<(int globalStateVersion, VersionStamp? dependentVersion)> ComputeCheapVersionAsync(DiagnosticsRequestState state, CancellationToken cancellationToken) { diff --git a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs index 4c7abb5d4ba8..6def0f52d7f8 100644 --- a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs +++ b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs @@ -52,7 +52,7 @@ private sealed class CacheItem(string uniqueKey) /// /// Returns if the previousPullResult can be re-used, otherwise returns a new resultId and the new data associated with it. /// - public async Task<(string, ImmutableArray)?> UpdateCacheItemAsync( + public async Task<(string, TComputedData)?> UpdateCacheItemAsync( VersionedPullCache cache, PreviousPullResult? previousPullResult, bool isFullyLoaded, diff --git a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs index 740ee258b2a3..aacaeca1cdc1 100644 --- a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host; @@ -19,7 +18,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; /// with different computation costs to determine if the previous cached data is still valid. /// internal abstract partial class VersionedPullCache(string uniqueKey) - where TComputedData : notnull { /// /// Map of workspace and diagnostic source to the data used to make the last pull report. @@ -59,9 +57,9 @@ internal abstract partial class VersionedPullCache. /// - public abstract Task> ComputeDataAsync(TState state, CancellationToken cancellationToken); + public abstract Task ComputeDataAsync(TState state, CancellationToken cancellationToken); - public abstract Checksum ComputeChecksum(ImmutableArray data); + public abstract Checksum ComputeChecksum(TComputedData data); /// /// If results have changed since the last request this calculates and returns a new @@ -70,7 +68,7 @@ internal abstract partial class VersionedPullCachea map of roslyn document or project id to the previous result the client sent us for that doc. /// the id of the project or document that we are checking to see if it has changed. /// Null when results are unchanged, otherwise returns a non-null new resultId. - public async Task<(string ResultId, ImmutableArray Data)?> GetOrComputeNewDataAsync( + public async Task<(string ResultId, TComputedData Data)?> GetOrComputeNewDataAsync( Dictionary idToClientLastResult, ProjectOrDocumentId projectOrDocumentId, Project project, diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs new file mode 100644 index 000000000000..07844957981f --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs @@ -0,0 +1,60 @@ +// 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 Microsoft.CodeAnalysis.Text; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +internal record struct SourceGeneratedDocumentGetTextState(Document Document); + +internal class SourceGeneratedDocumentCache(string uniqueKey) : VersionedPullCache<(SourceGeneratorExecutionVersion, VersionStamp), object?, SourceGeneratedDocumentGetTextState, SourceText?>(uniqueKey), ILspService +{ + public override async Task<(SourceGeneratorExecutionVersion, VersionStamp)> ComputeCheapVersionAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + // The execution version and the dependent version must be considered as one version cached together - + // it is not correct to say that if the execution version is the same then we can re-use results (as in automatic mode the execution version never changes). + var executionVersion = state.Document.Project.Solution.GetSourceGeneratorExecutionVersion(state.Document.Project.Id); + var dependentVersion = await state.Document.Project.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false); + return (executionVersion, dependentVersion); + } + + public override Task ComputeExpensiveVersionAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + return SpecializedTasks.Null(); + } + + public override Checksum ComputeChecksum(SourceText? data) + { + return data is null ? Checksum.Null : Checksum.From(data.GetChecksum()); + } + + public override async Task ComputeDataAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + // When a user has a open source-generated file, we ensure that the contents in the LSP snapshot match the contents that we + // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the + // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the + // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. + var unfrozenDocument = await state.Document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(state.Document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + return unfrozenDocument == null + ? null + : await unfrozenDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + } +} + +[ExportCSharpVisualBasicLspServiceFactory(typeof(SourceGeneratedDocumentCache)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SourceGeneratedDocumentCacheFactory() : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + return new SourceGeneratedDocumentCache(this.GetType().Name); + } +} diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs index 31402aaa61f2..70406a8547ca 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Composition; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics; using Roslyn.Utilities; using LSP = Roslyn.LanguageServer.Protocol; -namespace Microsoft.CodeAnalysis.LanguageServer.Handler; +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; [ExportCSharpVisualBasicStatelessLspService(typeof(SourceGeneratedDocumentGetTextHandler)), Shared] [Method(MethodName)] @@ -29,18 +31,45 @@ public async Task HandleRequestAsync(SourceGenerato { var document = context.Document; + if (document is null) + { + // The source generated file being asked about is not present. + // This is a rare case the request queue always gives us a frozen, non-null document for any opened sg document, + // even if the generator itself was removed and the document no longer exists in the host solution. + // + // We can only get a null document here if the sg document has not been opened and + // the source generated document does not exist in the workspace. + // + // Return a value indicating that the document is removed. + return new SourceGeneratedDocumentText(ResultId: null, Text: null); + } + // Nothing here strictly prevents this from working on any other document, but we'll assert we got a source-generated file, since // it wouldn't really make sense for the server to be asked for the contents of a regular file. Since this endpoint is intended for // source-generated files only, this would indicate that something else has gone wrong. Contract.ThrowIfFalse(document is SourceGeneratedDocument); - // When a user has a open source-generated file, we ensure that the contents in the LSP snapshot match the contents that we - // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the - // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the - // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. - document = await document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + var cache = context.GetRequiredLspService(); + var projectOrDocument = new ProjectOrDocumentId(document.Id); + + var previousPullResults = new Dictionary(); + if (request.ResultId is not null) + { + previousPullResults.Add(projectOrDocument, new PreviousPullResult(request.ResultId, request.TextDocument)); + } + + var newResult = await cache.GetOrComputeNewDataAsync(previousPullResults, projectOrDocument, document.Project, new SourceGeneratedDocumentGetTextState(document), cancellationToken).ConfigureAwait(false); - var text = document != null ? await document.GetTextAsync(cancellationToken).ConfigureAwait(false) : null; - return new SourceGeneratedDocumentText(text?.ToString()); + if (newResult is null) + { + Contract.ThrowIfNull(request.ResultId, "Attempted to reuse cache entry but given no resultId"); + // The generated document is the same, we can return the same resultId. + return new SourceGeneratedDocumentText(request.ResultId, null); + } + else + { + var data = newResult.Value.Data?.ToString(); + return new SourceGeneratedDocumentText(newResult.Value.ResultId, data); + } } } diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs index 17c1105a0870..192c50b73bfb 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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. @@ -6,4 +6,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; -internal sealed record SourceGeneratedDocumentText([property: JsonPropertyName("text")] string? Text); \ No newline at end of file +internal sealed record SourceGeneratedDocumentText( + [property: JsonPropertyName("resultId")] string? ResultId, + [property: JsonPropertyName("text")] string? Text); diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs index f930f1cc438c..9537b6d607d2 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs @@ -1,10 +1,12 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Text.Json.Serialization; using Roslyn.LanguageServer.Protocol; -namespace Microsoft.CodeAnalysis.LanguageServer.Handler; +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; -internal sealed record SourceGeneratorGetTextParams([property: JsonPropertyName("textDocument")] TextDocumentIdentifier TextDocument) : ITextDocumentParams; \ No newline at end of file +internal sealed record SourceGeneratorGetTextParams( + [property: JsonPropertyName("textDocument")] TextDocumentIdentifier TextDocument, + [property: JsonPropertyName("resultId")] string? ResultId) : ITextDocumentParams; diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs new file mode 100644 index 000000000000..2f3aa9ca7ab5 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs @@ -0,0 +1,140 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Utilities; +using StreamJsonRpc; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +internal class SourceGeneratorRefreshQueue : + IOnInitialized, + ILspService, + IDisposable +{ + private const string RefreshSourceGeneratedDocumentName = "workspace/refreshSourceGeneratedDocument"; + + private readonly IAsynchronousOperationListener _asyncListener; + private readonly CancellationTokenSource _disposalTokenSource = new(); + private readonly LspWorkspaceRegistrationService _lspWorkspaceRegistrationService; + private readonly LspWorkspaceManager _lspWorkspaceManager; + private readonly IClientLanguageServerManager _notificationManager; + private readonly AsyncBatchingWorkQueue _refreshQueue; + + public SourceGeneratorRefreshQueue( + IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, + LspWorkspaceRegistrationService lspWorkspaceRegistrationService, + LspWorkspaceManager lspWorkspaceManager, + IClientLanguageServerManager notificationManager) + { + _lspWorkspaceRegistrationService = lspWorkspaceRegistrationService; + _lspWorkspaceManager = lspWorkspaceManager; + _notificationManager = notificationManager; + _asyncListener = asynchronousOperationListenerProvider.GetListener(FeatureAttribute.SourceGenerators); + + // Batch up workspace notifications so that we only send a notification to refresh source generated files + // every 2 seconds - long enough to avoid spamming the client with notifications, but short enough to refresh + // the source generated files relatively frequently. + _refreshQueue = _refreshQueue = new AsyncBatchingWorkQueue( + delay: TimeSpan.FromMilliseconds(2000), + processBatchAsync: RefreshSourceGeneratedDocumentsAsync, + asyncListener: _asyncListener, + _disposalTokenSource.Token); + } + + public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken) + { + if (clientCapabilities.HasVisualStudioLspCapability()) + { + // VS source generated document content is not provided by LSP. + return Task.CompletedTask; + } + + // After we have initialized we can start listening for workspace changes. + _lspWorkspaceRegistrationService.LspSolutionChanged += OnLspSolutionChanged; + return Task.CompletedTask; + } + + private void OnLspSolutionChanged(object? sender, WorkspaceChangeEventArgs e) + { + var projectId = e.ProjectId ?? e.DocumentId?.ProjectId; + if (projectId is not null) + { + // We have a specific changed project - do some additional checks to see if + // source generators possibly changed. Note that this overreports actual + // changes to the source generated text; we rely on resultIds in the text retrieval to avoid unnecessary serialization. + + // Trivial check. see if the SG version of these projects changed. If so, we definitely want to update + // this generated file. + if (e.OldSolution.GetSourceGeneratorExecutionVersion(projectId) != + e.NewSolution.GetSourceGeneratorExecutionVersion(projectId)) + { + _refreshQueue.AddWork(); + return; + } + + var oldProject = e.OldSolution.GetProject(projectId); + var newProject = e.NewSolution.GetProject(projectId); + + if (oldProject != null && newProject != null) + { + var asyncToken = _asyncListener.BeginAsyncOperation($"{nameof(SourceGeneratorRefreshQueue)}.{nameof(OnLspSolutionChanged)}"); + CheckDependentVersionsAsync(oldProject, newProject, _disposalTokenSource.Token).CompletesAsyncOperation(asyncToken); + } + } + else + { + // We don't have a specific project change - if this is a solution change we need to queue a refresh anyway. + if (e.Kind is WorkspaceChangeKind.SolutionChanged or WorkspaceChangeKind.SolutionAdded or WorkspaceChangeKind.SolutionRemoved or WorkspaceChangeKind.SolutionReloaded or WorkspaceChangeKind.SolutionCleared) + { + _refreshQueue.AddWork(); + } + } + + async Task CheckDependentVersionsAsync(Project oldProject, Project newProject, CancellationToken cancellationToken) + { + if (await oldProject.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false) != + await newProject.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false)) + { + _refreshQueue.AddWork(); + } + } + } + + private ValueTask RefreshSourceGeneratedDocumentsAsync( + CancellationToken cancellationToken) + { + var hasOpenSourceGeneratedDocuments = _lspWorkspaceManager.GetTrackedLspText().Keys.Any(uri => uri.Scheme == SourceGeneratedDocumentUri.Scheme); + if (!hasOpenSourceGeneratedDocuments) + { + // There are no opened source generated documents - we don't need to bother asking the client to refresh anything. + return ValueTaskFactory.CompletedTask; + } + + try + { + return _notificationManager.SendNotificationAsync(RefreshSourceGeneratedDocumentName, cancellationToken); + } + catch (Exception ex) when (ex is ObjectDisposedException or ConnectionLostException) + { + // It is entirely possible that we're shutting down and the connection is lost while we're trying to send a notification + // as this runs outside of the guaranteed ordering in the queue. We can safely ignore this exception. + } + + return ValueTaskFactory.CompletedTask; + } + + public void Dispose() + { + _lspWorkspaceRegistrationService.LspSolutionChanged -= OnLspSolutionChanged; + _disposalTokenSource.Cancel(); + _disposalTokenSource.Dispose(); + } +} diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs new file mode 100644 index 000000000000..4b8531d0bf5f --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs @@ -0,0 +1,25 @@ +// 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.Shared.TestHooks; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +[ExportCSharpVisualBasicLspServiceFactory(typeof(SourceGeneratorRefreshQueue)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SourceGeneratorRefreshQueueFactory( + IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, + LspWorkspaceRegistrationService lspWorkspaceRegistrationService) : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + var notificationManager = lspServices.GetRequiredService(); + var lspWorkspaceManager = lspServices.GetRequiredService(); + return new SourceGeneratorRefreshQueue(asynchronousOperationListenerProvider, lspWorkspaceRegistrationService, lspWorkspaceManager, notificationManager); + } +} \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs b/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs index 33df968335b8..d997635b6cb6 100644 --- a/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs @@ -16,7 +16,7 @@ internal record struct SpellCheckState(ISpellCheckSpanService Service, Document /// Simplified version of that only uses a /// single cheap key to check results against. /// -internal sealed class SpellCheckPullCache(string uniqueKey) : VersionedPullCache<(Checksum parseOptionsChecksum, Checksum textChecksum)?, object?, SpellCheckState, SpellCheckSpan>(uniqueKey) +internal sealed class SpellCheckPullCache(string uniqueKey) : VersionedPullCache<(Checksum parseOptionsChecksum, Checksum textChecksum)?, object?, SpellCheckState, ImmutableArray>(uniqueKey) { public override async Task<(Checksum parseOptionsChecksum, Checksum textChecksum)?> ComputeCheapVersionAsync(SpellCheckState state, CancellationToken cancellationToken) { diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs index 1889e398f460..1d6ae9f07598 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs @@ -2,13 +2,18 @@ // 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.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; using Roslyn.Test.Utilities.TestGenerators; +using Roslyn.Utilities; using Xunit; using Xunit.Abstractions; using LSP = Roslyn.LanguageServer.Protocol; @@ -27,7 +32,7 @@ public async Task ReturnsTextForSourceGeneratedDocument(bool mutatingLspWorkspac var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text); Assert.Equal("// Hello, World", text.Text); @@ -43,7 +48,7 @@ public async Task OpenCloseSourceGeneratedDocument(bool mutatingLspWorkspace) var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text); Assert.Equal("// Hello, World", text.Text); @@ -68,7 +73,7 @@ public async Task OpenMultipleSourceGeneratedDocument(bool mutatingLspWorkspace) foreach (var sourceGeneratorDocumentUri in sourceGeneratorDocumentUris) { var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text?.Text); await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); } @@ -97,6 +102,191 @@ public async Task RequestOnSourceGeneratedDocument(bool mutatingLspWorkspace) Assert.Contains("class A", hover.Contents.Fourth.Value); } + [Theory, CombinatorialData] + public async Task ReturnsGeneratedSourceForOpenDocument(bool mutatingLspWorkspace) + { + var sourceGeneratorSource = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, sourceGeneratorSource); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + // Open the document with different text - this will cause the queue to generate frozen sg documents using this value. + // However the get text handler should return the real source generator source. + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, "LSP Open Document Text"); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal(sourceGeneratorSource, text.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsUnchangedResult(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "// Hello, World"); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Make a second request - since nothing has changed we should get back the same resultId. + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + AssertEx.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + Assert.Equal(text.ResultId, secondRequest.ResultId); + } + + [Theory, CombinatorialData] + internal async Task TestReturnsGeneratedSourceWhenDocumentChanges(bool mutatingLspWorkspace, SourceGeneratorExecutionPreference sourceGeneratorExecution) + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + + var configService = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); + configService.Options = new WorkspaceConfigurationOptions(SourceGeneratorExecution: sourceGeneratorExecution); + + var callCount = 0; + var generatorReference = await AddGeneratorAsync(new CallbackGenerator(() => ("hintName.cs", "// callCount: " + callCount++)), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// callCount: 0", text.Text); + + // Modify a normal document in the workspace. + // In automatic mode this should trigger generators to re-run. + // In balanced mode generators should not re-run. + await testLspServer.TestWorkspace.ChangeDocumentAsync(testLspServer.TestWorkspace.Documents.Single(d => !d.IsSourceGenerated).Id, SourceText.From("new text")); + await WaitForSourceGeneratorsAsync(testLspServer.TestWorkspace); + + // Ask for the source generated text again. + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + if (sourceGeneratorExecution == SourceGeneratorExecutionPreference.Automatic) + { + // We should get newly generated text + AssertEx.NotNull(secondRequest); + Assert.NotEqual(text.ResultId, secondRequest.ResultId); + Assert.Equal("// callCount: 1", secondRequest.Text); + } + else + { + // We should get an unchanged result + AssertEx.NotNull(secondRequest); + Assert.Equal(text.ResultId, secondRequest.ResultId); + Assert.Null(secondRequest.Text); + } + } + + [Theory, CombinatorialData] + internal async Task TestReturnsGeneratedSourceWhenManuallyRefreshed(bool mutatingLspWorkspace, SourceGeneratorExecutionPreference sourceGeneratorExecution) + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + + var configService = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); + configService.Options = new WorkspaceConfigurationOptions(SourceGeneratorExecution: sourceGeneratorExecution); + + var callCount = 0; + var generatorReference = await AddGeneratorAsync(new CallbackGenerator(() => ("hintName.cs", "// callCount: " + callCount++)), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// callCount: 0", text.Text); + + // Updating the execution version should trigger source generators to run in both automatic and balanced mode. + testLspServer.TestWorkspace.EnqueueUpdateSourceGeneratorVersion(projectId: null, forceRegeneration: true); + await WaitForSourceGeneratorsAsync(testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + AssertEx.NotNull(secondRequest); + Assert.NotEqual(text.ResultId, secondRequest.ResultId); + Assert.Equal("// callCount: 1", secondRequest.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsNullForRemovedClosedGeneratedFile(bool mutatingLspWorkspace) + { + var generatorText = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + var generatorReference = await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Remove the generator and verify that we get null text back. + await RemoveGeneratorAsync(generatorReference, testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + Assert.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsNullForRemovedOpenedGeneratedFile(bool mutatingLspWorkspace) + { + var generatorText = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + var generatorReference = await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Open the document - this will cause the queue to generate frozen sg documents based on the LSP open text + // even if the source generator is removed entirely. + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); + + // Remove the generator - the handler should return null text. + await RemoveGeneratorAsync(generatorReference, testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + Assert.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + } + + private static async Task WaitForSourceGeneratorsAsync(EditorTestWorkspace workspace) + { + var operations = workspace.ExportProvider.GetExportedValue(); + await operations.WaitAllAsync(workspace, [FeatureAttribute.Workspace, FeatureAttribute.SourceGenerators]); + } + private async Task CreateTestLspServerWithGeneratorAsync(bool mutatingLspWorkspace, string generatedDocumentText) { var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); From c3f434be3240b37ffe028ea8a70402447d7b8d4c Mon Sep 17 00:00:00 2001 From: David Barbet Date: Fri, 15 Nov 2024 13:50:10 -0800 Subject: [PATCH 2/2] review feedback --- .../SourceGeneratedDocumentCache.cs | 3 +- .../SourceGeneratedDocumentGetTextHandler.cs | 6 ++-- .../SourceGeneratedDocumentText.cs | 10 ++++++ .../SourceGeneratorRefreshQueue.cs | 35 +++++++++++-------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs index 07844957981f..4bbdc4e175db 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs @@ -14,7 +14,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; internal record struct SourceGeneratedDocumentGetTextState(Document Document); -internal class SourceGeneratedDocumentCache(string uniqueKey) : VersionedPullCache<(SourceGeneratorExecutionVersion, VersionStamp), object?, SourceGeneratedDocumentGetTextState, SourceText?>(uniqueKey), ILspService +internal sealed class SourceGeneratedDocumentCache(string uniqueKey) : VersionedPullCache<(SourceGeneratorExecutionVersion, VersionStamp), object?, SourceGeneratedDocumentGetTextState, SourceText?>(uniqueKey), ILspService { public override async Task<(SourceGeneratorExecutionVersion, VersionStamp)> ComputeCheapVersionAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) { @@ -41,6 +41,7 @@ public override Checksum ComputeChecksum(SourceText? data) // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. + // This can return null when the source generated file has been removed (but the queue itself is using the frozen non-null document). var unfrozenDocument = await state.Document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(state.Document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); return unfrozenDocument == null ? null diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs index 70406a8547ca..6354c26e9b46 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics; +using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.Utilities; using LSP = Roslyn.LanguageServer.Protocol; @@ -52,7 +53,7 @@ public async Task HandleRequestAsync(SourceGenerato var cache = context.GetRequiredLspService(); var projectOrDocument = new ProjectOrDocumentId(document.Id); - var previousPullResults = new Dictionary(); + using var _ = PooledDictionary.GetInstance(out var previousPullResults); if (request.ResultId is not null) { previousPullResults.Add(projectOrDocument, new PreviousPullResult(request.ResultId, request.TextDocument)); @@ -64,10 +65,11 @@ public async Task HandleRequestAsync(SourceGenerato { Contract.ThrowIfNull(request.ResultId, "Attempted to reuse cache entry but given no resultId"); // The generated document is the same, we can return the same resultId. - return new SourceGeneratedDocumentText(request.ResultId, null); + return new SourceGeneratedDocumentText(request.ResultId, Text: null); } else { + // We may get no text back if the unfrozen source generated file no longer exists. var data = newResult.Value.Data?.ToString(); return new SourceGeneratedDocumentText(newResult.Value.ResultId, data); } diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs index 192c50b73bfb..10fc9991c6c9 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs @@ -6,6 +6,16 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; +/// +/// Source generated file text result. The client uses the resultId to inform what the text value is. +/// +/// An unchanged result has a non-null resultId (same as client request resultId) + null text. +/// +/// A changed result has a new non-null resultId + possibly null text (if the sg document no longer exists). +/// +/// In rare circumstances it is possible to get a null resultId + null text - this happens when +/// the source generated document is not open AND the source generated document no longer exists +/// internal sealed record SourceGeneratedDocumentText( [property: JsonPropertyName("resultId")] string? ResultId, [property: JsonPropertyName("text")] string? Text); diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs index 2f3aa9ca7ab5..e88c75b25ae9 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs @@ -14,10 +14,10 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; -internal class SourceGeneratorRefreshQueue : - IOnInitialized, - ILspService, - IDisposable +internal sealed class SourceGeneratorRefreshQueue : + IOnInitialized, + ILspService, + IDisposable { private const string RefreshSourceGeneratedDocumentName = "workspace/refreshSourceGeneratedDocument"; @@ -29,10 +29,10 @@ internal class SourceGeneratorRefreshQueue : private readonly AsyncBatchingWorkQueue _refreshQueue; public SourceGeneratorRefreshQueue( - IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, - LspWorkspaceRegistrationService lspWorkspaceRegistrationService, - LspWorkspaceManager lspWorkspaceManager, - IClientLanguageServerManager notificationManager) + IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, + LspWorkspaceRegistrationService lspWorkspaceRegistrationService, + LspWorkspaceManager lspWorkspaceManager, + IClientLanguageServerManager notificationManager) { _lspWorkspaceRegistrationService = lspWorkspaceRegistrationService; _lspWorkspaceManager = lspWorkspaceManager; @@ -43,7 +43,7 @@ public SourceGeneratorRefreshQueue( // every 2 seconds - long enough to avoid spamming the client with notifications, but short enough to refresh // the source generated files relatively frequently. _refreshQueue = _refreshQueue = new AsyncBatchingWorkQueue( - delay: TimeSpan.FromMilliseconds(2000), + delay: DelayTimeSpan.Idle, processBatchAsync: RefreshSourceGeneratedDocumentsAsync, asyncListener: _asyncListener, _disposalTokenSource.Token); @@ -63,6 +63,14 @@ public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestCon } private void OnLspSolutionChanged(object? sender, WorkspaceChangeEventArgs e) + { + var asyncToken = _asyncListener.BeginAsyncOperation($"{nameof(SourceGeneratorRefreshQueue)}.{nameof(OnLspSolutionChanged)}"); + _ = OnLspSolutionChangedAsync(e) + .CompletesAsyncOperation(asyncToken) + .ReportNonFatalErrorUnlessCancelledAsync(_disposalTokenSource.Token); + } + + private async Task OnLspSolutionChangedAsync(WorkspaceChangeEventArgs e) { var projectId = e.ProjectId ?? e.DocumentId?.ProjectId; if (projectId is not null) @@ -85,8 +93,7 @@ private void OnLspSolutionChanged(object? sender, WorkspaceChangeEventArgs e) if (oldProject != null && newProject != null) { - var asyncToken = _asyncListener.BeginAsyncOperation($"{nameof(SourceGeneratorRefreshQueue)}.{nameof(OnLspSolutionChanged)}"); - CheckDependentVersionsAsync(oldProject, newProject, _disposalTokenSource.Token).CompletesAsyncOperation(asyncToken); + await CheckDependentVersionsAsync(oldProject, newProject).ConfigureAwait(false); } } else @@ -98,10 +105,10 @@ private void OnLspSolutionChanged(object? sender, WorkspaceChangeEventArgs e) } } - async Task CheckDependentVersionsAsync(Project oldProject, Project newProject, CancellationToken cancellationToken) + async Task CheckDependentVersionsAsync(Project oldProject, Project newProject) { - if (await oldProject.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false) != - await newProject.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false)) + if (await oldProject.GetDependentVersionAsync(_disposalTokenSource.Token).ConfigureAwait(false) != + await newProject.GetDependentVersionAsync(_disposalTokenSource.Token).ConfigureAwait(false)) { _refreshQueue.AddWork(); }