File: ExternalAccess\UnitTesting\SolutionCrawler\UnitTestingWorkCoordinator.UnitTestingSemanticChangeProcessor.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.UnitTesting.Api;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.ExternalAccess.UnitTesting.SolutionCrawler;
 
internal sealed partial class UnitTestingSolutionCrawlerRegistrationService
{
    /// <summary>
    /// this will be used in the unit test to indicate certain action has happened or not.
    /// </summary>
    public const string EnqueueItem = nameof(EnqueueItem);
 
    internal sealed partial class UnitTestingWorkCoordinator
    {
        private sealed class UnitTestingSemanticChangeProcessor : UnitTestingIdleProcessor
        {
            private static readonly Func<int, DocumentId, bool, string> s_enqueueLogger = (tick, documentId, hint) => $"Tick:{tick}, {documentId}, {documentId.ProjectId}, hint:{hint}";
 
            private readonly SemaphoreSlim _gate;
 
            private readonly UnitTestingRegistration _registration;
            private readonly UnitTestingProjectProcessor _processor;
 
            private readonly SemaphoreSlim _workGate = new(initialCount: 1);
            private readonly Dictionary<DocumentId, UnitTestingData> _pendingWork = [];
 
            public UnitTestingSemanticChangeProcessor(
                IAsynchronousOperationListener listener,
                UnitTestingRegistration registration,
                UnitTestingIncrementalAnalyzerProcessor documentWorkerProcessor,
                TimeSpan backOffTimeSpan,
                TimeSpan projectBackOffTimeSpan,
                CancellationToken cancellationToken)
                : base(listener, backOffTimeSpan, cancellationToken)
            {
                _gate = new SemaphoreSlim(initialCount: 0);
 
                _registration = registration;
 
                _processor = new UnitTestingProjectProcessor(listener, registration, documentWorkerProcessor, projectBackOffTimeSpan, cancellationToken);
 
                Start();
 
                // Register a clean-up task to ensure pending work items are flushed from the queue if they will
                // never be processed.
                AsyncProcessorTask.ContinueWith(
                    _ => ClearQueueWorker(_workGate, _pendingWork, data => data.AsyncToken),
                    CancellationToken.None,
                    TaskContinuationOptions.ExecuteSynchronously,
                    TaskScheduler.Default);
            }
 
            protected override void OnPaused()
            {
            }
 
            public override Task AsyncProcessorTask
                => Task.WhenAll(base.AsyncProcessorTask, _processor.AsyncProcessorTask);
 
            protected override Task WaitAsync(CancellationToken cancellationToken)
                => _gate.WaitAsync(cancellationToken);
 
            protected override async Task ExecuteAsync()
            {
                var data = Dequeue();
 
                using (data.AsyncToken)
                {
                    // we have a hint. check whether we can take advantage of it
                    if (await TryEnqueueFromHintAsync(data).ConfigureAwait(continueOnCapturedContext: false))
                        return;
 
                    EnqueueFullProjectDependency(data.Project);
                }
            }
 
            private UnitTestingData Dequeue()
                => DequeueWorker(_workGate, _pendingWork, CancellationToken);
 
            private async Task<bool> TryEnqueueFromHintAsync(UnitTestingData data)
            {
                var changedMember = data.ChangedMember;
                if (changedMember == null)
                    return false;
 
                var document = data.GetRequiredDocument();
 
                // see whether we already have semantic model. otherwise, use the expansive full project dependency one
                // TODO: if there is a reliable way to track changed member, we could use GetSemanticModel here which could
                //       rebuild compilation from scratch
                if (!document.TryGetSemanticModel(out var model) ||
                    !changedMember.TryResolve(await document.GetSyntaxRootAsync(CancellationToken).ConfigureAwait(false), out SyntaxNode? declarationNode))
                {
                    return false;
                }
 
                var symbol = model.GetDeclaredSymbol(declarationNode, CancellationToken);
                if (symbol == null)
                {
                    return false;
                }
 
                return await TryEnqueueFromMemberAsync(document, symbol).ConfigureAwait(false) ||
                    await TryEnqueueFromTypeAsync(document, symbol).ConfigureAwait(false);
            }
 
            private async Task<bool> TryEnqueueFromTypeAsync(Document document, ISymbol symbol)
            {
                if (!IsType(symbol))
                {
                    return false;
                }
 
                if (symbol.DeclaredAccessibility == Accessibility.Private)
                {
                    await EnqueueWorkItemAsync(document, symbol).ConfigureAwait(false);
 
                    Logger.Log(FunctionId.WorkCoordinator_SemanticChange_EnqueueFromType, symbol.Name);
                    return true;
                }
 
                if (IsInternal(symbol))
                {
                    var assembly = symbol.ContainingAssembly;
                    EnqueueFullProjectDependency(document.Project, assembly);
                    return true;
                }
 
                return false;
            }
 
            private async Task<bool> TryEnqueueFromMemberAsync(Document document, ISymbol symbol)
            {
                if (!IsMember(symbol))
                {
                    return false;
                }
 
                var typeSymbol = symbol.ContainingType;
 
                if (symbol.DeclaredAccessibility == Accessibility.Private)
                {
                    await EnqueueWorkItemAsync(document, symbol).ConfigureAwait(false);
 
                    Logger.Log(FunctionId.WorkCoordinator_SemanticChange_EnqueueFromMember, symbol.Name);
                    return true;
                }
 
                if (typeSymbol == null)
                {
                    return false;
                }
 
                return await TryEnqueueFromTypeAsync(document, typeSymbol).ConfigureAwait(false);
            }
 
            private Task EnqueueWorkItemAsync(Document document, ISymbol symbol)
                => EnqueueWorkItemAsync(document, symbol.ContainingType != null ? symbol.ContainingType.Locations : symbol.Locations);
 
            private async Task EnqueueWorkItemAsync(Document thisDocument, ImmutableArray<Location> locations)
            {
                var solution = thisDocument.Project.Solution;
                var projectId = thisDocument.Id.ProjectId;
 
                foreach (var location in locations)
                {
                    Debug.Assert(location.IsInSource);
 
                    var documentId = solution.GetDocumentId(location.SourceTree, projectId);
                    if (documentId == null || thisDocument.Id == documentId)
                        continue;
 
                    await _processor.EnqueueWorkItemAsync(solution.GetRequiredProject(documentId.ProjectId), documentId, document: null).ConfigureAwait(false);
                }
            }
 
            private static bool IsInternal(ISymbol symbol)
            {
                return symbol.DeclaredAccessibility is Accessibility.Internal or
                       Accessibility.ProtectedAndInternal or
                       Accessibility.ProtectedOrInternal;
            }
 
            private static bool IsType(ISymbol symbol)
                => symbol.Kind == SymbolKind.NamedType;
 
            private static bool IsMember(ISymbol symbol)
            {
                return symbol.Kind is SymbolKind.Event or
                       SymbolKind.Field or
                       SymbolKind.Method or
                       SymbolKind.Property;
            }
 
            private void EnqueueFullProjectDependency(Project project, IAssemblySymbol? internalVisibleToAssembly = null)
            {
                var self = project.Id;
 
                // if there is no hint (this can happen for cases such as solution/project load and etc), 
                // we can postpone it even further
                if (internalVisibleToAssembly == null)
                {
                    _processor.Enqueue(self, needDependencyTracking: true);
                    return;
                }
 
                // most likely we got here since we are called due to typing.
                // calculate dependency here and register each affected project to the next pipe line
                var solution = project.Solution;
                foreach (var projectId in GetProjectsToAnalyze(solution, self))
                {
                    var otherProject = solution.GetProject(projectId);
                    if (otherProject == null)
                        continue;
 
                    if (otherProject.TryGetCompilation(out var compilation))
                    {
                        var assembly = compilation.Assembly;
                        if (assembly != null && !assembly.IsSameAssemblyOrHasFriendAccessTo(internalVisibleToAssembly))
                            continue;
                    }
 
                    _processor.Enqueue(projectId);
                }
 
                Logger.Log(FunctionId.WorkCoordinator_SemanticChange_FullProjects, internalVisibleToAssembly == null ? "full" : "internals");
            }
 
            public void Enqueue(Project project, DocumentId documentId, Document? document, SyntaxPath? changedMember)
            {
                UpdateLastAccessTime();
 
                using (_workGate.DisposableWait(CancellationToken))
                {
                    if (_pendingWork.TryGetValue(documentId, out var data))
                    {
                        // create new async token and dispose old one.
                        var newAsyncToken = Listener.BeginAsyncOperation(nameof(Enqueue), tag: _registration.Services);
                        data.AsyncToken.Dispose();
 
                        _pendingWork[documentId] = new UnitTestingData(project, documentId, document, data.ChangedMember == changedMember ? changedMember : null, newAsyncToken);
                        return;
                    }
 
                    _pendingWork.Add(documentId, new UnitTestingData(project, documentId, document, changedMember, Listener.BeginAsyncOperation(nameof(Enqueue), tag: _registration.Services)));
                    _gate.Release();
                }
 
                Logger.Log(FunctionId.WorkCoordinator_SemanticChange_Enqueue, s_enqueueLogger, Environment.TickCount, documentId, changedMember != null);
            }
 
            private static TValue DequeueWorker<TKey, TValue>(SemaphoreSlim gate, Dictionary<TKey, TValue> map, CancellationToken cancellationToken)
                where TKey : notnull
            {
                using (gate.DisposableWait(cancellationToken))
                {
                    var first = default(KeyValuePair<TKey, TValue>);
                    foreach (var kv in map)
                    {
                        first = kv;
                        break;
                    }
 
                    // this is only one that removes data from the queue. so, it should always succeed
                    var result = map.Remove(first.Key);
                    Debug.Assert(result);
 
                    return first.Value;
                }
            }
 
            private static void ClearQueueWorker<TKey, TValue>(SemaphoreSlim gate, Dictionary<TKey, TValue> map, Func<TValue, IDisposable> disposerSelector)
                where TKey : notnull
            {
                using (gate.DisposableWait(CancellationToken.None))
                {
                    foreach (var (_, data) in map)
                    {
                        disposerSelector?.Invoke(data)?.Dispose();
                    }
 
                    map.Clear();
                }
            }
 
            private static IEnumerable<ProjectId> GetProjectsToAnalyze(Solution solution, ProjectId projectId)
            {
                var graph = solution.GetProjectDependencyGraph();
 
                // Reanalyze direct dependencies only as reanalyzing all transitive dependencies is very expensive.
                return graph.GetProjectsThatDirectlyDependOnThisProject(projectId).Concat(projectId);
            }
 
            private readonly struct UnitTestingData(Project project, DocumentId documentId, Document? document, SyntaxPath? changedMember, IAsyncToken asyncToken)
            {
                private readonly DocumentId _documentId = documentId;
                private readonly Document? _document = document;
 
                public readonly Project Project = project;
                public readonly SyntaxPath? ChangedMember = changedMember;
                public readonly IAsyncToken AsyncToken = asyncToken;
 
                public Document GetRequiredDocument()
                    => UnitTestingWorkCoordinator.GetRequiredDocument(Project, _documentId, _document);
            }
 
            private sealed class UnitTestingProjectProcessor : UnitTestingIdleProcessor
            {
                private static readonly Func<int, ProjectId, string> s_enqueueLogger = (t, i) => string.Format("[{0}] {1}", t, i.ToString());
 
                private readonly SemaphoreSlim _gate;
 
                private readonly UnitTestingRegistration _registration;
                private readonly UnitTestingIncrementalAnalyzerProcessor _processor;
 
                private readonly SemaphoreSlim _workGate = new(initialCount: 1);
                private readonly Dictionary<ProjectId, UnitTestingData> _pendingWork = [];
 
                public UnitTestingProjectProcessor(
                    IAsynchronousOperationListener listener,
                    UnitTestingRegistration registration,
                    UnitTestingIncrementalAnalyzerProcessor processor,
                    TimeSpan backOffTimeSpan,
                    CancellationToken cancellationToken)
                    : base(listener, backOffTimeSpan, cancellationToken)
                {
                    _registration = registration;
                    _processor = processor;
 
                    _gate = new SemaphoreSlim(initialCount: 0);
 
                    Start();
 
                    // Register a clean-up task to ensure pending work items are flushed from the queue if they will
                    // never be processed.
                    AsyncProcessorTask.ContinueWith(
                        _ => ClearQueueWorker(_workGate, _pendingWork, data => data.AsyncToken),
                        CancellationToken.None,
                        TaskContinuationOptions.ExecuteSynchronously,
                        TaskScheduler.Default);
                }
 
                protected override void OnPaused()
                {
                }
 
                public void Enqueue(ProjectId projectId, bool needDependencyTracking = false)
                {
                    UpdateLastAccessTime();
 
                    using (_workGate.DisposableWait(CancellationToken))
                    {
                        // the project is already in the queue. nothing needs to be done
                        if (_pendingWork.ContainsKey(projectId))
                        {
                            return;
                        }
 
                        var data = new UnitTestingData(projectId, needDependencyTracking, Listener.BeginAsyncOperation(nameof(Enqueue), tag: _registration.Services));
 
                        _pendingWork.Add(projectId, data);
                        _gate.Release();
                    }
 
                    Logger.Log(FunctionId.WorkCoordinator_Project_Enqueue, s_enqueueLogger, Environment.TickCount, projectId);
                }
 
                public async Task EnqueueWorkItemAsync(Project project, DocumentId documentId, Document? document)
                {
                    // we are shutting down
                    CancellationToken.ThrowIfCancellationRequested();
 
                    // call to this method is serialized. and only this method does the writing.
                    var priorityService = project.GetLanguageService<IUnitTestingWorkCoordinatorPriorityService>();
                    var isLowPriority = priorityService != null && await priorityService.IsLowPriorityAsync(GetRequiredDocument(project, documentId, document), CancellationToken).ConfigureAwait(false);
 
                    _processor.Enqueue(
                        new UnitTestingWorkItem(documentId, project.Language, UnitTestingInvocationReasons.SemanticChanged,
                            isLowPriority, activeMember: null, Listener.BeginAsyncOperation(nameof(EnqueueWorkItemAsync), tag: EnqueueItem)));
                }
 
                protected override Task WaitAsync(CancellationToken cancellationToken)
                    => _gate.WaitAsync(cancellationToken);
 
                protected override async Task ExecuteAsync()
                {
                    var data = Dequeue();
 
                    using (data.AsyncToken)
                    {
                        var project = _registration.GetSolutionToAnalyze().GetProject(data.ProjectId);
                        if (project == null)
                        {
                            return;
                        }
 
                        if (!data.NeedDependencyTracking)
                        {
                            await EnqueueWorkItemAsync(project).ConfigureAwait(false);
                            return;
                        }
 
                        // do dependency tracking here with current solution
                        var solution = _registration.GetSolutionToAnalyze();
                        foreach (var projectId in GetProjectsToAnalyze(solution, data.ProjectId))
                        {
                            project = solution.GetProject(projectId);
                            await EnqueueWorkItemAsync(project).ConfigureAwait(false);
                        }
                    }
                }
 
                private UnitTestingData Dequeue()
                    => DequeueWorker(_workGate, _pendingWork, CancellationToken);
 
                private async Task EnqueueWorkItemAsync(Project? project)
                {
                    if (project == null)
                        return;
 
                    foreach (var documentId in project.DocumentIds)
                        await EnqueueWorkItemAsync(project, documentId, document: null).ConfigureAwait(false);
                }
 
                private readonly struct UnitTestingData(ProjectId projectId, bool needDependencyTracking, IAsyncToken asyncToken)
                {
                    public readonly IAsyncToken AsyncToken = asyncToken;
                    public readonly ProjectId ProjectId = projectId;
                    public readonly bool NeedDependencyTracking = needDependencyTracking;
                }
            }
        }
    }
}