From 130eeecb96044d4cd68ddf51d84eda9ff3d2cdd4 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 24 Apr 2024 17:28:55 +1000 Subject: [PATCH 1/3] Collapse Reset then Add to Update in project state synchronizer --- ...ConfigurationStateSynchronizer.Comparer.cs | 2 +- .../ProjectConfigurationStateSynchronizer.cs | 160 ++++++++++-------- .../ProjectSystem/ProjectKey.cs | 2 + ...ojectConfigurationStateSynchronizerTest.cs | 67 +++++--- 4 files changed, 132 insertions(+), 99 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs index fe7834af468..f806562ca9c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs @@ -31,7 +31,7 @@ public bool Equals(Work? x, Work? y) // are equal only if their identifying properties are equal. So, only // configuration file paths and project keys. - if (!FilePathComparer.Instance.Equals(x.ConfigurationFilePath, y.ConfigurationFilePath)) + if (x.ProjectKey != y.ProjectKey) { return false; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs index 6b585cf7d0c..6be99c85fc1 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs @@ -4,11 +4,13 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.LanguageServer.Common; using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; @@ -23,10 +25,10 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer; internal partial class ProjectConfigurationStateSynchronizer : IProjectConfigurationFileChangeListener, IDisposable { - private abstract record Work(string ConfigurationFilePath); - private sealed record AddProject(string ConfigurationFilePath, RazorProjectInfo ProjectInfo) : Work(ConfigurationFilePath); - private sealed record ResetProject(string ConfigurationFilePath, ProjectKey ProjectKey) : Work(ConfigurationFilePath); - private sealed record UpdateProject(string ConfigurationFilePath, ProjectKey ProjectKey, RazorProjectInfo ProjectInfo) : Work(ConfigurationFilePath); + private abstract record Work(ProjectKey ProjectKey); + private sealed record AddProject(RazorProjectInfo ProjectInfo) : Work(ProjectKey.From(ProjectInfo)); + private sealed record ResetProject(ProjectKey ProjectKey) : Work(ProjectKey); + private sealed record UpdateProject(ProjectKey ProjectKey, RazorProjectInfo ProjectInfo) : Work(ProjectKey); private static readonly TimeSpan s_delay = TimeSpan.FromMilliseconds(250); @@ -37,8 +39,8 @@ private sealed record UpdateProject(string ConfigurationFilePath, ProjectKey Pro private readonly CancellationTokenSource _disposeTokenSource; private readonly AsyncBatchingWorkQueue _workQueue; - private ImmutableDictionary _filePathToProjectKeyMap = - ImmutableDictionary.Empty.WithComparers(keyComparer: FilePathComparer.Instance); + private readonly Dictionary _resetProjectMap = new(); + private readonly HashSet _indicesToSkip = new(); public ProjectConfigurationStateSynchronizer( IRazorProjectService projectService, @@ -69,24 +71,81 @@ public void Dispose() } private async ValueTask ProcessBatchAsync(ImmutableArray items, CancellationToken token) { + // Clear out our helper collections. + _resetProjectMap.Clear(); + _indicesToSkip.Clear(); + + using var itemsToProcess = new PooledArrayBuilder(items.Length); + var index = 0; + foreach (var item in items.GetMostRecentUniqueItems(Comparer.Instance)) { + if (token.IsCancellationRequested) + { + return; + } + + if (item is ResetProject reset) + { + // We shouldn't ever get two resets for the same project, due to GetMostRecentUniqueItems, so the dictionary Add + // should always be safe + Debug.Assert(!_resetProjectMap.ContainsKey(reset.ProjectKey)); + + _resetProjectMap.Add(reset.ProjectKey, (reset, index)); + itemsToProcess.Add(reset); + } + else if (item is AddProject add && + _resetProjectMap.TryGetValue(add.ProjectKey, out var previousReset)) + { + // We've already seen a Reset for this file path and now we have an Add, so let's convert to an Update + // and skip the Reset. + _indicesToSkip.Add(previousReset.index); + + var update = new UpdateProject(previousReset.work.ProjectKey, add.ProjectInfo); + itemsToProcess.Add(update); + _resetProjectMap.Remove(add.ProjectKey); + } + else + { + itemsToProcess.Add(item); + } + + index++; + } + + // Now, loop through all of the file paths we collected and notify listeners of changes, + // taking care of to skip any indices that we noted earlier. + for (var i = 0; i < itemsToProcess.Count; i++) + { + if (token.IsCancellationRequested) + { + return; + } + + if (_indicesToSkip.Contains(i)) + { + continue; + } + + var item = itemsToProcess[i]; + var itemTask = item switch { - AddProject(var configurationFilePath, var projectInfo) => AddProjectAsync(configurationFilePath, projectInfo, token), - ResetProject(_, var projectKey) => ResetProjectAsync(projectKey, token), - UpdateProject(_, var projectKey, var projectInfo) => UpdateProjectAsync(projectKey, projectInfo, token), + AddProject(var projectInfo) => AddProjectAsync(projectInfo, token), + ResetProject(var projectKey) => ResetProjectAsync(projectKey, token), + UpdateProject(var projectKey, var projectInfo) => UpdateProjectAsync(projectKey, projectInfo, token), _ => Assumed.Unreachable() }; await itemTask.ConfigureAwait(false); } - async Task AddProjectAsync(string configurationFilePath, RazorProjectInfo projectInfo, CancellationToken token) + async Task AddProjectAsync(RazorProjectInfo projectInfo, CancellationToken token) { var projectFilePath = FilePathNormalizer.Normalize(projectInfo.FilePath); - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(configurationFilePath); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo.SerializedFilePath); + // If the project already exists, this will be a no-op var projectKey = await _projectService .AddProjectAsync( projectFilePath, @@ -99,8 +158,7 @@ async Task AddProjectAsync(string configurationFilePath, RazorProjectInfo projec _logger.LogInformation($"Added {projectKey.Id}."); - ImmutableInterlocked.AddOrUpdate(ref _filePathToProjectKeyMap, configurationFilePath, projectKey, static (k, v) => v); - _logger.LogInformation($"Project configuration file added for project '{projectFilePath}': '{configurationFilePath}'"); + _logger.LogInformation($"Project configuration file added for project '{projectFilePath}': '{projectInfo.SerializedFilePath}'"); await UpdateProjectAsync(projectKey, projectInfo, token).ConfigureAwait(false); } @@ -138,55 +196,26 @@ Task UpdateProjectAsync(ProjectKey projectKey, RazorProjectInfo projectInfo, Can public void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventArgs args) { - var configurationFilePath = FilePathNormalizer.Normalize(args.ConfigurationFilePath); - switch (args.Kind) { case RazorFileChangeKind.Changed: { if (args.TryDeserialize(_options, out var projectInfo)) { - if (_filePathToProjectKeyMap.TryGetValue(configurationFilePath, out var projectKey)) - { - _logger.LogInformation($""" - Configuration file changed for project '{projectKey.Id}'. - Configuration file path: '{configurationFilePath}' - """); - - _workQueue.AddWork(new UpdateProject(configurationFilePath, projectKey, projectInfo)); - } - else - { - _logger.LogWarning($""" - Adding project for previously unseen configuration file. - Configuration file path: '{configurationFilePath}' - """); - - _workQueue.AddWork(new AddProject(configurationFilePath, projectInfo)); - } + var projectKey = ProjectKey.From(projectInfo); + _logger.LogInformation($"Configuration file changed for project '{projectKey.Id}'."); + + // UpdateProject will no-op if the project isn't known + _workQueue.AddWork(new UpdateProject(projectKey, projectInfo)); } else { - if (_filePathToProjectKeyMap.TryGetValue(configurationFilePath, out var projectKey)) - { - _logger.LogWarning($""" - Failed to deserialize after change to configuration file for project '{projectKey.Id}'. - Configuration file path: '{configurationFilePath}' - """); - - // We found the last associated project file for the configuration file. Reset the project since we can't - // accurately determine its configurations. - - _workQueue.AddWork(new ResetProject(configurationFilePath, projectKey)); - } - else - { - // Could not resolve an associated project file. - _logger.LogWarning($""" - Failed to deserialize after change to previously unseen configuration file. - Configuration file path: '{configurationFilePath}' - """); - } + var projectKey = ProjectKey.FromString(FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath)); + _logger.LogWarning($"Failed to deserialize after change to configuration file for project '{projectKey.Id}'."); + + // We found the last associated project file for the configuration file. Reset the project since we can't + // accurately determine its configurations. + _workQueue.AddWork(new ResetProject(projectKey)); } } @@ -196,16 +225,13 @@ Failed to deserialize after change to previously unseen configuration file. { if (args.TryDeserialize(_options, out var projectInfo)) { - _workQueue.AddWork(new AddProject(configurationFilePath, projectInfo)); + _workQueue.AddWork(new AddProject(projectInfo)); } else { // This is the first time we've seen this configuration file, but we can't deserialize it. // The only thing we can really do is issue a warning. - _logger.LogWarning($""" - Failed to deserialize previously unseen configuration file. - Configuration file path: '{configurationFilePath}' - """); + _logger.LogWarning($"Failed to deserialize previously unseen configuration file '{args.ConfigurationFilePath}'"); } } @@ -213,22 +239,10 @@ Failed to deserialize previously unseen configuration file. case RazorFileChangeKind.Removed: { - if (ImmutableInterlocked.TryRemove(ref _filePathToProjectKeyMap, configurationFilePath, out var projectKey)) - { - _logger.LogInformation($""" - Configuration file removed for project '{projectKey}'. - Configuration file path: '{configurationFilePath}' - """); + var projectKey = ProjectKey.FromString(FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath)); + _logger.LogInformation($"Configuration file removed for project '{projectKey}'."); - _workQueue.AddWork(new ResetProject(configurationFilePath, projectKey)); - } - else - { - _logger.LogWarning($""" - Failed to resolve associated project on configuration removed event. - Configuration file path: '{configurationFilePath}' - """); - } + _workQueue.AddWork(new ResetProject(projectKey)); } break; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectKey.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectKey.cs index 3bbda576c57..fa74cb8a55a 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectKey.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectKey.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Utilities; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -18,6 +19,7 @@ internal readonly record struct ProjectKey // end up. All creation logic is here in one place to ensure this is consistent. public static ProjectKey From(HostProject hostProject) => new(hostProject.IntermediateOutputPath); public static ProjectKey From(IProjectSnapshot project) => new(project.IntermediateOutputPath); + public static ProjectKey From(RazorProjectInfo project) => new(FilePathNormalizer.GetNormalizedDirectoryName(project.SerializedFilePath)); public static ProjectKey From(Project project) { var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(project.CompilationOutputInfo.AssemblyPath); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs index e56723bde84..0c5e3b191fd 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs @@ -28,19 +28,32 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer; public class ProjectConfigurationStateSynchronizerTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) { [Fact] - public async Task ProjectConfigurationFileChanged_Removed_UnknownDocumentNoops() + public async Task ProjectConfigurationFileChanged_Removed_UntrackedProject_CallsUpdate() { // Arrange - var projectServiceMock = new StrictMock(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - var args = new ProjectConfigurationFileChangeEventArgs( configurationFilePath: "/path/to/project.razor.bin", kind: RazorFileChangeKind.Removed, deserializer: StrictMock.Of()); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + // Act synchronizer.ProjectConfigurationFileChanged(args); @@ -446,30 +459,43 @@ public async Task ProjectConfigurationFileChanged_Changed_CantDeserialize_Resets } [Fact] - public async Task ProjectConfigurationFileChanged_Changed_UntrackedProject_Noops() + public async Task ProjectConfigurationFileChanged_Changed_UntrackedProject_CallsUpdate() { // Arrange - var projectService = new StrictMock(); - - using var synchronizer = GetSynchronizer(projectService.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - var changedArgs = new ProjectConfigurationFileChangeEventArgs( configurationFilePath: "/path/to/project.razor.bin", kind: RazorFileChangeKind.Changed, deserializer: CreateDeserializer(projectInfo: null)); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(changedArgs.ConfigurationFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + // Act synchronizer.ProjectConfigurationFileChanged(changedArgs); await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); // Assert - projectService.VerifyAll(); + projectServiceMock.VerifyAll(); } [Fact] - public async Task ProjectConfigurationFileChanged_RemoveThenAdd_OnlyAdds() + public async Task ProjectConfigurationFileChanged_RemoveThenAdd_Updates() { // Arrange var projectInfo = new RazorProjectInfo( @@ -484,15 +510,6 @@ public async Task ProjectConfigurationFileChanged_RemoveThenAdd_OnlyAdds() var projectKey = TestProjectKey.Create(intermediateOutputPath); var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(service => service.AddProjectAsync( - projectInfo.FilePath, - intermediateOutputPath, - It.IsAny(), - projectInfo.RootNamespace, - projectInfo.DisplayName, - It.IsAny())) - .ReturnsAsync(projectKey); projectServiceMock .Setup(p => p.UpdateProjectAsync( projectKey, @@ -508,12 +525,12 @@ public async Task ProjectConfigurationFileChanged_RemoveThenAdd_OnlyAdds() var synchronizerAccessor = synchronizer.GetTestAccessor(); var deserializer = CreateDeserializer(projectInfo); + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Removed, deserializer); var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Added, deserializer); - var changedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Changed, deserializer); // Act + synchronizer.ProjectConfigurationFileChanged(removedArgs); synchronizer.ProjectConfigurationFileChanged(addedArgs); - synchronizer.ProjectConfigurationFileChanged(changedArgs); await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); From 90b56bd93148d222de11215bf8d8b9bdf2a657a0 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Tue, 30 Apr 2024 21:24:18 +1000 Subject: [PATCH 2/3] Add more tests, and fix a bug found by the tests that were added --- .../ProjectConfigurationStateSynchronizer.cs | 31 +- ...ojectConfigurationStateSynchronizerTest.cs | 272 +++++++++++++++++- 2 files changed, 284 insertions(+), 19 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs index 6be99c85fc1..1f318eaf478 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.LanguageServer.Common; @@ -13,8 +12,6 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Utilities; @@ -40,7 +37,7 @@ private sealed record UpdateProject(ProjectKey ProjectKey, RazorProjectInfo Proj private readonly AsyncBatchingWorkQueue _workQueue; private readonly Dictionary _resetProjectMap = new(); - private readonly HashSet _indicesToSkip = new(); + private readonly List _indicesToSkip = new(); public ProjectConfigurationStateSynchronizer( IRazorProjectService projectService, @@ -78,7 +75,7 @@ private async ValueTask ProcessBatchAsync(ImmutableArray items, Cancellati using var itemsToProcess = new PooledArrayBuilder(items.Length); var index = 0; - foreach (var item in items.GetMostRecentUniqueItems(Comparer.Instance)) + foreach (var item in items) { if (token.IsCancellationRequested) { @@ -113,22 +110,22 @@ private async ValueTask ProcessBatchAsync(ImmutableArray items, Cancellati index++; } - // Now, loop through all of the file paths we collected and notify listeners of changes, - // taking care of to skip any indices that we noted earlier. - for (var i = 0; i < itemsToProcess.Count; i++) + // Now that we've got the real items we want to process, we remove the items we want to skip + // and then we can process the most recent unique items as normal + for (var i = _indicesToSkip.Count - 1; i >= 0; i--) + { + itemsToProcess.RemoveAt(_indicesToSkip[i]); + } + + var finalItems = itemsToProcess.DrainToImmutable(); + + foreach (var item in finalItems.GetMostRecentUniqueItems(Comparer.Instance)) { if (token.IsCancellationRequested) { return; } - if (_indicesToSkip.Contains(i)) - { - continue; - } - - var item = itemsToProcess[i]; - var itemTask = item switch { AddProject(var projectInfo) => AddProjectAsync(projectInfo, token), @@ -156,9 +153,7 @@ async Task AddProjectAsync(RazorProjectInfo projectInfo, CancellationToken token token) .ConfigureAwait(false); - _logger.LogInformation($"Added {projectKey.Id}."); - - _logger.LogInformation($"Project configuration file added for project '{projectFilePath}': '{projectInfo.SerializedFilePath}'"); + _logger.LogInformation($"Project configuration file added for project key {projectKey.Id}, file '{projectFilePath}': '{projectInfo.SerializedFilePath}'"); await UpdateProjectAsync(projectKey, projectInfo, token).ConfigureAwait(false); } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs index 0c5e3b191fd..624d84ced8c 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs @@ -548,8 +548,278 @@ public async Task ProjectConfigurationFileChanged_RemoveThenAdd_Updates() projectServiceMock.VerifyAll(); } + [Fact] + public async Task ProjectConfigurationFileChanged_AddThenRemove_AddsAndRemoves() + { + // Arrange + var projectInfo = new RazorProjectInfo( + "/path/to/obj/project.razor.json", + "path/to/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo.SerializedFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.AddProjectAsync( + projectInfo.FilePath, + intermediateOutputPath, + It.IsAny(), + projectInfo.RootNamespace, + projectInfo.DisplayName, + It.IsAny())) + .ReturnsAsync(projectKey); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + + var deserializer = CreateDeserializer(projectInfo); + var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Added, deserializer); + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Removed, deserializer); + + // Act + synchronizer.ProjectConfigurationFileChanged(addedArgs); + synchronizer.ProjectConfigurationFileChanged(removedArgs); + + await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Assert + projectServiceMock.Verify(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Exactly(2)); + + projectServiceMock.VerifyAll(); + } + + [Fact] + public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAdd_AddsAndUpdates() + { + // Arrange + var projectInfo = new RazorProjectInfo( + "/path/to/obj/project.razor.json", + "path/to/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo.SerializedFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.AddProjectAsync( + projectInfo.FilePath, + intermediateOutputPath, + It.IsAny(), + projectInfo.RootNamespace, + projectInfo.DisplayName, + It.IsAny())) + .ReturnsAsync(projectKey); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + + var deserializer = CreateDeserializer(projectInfo); + var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Added, deserializer); + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Removed, deserializer); + + // Act + synchronizer.ProjectConfigurationFileChanged(addedArgs); + synchronizer.ProjectConfigurationFileChanged(removedArgs); + synchronizer.ProjectConfigurationFileChanged(addedArgs); + + await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Assert + projectServiceMock.Verify(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Exactly(2)); + + projectServiceMock.VerifyAll(); + } + + [Fact] + public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAddThenUpdate_AddsAndUpdates() + { + // Arrange + var projectInfo = new RazorProjectInfo( + "/path/to/obj/project.razor.json", + "path/to/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo.SerializedFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.AddProjectAsync( + projectInfo.FilePath, + intermediateOutputPath, + It.IsAny(), + projectInfo.RootNamespace, + projectInfo.DisplayName, + It.IsAny())) + .ReturnsAsync(projectKey); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + + var deserializer = CreateDeserializer(projectInfo); + var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Added, deserializer); + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Removed, deserializer); + var changedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Changed, deserializer); + + // Act + synchronizer.ProjectConfigurationFileChanged(addedArgs); + synchronizer.ProjectConfigurationFileChanged(removedArgs); + synchronizer.ProjectConfigurationFileChanged(addedArgs); + synchronizer.ProjectConfigurationFileChanged(changedArgs); + synchronizer.ProjectConfigurationFileChanged(changedArgs); + synchronizer.ProjectConfigurationFileChanged(changedArgs); + + await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Assert + // Update is only called twice because the Remove-then-Add is changed to an Update, + // then that Update is deduped with the one following + projectServiceMock.Verify(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Exactly(2)); + + projectServiceMock.VerifyAll(); + } + + [Fact] + public async Task ProjectConfigurationFileChanged_RemoveThenAddDifferentProjects_RemovesAndAdds() + { + // Arrange + var projectInfo1 = new RazorProjectInfo( + "/path/to/obj/project.razor.json", + "path/to/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath1 = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo1.SerializedFilePath); + var projectKey1 = TestProjectKey.Create(intermediateOutputPath1); + + var projectInfo2 = new RazorProjectInfo( + "/path/other/obj/project.razor.json", + "path/other/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath2 = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo2.SerializedFilePath); + var projectKey2 = TestProjectKey.Create(intermediateOutputPath2); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey1, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + projectServiceMock + .Setup(p => p.AddProjectAsync( + projectInfo2.FilePath, + intermediateOutputPath2, + It.IsAny(), + projectInfo2.RootNamespace, + projectInfo2.DisplayName, + It.IsAny())) + .ReturnsAsync(projectKey2); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey2, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo1.SerializedFilePath, RazorFileChangeKind.Removed, CreateDeserializer(projectInfo1)); + var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo2.SerializedFilePath, RazorFileChangeKind.Added, CreateDeserializer(projectInfo2)); + + // Act + synchronizer.ProjectConfigurationFileChanged(removedArgs); + synchronizer.ProjectConfigurationFileChanged(addedArgs); + + await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Assert + projectServiceMock.VerifyAll(); + } + private TestProjectConfigurationStateSynchronizer GetSynchronizer(IRazorProjectService razorProjectService) - => new(razorProjectService, LoggerFactory, TestLanguageServerFeatureOptions.Instance, TimeSpan.FromMilliseconds(5)); + => new(razorProjectService, LoggerFactory, TestLanguageServerFeatureOptions.Instance, TimeSpan.FromMilliseconds(50)); private static IRazorProjectInfoDeserializer CreateDeserializer(RazorProjectInfo? projectInfo) { From 0d467883fcacdf98312802952e6711940cadde5b Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 1 May 2024 14:57:29 +1000 Subject: [PATCH 3/3] PR Feedback --- ...ProjectConfigurationFileChangeEventArgs.cs | 7 + ...ConfigurationStateSynchronizer.Comparer.cs | 16 ++- ...jectConfigurationStateSynchronizer.Work.cs | 53 ++++++++ .../ProjectConfigurationStateSynchronizer.cs | 122 ++++++++++-------- ...ojectConfigurationStateSynchronizerTest.cs | 55 ++++++++ 5 files changed, 197 insertions(+), 56 deletions(-) create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Work.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs index 91f14687bc1..f330315c3a6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; namespace Microsoft.AspNetCore.Razor.LanguageServer; @@ -77,4 +78,10 @@ public bool TryDeserialize(LanguageServerFeatureOptions options, [NotNullWhen(tr return projectInfo is not null; } + + internal ProjectKey GetProjectKey() + { + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(ConfigurationFilePath); + return ProjectKey.FromString(intermediateOutputPath); + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs index f806562ca9c..b6ab06af06c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.CodeAnalysis.Razor; +using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Razor.LanguageServer; @@ -36,6 +37,13 @@ public bool Equals(Work? x, Work? y) return false; } + // If we're skipping an item, then it's never equal to an item that isn't being skipped so a more recent skipped item + // won't cause a previous item to be de-duped when it should be processed. + if (x.Skip ^ y.Skip) + { + return false; + } + return (x, y) switch { (AddProject, AddProject) => true, @@ -53,6 +61,12 @@ public bool Equals(Work? x, Work? y) } public int GetHashCode(Work obj) - => obj.GetHashCode(); + { + var hash = HashCodeCombiner.Start(); + hash.Add(obj.Skip); + hash.Add(obj.ProjectKey.GetHashCode()); + hash.Add(obj.GetType().GetHashCode()); + return hash.CombinedHash; + } } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Work.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Work.cs new file mode 100644 index 00000000000..e05b8b8ec1e --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Work.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; + +namespace Microsoft.AspNetCore.Razor.LanguageServer; + +internal partial class ProjectConfigurationStateSynchronizer +{ + private abstract class Work(ProjectKey projectKey) + { + public ProjectKey ProjectKey => projectKey; + + public bool Skip { get; set; } + } + + private sealed class AddProject(RazorProjectInfo projectInfo) : Work(ProjectKey.From(projectInfo)) + { + public RazorProjectInfo ProjectInfo => projectInfo; + + public void Deconstruct(out RazorProjectInfo projectInfo) + { + projectInfo = ProjectInfo; + } + + public void Deconstruct(out ProjectKey projectKey, out RazorProjectInfo projectInfo) + { + projectKey = ProjectKey; + projectInfo = ProjectInfo; + } + } + + private sealed class ResetProject(ProjectKey projectKey) : Work(projectKey) + { + public void Deconstruct(out ProjectKey projectKey) + { + projectKey = ProjectKey; + } + } + + private sealed class UpdateProject(ProjectKey projectKey, RazorProjectInfo projectInfo) : Work(projectKey) + { + public RazorProjectInfo ProjectInfo => projectInfo; + + public void Deconstruct(out ProjectKey projectKey, out RazorProjectInfo projectInfo) + { + projectKey = ProjectKey; + projectInfo = ProjectInfo; + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs index 1f318eaf478..cdadcb5defd 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs @@ -16,17 +16,11 @@ using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Utilities; using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.VisualStudio.Threading; namespace Microsoft.AspNetCore.Razor.LanguageServer; internal partial class ProjectConfigurationStateSynchronizer : IProjectConfigurationFileChangeListener, IDisposable { - private abstract record Work(ProjectKey ProjectKey); - private sealed record AddProject(RazorProjectInfo ProjectInfo) : Work(ProjectKey.From(ProjectInfo)); - private sealed record ResetProject(ProjectKey ProjectKey) : Work(ProjectKey); - private sealed record UpdateProject(ProjectKey ProjectKey, RazorProjectInfo ProjectInfo) : Work(ProjectKey); - private static readonly TimeSpan s_delay = TimeSpan.FromMilliseconds(250); private readonly IRazorProjectService _projectService; @@ -36,8 +30,7 @@ private sealed record UpdateProject(ProjectKey ProjectKey, RazorProjectInfo Proj private readonly CancellationTokenSource _disposeTokenSource; private readonly AsyncBatchingWorkQueue _workQueue; - private readonly Dictionary _resetProjectMap = new(); - private readonly List _indicesToSkip = new(); + private readonly Dictionary _resetProjectMap = new(); public ProjectConfigurationStateSynchronizer( IRazorProjectService projectService, @@ -70,62 +63,26 @@ private async ValueTask ProcessBatchAsync(ImmutableArray items, Cancellati { // Clear out our helper collections. _resetProjectMap.Clear(); - _indicesToSkip.Clear(); - using var itemsToProcess = new PooledArrayBuilder(items.Length); - var index = 0; + var combinedItems = ConvertRemoveThenAddToUpdate(items, token); - foreach (var item in items) + if (token.IsCancellationRequested) { - if (token.IsCancellationRequested) - { - return; - } - - if (item is ResetProject reset) - { - // We shouldn't ever get two resets for the same project, due to GetMostRecentUniqueItems, so the dictionary Add - // should always be safe - Debug.Assert(!_resetProjectMap.ContainsKey(reset.ProjectKey)); - - _resetProjectMap.Add(reset.ProjectKey, (reset, index)); - itemsToProcess.Add(reset); - } - else if (item is AddProject add && - _resetProjectMap.TryGetValue(add.ProjectKey, out var previousReset)) - { - // We've already seen a Reset for this file path and now we have an Add, so let's convert to an Update - // and skip the Reset. - _indicesToSkip.Add(previousReset.index); - - var update = new UpdateProject(previousReset.work.ProjectKey, add.ProjectInfo); - itemsToProcess.Add(update); - _resetProjectMap.Remove(add.ProjectKey); - } - else - { - itemsToProcess.Add(item); - } - - index++; + return; } - // Now that we've got the real items we want to process, we remove the items we want to skip - // and then we can process the most recent unique items as normal - for (var i = _indicesToSkip.Count - 1; i >= 0; i--) - { - itemsToProcess.RemoveAt(_indicesToSkip[i]); - } - - var finalItems = itemsToProcess.DrainToImmutable(); - - foreach (var item in finalItems.GetMostRecentUniqueItems(Comparer.Instance)) + foreach (var item in combinedItems.GetMostRecentUniqueItems(Comparer.Instance)) { if (token.IsCancellationRequested) { return; } + if (item.Skip) + { + continue; + } + var itemTask = item switch { AddProject(var projectInfo) => AddProjectAsync(projectInfo, token), @@ -187,6 +144,61 @@ Task UpdateProjectAsync(ProjectKey projectKey, RazorProjectInfo projectInfo, Can projectInfo.Documents, token); } + + ImmutableArray ConvertRemoveThenAddToUpdate(ImmutableArray items, CancellationToken token) + { + using var itemsToProcess = new PooledArrayBuilder(items.Length); + + var skippedAny = false; + foreach (var item in items) + { + if (token.IsCancellationRequested) + { + return ImmutableArray.Empty; + } + + switch (item) + { + case ResetProject(var projectKey) reset: + // If there was a previous Reset for this project, we want to remove it from the map + // so it can't be skipped, because it must be genuine for there to be another reset + // after it (though realistically this would be an odd set of file events to get) + _resetProjectMap[projectKey] = reset; + itemsToProcess.Add(reset); + + break; + + case AddProject(var projectKey, var projectInfo) + when _resetProjectMap.TryGetValue(projectKey, out var previousReset): + // We've already seen a Reset for this file path and now we have an Add, so let's convert to an Update + // and skip the Reset. + previousReset.Skip = true; + + skippedAny = true; + + var update = new UpdateProject(projectKey, projectInfo); + itemsToProcess.Add(update); + + // Remove from the project map in case we see another Remove-Add set later + _resetProjectMap.Remove(projectKey); + + break; + + default: + itemsToProcess.Add(item); + + break; + } + } + + // If there is nothing to skip, we did nothing, so return the original list + if (!skippedAny) + { + return items; + } + + return itemsToProcess.DrainToImmutable(); + } } public void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventArgs args) @@ -205,7 +217,7 @@ public void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventA } else { - var projectKey = ProjectKey.FromString(FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath)); + var projectKey = args.GetProjectKey(); _logger.LogWarning($"Failed to deserialize after change to configuration file for project '{projectKey.Id}'."); // We found the last associated project file for the configuration file. Reset the project since we can't @@ -234,7 +246,7 @@ public void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventA case RazorFileChangeKind.Removed: { - var projectKey = ProjectKey.FromString(FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath)); + var projectKey = args.GetProjectKey(); _logger.LogInformation($"Configuration file removed for project '{projectKey}'."); _workQueue.AddWork(new ResetProject(projectKey)); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs index 624d84ced8c..eb9e1615db0 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs @@ -675,6 +675,61 @@ public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAdd_AddsAndUp projectServiceMock.VerifyAll(); } + [Fact] + public async Task ProjectConfigurationFileChanged_RemoveThenRemoveThenAdd_UpdatesTwice() + { + // Arrange + var projectInfo = new RazorProjectInfo( + "/path/to/obj/project.razor.json", + "path/to/project.csproj", + RazorConfiguration.Default, + rootNamespace: "TestRootNamespace", + displayName: "project", + ProjectWorkspaceState.Create(LanguageVersion.CSharp5), + documents: []); + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(projectInfo.SerializedFilePath); + var projectKey = TestProjectKey.Create(intermediateOutputPath); + + var projectServiceMock = new StrictMock(); + projectServiceMock + .Setup(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + using var synchronizer = GetSynchronizer(projectServiceMock.Object); + var synchronizerAccessor = synchronizer.GetTestAccessor(); + + var deserializer = CreateDeserializer(projectInfo); + var addedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Added, deserializer); + var removedArgs = new ProjectConfigurationFileChangeEventArgs(projectInfo.SerializedFilePath, RazorFileChangeKind.Removed, deserializer); + + // Act + synchronizer.ProjectConfigurationFileChanged(removedArgs); + synchronizer.ProjectConfigurationFileChanged(removedArgs); + synchronizer.ProjectConfigurationFileChanged(addedArgs); + + await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Assert + projectServiceMock.Verify(p => p.UpdateProjectAsync( + projectKey, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Exactly(2)); + + projectServiceMock.VerifyAll(); + } + [Fact] public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAddThenUpdate_AddsAndUpdates() {