File: Diagnostics\Service\DiagnosticAnalyzerService_DeprioritizationCandidates.cs
Web Access
Project: 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.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Diagnostics;

internal sealed partial class DiagnosticAnalyzerService
{
    /// <summary>
    /// A cache from DiagnosticAnalyzer to whether or not it is a candidate for deprioritization when lightbulbs
    /// compute diagnostics for a particular priority class.  Note: as this caches data, it may technically be
    /// inaccurate as things change in the system.  For example, this is based on the registered actions made
    /// by an analyzer.  Hypothetically, such an analyzer might register different actions based on on things
    /// like appearing in a different language's compilation, or a compilation with different references, etc.
    /// We accept that this cache may be inaccurate in such scenarios as they are likely rare, and this only
    /// serves as a simple heuristic to order analyzer execution.  If wrong, it's not a major deal.
    /// Each Lazy wraps a single non-cancelable computation task, so request cancellation only cancels that
    /// request's wait and cannot restart the analyzer initialization.
    /// </summary>
    private static readonly ConditionalWeakTable<DiagnosticAnalyzer, Lazy<Task<ImmutableHashSet<string>?>>> s_analyzerToDeprioritizedDiagnosticIds = new();

    private async Task<bool> IsDeprioritizedAnalyzerAsync(
        Project project, DiagnosticAnalyzer analyzer, CancellationToken cancellationToken)
    {
        await PopulateDeprioritizedDiagnosticIdMapAsync(project, cancellationToken).ConfigureAwait(false);

        return await GetCachedDeprioritizedDiagnosticIdsAsync(analyzer, cancellationToken).ConfigureAwait(false) != null;
    }

    private async ValueTask PopulateDeprioritizedDiagnosticIdMapAsync(Project project, CancellationToken cancellationToken)
    {
        await IsAnyDiagnosticIdDeprioritizedAsync(project, diagnosticIds: [], cancellationToken).ConfigureAwait(false);
    }

    public async Task<bool> IsAnyDeprioritizedDiagnosticIdInProcessAsync(
        Project project, ImmutableArray<string> diagnosticIds, CancellationToken cancellationToken)
    {
        CompilationWithAnalyzers? compilationWithAnalyzers = null;

        var analyzers = GetProjectAnalyzers_OnlyCallInProcess(project);
        foreach (var analyzer in analyzers)
        {
            if (!s_analyzerToDeprioritizedDiagnosticIds.TryGetValue(analyzer, out var lazyDeprioritizedIds))
            {
                if (compilationWithAnalyzers is null)
                {
                    compilationWithAnalyzers = await GetOrCreateCompilationWithAnalyzers_OnlyCallInProcessAsync(
                        project, analyzers, GetOrCreateHostAnalyzerInfo_OnlyCallInProcess(project), this.CrashOnAnalyzerException, cancellationToken).ConfigureAwait(false);
                }

                // Concurrent cache misses can create multiple candidate lazies, but only the lazy stored in the
                // ConditionalWeakTable is evaluated. ExecutionAndPublication then ensures that every request through
                // that lazy shares the same task.
#pragma warning disable VSTHRD011 // The value factory only queues work; callers never synchronously wait on its task.
                var createdLazy = new Lazy<Task<ImmutableHashSet<string>?>>(
                    () => Task.Run(
                        () => ComputeDeprioritizedDiagnosticIdsAsync(analyzer),
                        CancellationToken.None),
                    LazyThreadSafetyMode.ExecutionAndPublication);
#pragma warning restore VSTHRD011 // The value factory only queues work; callers never synchronously wait on its task.
                lazyDeprioritizedIds = s_analyzerToDeprioritizedDiagnosticIds.GetValue(analyzer, _ => createdLazy);

                if (ReferenceEquals(lazyDeprioritizedIds, createdLazy))
                {
                    var createdComputationTask = GetLazyValueAsync(createdLazy, CancellationToken.None);
                    _ = createdComputationTask.ContinueWith(
                        task =>
                        {
                            // The exception was already reported inside the computation. If every caller canceled its
                            // wait, nobody else will observe the shared task's fault, so observe it here. Remove any
                            // faulted or canceled computation so a later lookup can retry.
                            if (task.IsFaulted)
                                _ = task.Exception;

                            s_analyzerToDeprioritizedDiagnosticIds.Remove(analyzer);
                        },
                        CancellationToken.None,
                        TaskContinuationOptions.NotOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously,
                        TaskScheduler.Default);
                }
            }

            var deprioritizedIds = await GetLazyValueAsync(lazyDeprioritizedIds, cancellationToken).ConfigureAwait(false);
            if (deprioritizedIds != null)
            {
                foreach (var id in diagnosticIds)
                {
                    if (deprioritizedIds.Contains(id))
                        return true;
                }
            }
        }

        return false;

        async Task<ImmutableHashSet<string>?> ComputeDeprioritizedDiagnosticIdsAsync(DiagnosticAnalyzer analyzer)
        {
            try
            {
                // We deprioritize SymbolStart/End and SemanticModel analyzers from 'Normal' to 'Low' priority bucket,
                // as these are computationally more expensive.
                // Note that we never de-prioritize compiler analyzer, even though it registers a SemanticModel action.
                if (compilationWithAnalyzers == null ||
                    analyzer.IsWorkspaceDiagnosticAnalyzer() ||
                    analyzer.IsCompilerAnalyzer())
                {
                    return null;
                }

                var telemetryInfo = await compilationWithAnalyzers.GetAnalyzerTelemetryInfoAsync(analyzer, CancellationToken.None).ConfigureAwait(false);
                if (telemetryInfo == null)
                    return null;

                if (telemetryInfo is { SymbolStartActionsCount: 0, SemanticModelActionsCount: 0 })
                    return null;

                return [.. analyzer.SupportedDiagnostics.Select(d => d.Id)];
            }
            // Report while the original stack is unwinding, then let the same exception fault the shared task so
            // every caller already waiting on it observes the failure.
            catch (Exception ex) when (FatalError.ReportAndPropagate(ex))
            {
                throw ExceptionUtilities.Unreachable();
            }
        }
    }

    private static async Task<ImmutableHashSet<string>?> GetCachedDeprioritizedDiagnosticIdsAsync(
        DiagnosticAnalyzer analyzer, CancellationToken cancellationToken)
    {
        Contract.ThrowIfFalse(s_analyzerToDeprioritizedDiagnosticIds.TryGetValue(analyzer, out var lazy));

        return await GetLazyValueAsync(lazy, cancellationToken).ConfigureAwait(false);
    }

    private static Task<T> GetLazyValueAsync<T>(Lazy<Task<T>> lazy, CancellationToken cancellationToken)
    {
#pragma warning disable VSTHRD011 // This lazy intentionally owns one shared task; callers never block synchronously.
        var task = lazy.Value;
#pragma warning restore VSTHRD011 // This lazy intentionally owns one shared task; callers never block synchronously.

#if NET
        return task.WaitAsync(cancellationToken);
#else
        // Compatibility implementation of Task.WaitAsync(CancellationToken), which is unavailable on netstandard2.0.
        // The continuation is only a proxy for this caller: its token can cancel the wait without canceling or restarting
        // the shared task. Returning and unwrapping the antecedent preserves its exact completion state.
        return !cancellationToken.CanBeCanceled || task.IsCompleted
            ? task
            : task.ContinueWith(
                static task => task,
                cancellationToken,
                TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default).Unwrap();
#endif
    }
}