diff --git a/src/VisualStudio/Core/Impl/CodeModel/ProjectCodeModelFactory.cs b/src/VisualStudio/Core/Impl/CodeModel/ProjectCodeModelFactory.cs index d35c2a1dcacad..a5912a3f402dc 100644 --- a/src/VisualStudio/Core/Impl/CodeModel/ProjectCodeModelFactory.cs +++ b/src/VisualStudio/Core/Impl/CodeModel/ProjectCodeModelFactory.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.ComponentModel.Composition; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -15,6 +16,7 @@ using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageService; +using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Threading; using Microsoft.VisualStudio.Shell; @@ -33,7 +35,7 @@ internal sealed class ProjectCodeModelFactory : IProjectCodeModelFactory private readonly IServiceProvider _serviceProvider; private readonly IThreadingContext _threadingContext; - private readonly AsyncBatchingWorkQueue _documentsToFireEventsFor; + private readonly AsyncBatchingWorkQueue _workspaceChangeEventsToFireEventsFor; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] @@ -52,12 +54,9 @@ public ProjectCodeModelFactory( // Queue up notifications we hear about docs changing. that way we don't have to fire events multiple times // for the same documents. Once enough time has passed, take the documents that were changed and run // through them, firing their latest events. - _documentsToFireEventsFor = new AsyncBatchingWorkQueue( + _workspaceChangeEventsToFireEventsFor = new AsyncBatchingWorkQueue( DelayTimeSpan.Idle, - ProcessNextDocumentBatchAsync, - // We only care about unique doc-ids, so pass in this comparer to collapse streams of changes for a - // single document down to one notification. - EqualityComparer.Default, + ProcessNextWorkspaceChangeEventBatchAsync, Listener, threadingContext.DisposalToken); @@ -66,61 +65,65 @@ public ProjectCodeModelFactory( internal IAsynchronousOperationListener Listener { get; } - private async ValueTask ProcessNextDocumentBatchAsync( - ImmutableSegmentedList documentIds, CancellationToken cancellationToken) + private async ValueTask ProcessNextWorkspaceChangeEventBatchAsync( + ImmutableSegmentedList workspaceChangeEvents, CancellationToken cancellationToken) { - // This logic preserves the previous behavior we had with IForegroundNotificationService. - // Specifically, we don't run on the UI thread for more than 15ms at a time. And once we - // have, we wait 50ms before continuing. These constants are just what we defined from - // legacy, and otherwise have no special meaning. - const int MaxTimeSlice = 15; - var delayBetweenProcessing = DelayTimeSpan.NearImmediate; - Debug.Assert(!_threadingContext.JoinableTaskContext.IsOnMainThread, "The following context switch is not expected to cause runtime overhead."); await TaskScheduler.Default; - // Ensure MEF services used by the code model are initially obtained on a background thread. - // This code avoids allocations where possible. - // https://github.com/dotnet/roslyn/issues/54159 - string? previousLanguage = null; - foreach (var projectState in _visualStudioWorkspace.CurrentSolution.SolutionState.SortedProjectStates) - { - if (projectState.Language == previousLanguage) - { - // Avoid duplicate calls if the language did not change - continue; - } + // Calculate the full set of changes over this set of events while on a background thread. + using var _1 = PooledHashSet.GetInstance(out var documentIds); + AddChangedDocuments(workspaceChangeEvents, documentIds); - previousLanguage = projectState.Language; - projectState.LanguageServices.GetService(); - projectState.LanguageServices.GetService(); - projectState.LanguageServices.GetService(); - } + if (documentIds.Count == 0) + return; + + // get the file path information while on a background thread + using var _2 = ArrayBuilder<(ProjectCodeModel projectCodeModel, string filename)>.GetInstance(out var projectCodeModelAndFileNames); + AddProjectCodeModelAndFileNames(documentIds, projectCodeModelAndFileNames); + + // Ensure MEF services used by the code model are initially obtained on a background thread. + EnsureServicesCreated(); await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); - var stopwatch = SharedStopwatch.StartNew(); - foreach (var documentId in documentIds) + // Fire off the code model events while on the main thread + await FireEventsForChangedDocumentsAsync(projectCodeModelAndFileNames, cancellationToken).ConfigureAwait(false); + } + + private static void AddChangedDocuments(ImmutableSegmentedList workspaceChangeEvents, PooledHashSet documentIds) + { + if (workspaceChangeEvents.All(static e => e.Kind is WorkspaceChangeKind.DocumentRemoved or WorkspaceChangeKind.DocumentChanged)) { - FireEventsForDocument(documentId); + // Fast path when we know we affected a document that could have had code model elements in it. No + // need to do a solution diff in this case. + foreach (var e in workspaceChangeEvents) + documentIds.Add(e.DocumentId!); - // Keep firing events for this doc, as long as we haven't exceeded the max amount - // of waiting time, and there's no user input that should take precedence. - if (stopwatch.Elapsed.TotalMilliseconds > MaxTimeSlice || IsInputPending()) - { - await this.Listener.Delay(delayBetweenProcessing, cancellationToken).ConfigureAwait(true); - stopwatch = SharedStopwatch.StartNew(); - } + return; } - return; + // Contains an event that could indicate a doc change/removal. Have to actually analyze the change to + // determine what we should do here. + var oldSolution = workspaceChangeEvents[0].OldSolution; + var newSolution = workspaceChangeEvents[^1].NewSolution; + + var changes = oldSolution.GetChanges(newSolution); + + foreach (var project in changes.GetRemovedProjects()) + documentIds.AddRange(project.DocumentIds); - void FireEventsForDocument(DocumentId documentId) + foreach (var projectChange in changes.GetProjectChanges()) { - // If we've been asked to shutdown, don't bother reporting any more events. - if (_threadingContext.DisposalToken.IsCancellationRequested) - return; + documentIds.AddRange(projectChange.GetRemovedDocuments()); + documentIds.AddRange(projectChange.GetChangedDocuments()); + } + } + private void AddProjectCodeModelAndFileNames(HashSet documentIds, ArrayBuilder<(ProjectCodeModel, string)> projectCodeModelAndFileNames) + { + foreach (var documentId in documentIds) + { var projectCodeModel = this.TryGetProjectCodeModel(documentId.ProjectId); if (projectCodeModel == null) return; @@ -129,12 +132,52 @@ void FireEventsForDocument(DocumentId documentId) if (filename == null) return; - if (!projectCodeModel.TryGetCachedFileCodeModel(filename, out var fileCodeModelHandle)) + projectCodeModelAndFileNames.Add((projectCodeModel, filename)); + } + } + + private async Task FireEventsForChangedDocumentsAsync( + ArrayBuilder<(ProjectCodeModel projectCodeModel, string filename)> projectCodeModelAndFileNames, + CancellationToken cancellationToken) + { + // This logic preserves the previous behavior we had with IForegroundNotificationService. + // Specifically, we don't run on the UI thread for more than 15ms at a time. And we don't + // check the input queue more than once per ms. Once we have run for more than 15 ms, + // we wait 50ms before continuing. These constants are just what we defined from + // legacy, and otherwise have no special meaning. + const int MaxTimeSlice = 15; + double nextInputCheckElapsedMs = 1; + + var stopwatch = SharedStopwatch.StartNew(); + foreach (var (projectCodeModel, filename) in projectCodeModelAndFileNames) + { + // If we've been asked to shutdown, don't bother reporting any more events. + if (cancellationToken.IsCancellationRequested) return; - var codeModel = fileCodeModelHandle.Object; - codeModel.FireEvents(); - return; + FireEventsForDocument(projectCodeModel, filename); + + // Keep firing events for this doc, as long as we haven't exceeded MaxTimeSlice ms or input isn't pending. + // We'll validate against those constraints at most once every 1 ms, to avoid spam checking the + // input queue, as this has shown up in performance profiles. + var elapsedMs = stopwatch.Elapsed.TotalMilliseconds; + if (elapsedMs > nextInputCheckElapsedMs) + { + if (elapsedMs > MaxTimeSlice || IsInputPending()) + { + await this.Listener.Delay(DelayTimeSpan.NearImmediate, cancellationToken).ConfigureAwait(true); + stopwatch = SharedStopwatch.StartNew(); + elapsedMs = 0; + } + + nextInputCheckElapsedMs = elapsedMs + 1; + } + } + + static void FireEventsForDocument(ProjectCodeModel projectCodeModel, string filename) + { + if (projectCodeModel.TryGetCachedFileCodeModel(filename, out var fileCodeModelHandle)) + fileCodeModelHandle.Object.FireEvents(); } // Returns true if any keyboard or mouse button input is pending on the message queue. @@ -153,6 +196,24 @@ static bool IsInputPending() } } + private void EnsureServicesCreated() + { + // Ensure MEF services used by the code model are initially obtained on a background thread. + // This code avoids allocations where possible. + // https://github.com/dotnet/roslyn/issues/54159 + using var _ = PooledHashSet.GetInstance(out var previousLanguages); + foreach (var projectState in _visualStudioWorkspace.CurrentSolution.SolutionState.SortedProjectStates) + { + // Avoid duplicate calls if the language has been seen + if (previousLanguages.Add(projectState.Language)) + { + projectState.LanguageServices.GetService(); + projectState.LanguageServices.GetService(); + projectState.LanguageServices.GetService(); + } + } + } + private void OnWorkspaceChanged(WorkspaceChangeEventArgs e) { // Events that can't change existing code model items. Can just ignore them. @@ -170,27 +231,9 @@ private void OnWorkspaceChanged(WorkspaceChangeEventArgs e) case WorkspaceChangeKind.AnalyzerConfigDocumentReloaded: case WorkspaceChangeKind.AnalyzerConfigDocumentChanged: return; - case WorkspaceChangeKind.DocumentRemoved: - case WorkspaceChangeKind.DocumentChanged: - // Fast path when we know we affected a document that could have had code model elements in it. No - // need to do a solution diff in this case. - _documentsToFireEventsFor.AddWork(e.DocumentId!); - return; } - // Other type of event that could indicate a doc change/removal. Have to actually analyze the change to - // determine what we should do here. - - var changes = e.OldSolution.GetChanges(e.NewSolution); - - foreach (var project in changes.GetRemovedProjects()) - _documentsToFireEventsFor.AddWork(project.DocumentIds); - - foreach (var projectChange in changes.GetProjectChanges()) - { - _documentsToFireEventsFor.AddWork(projectChange.GetRemovedDocuments()); - _documentsToFireEventsFor.AddWork(projectChange.GetChangedDocuments()); - } + _workspaceChangeEventsToFireEventsFor.AddWork(e); } public IProjectCodeModel CreateProjectCodeModel(ProjectId id, ICodeModelInstanceFactory codeModelInstanceFactory)