File: Workspace\IsolatedAnalyzerReferenceSet.Core.cs
Web Access
Project: src\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// 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.
 
#if NET
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Serialization;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis;
 
/// <summary>
/// A set of <see cref="IsolatedAnalyzerFileReference"/>s and their associated shadow copy loader (which has its own
/// <see cref="AssemblyLoadContext"/>).  As long as something is keeping this set alive, the ALC will be kept alive.
/// Once this set is dropped, the loader will be explicitly <see cref="IDisposable.Dispose"/>'d in its finalizer.
/// </summary>
internal sealed partial class IsolatedAnalyzerReferenceSet
{
    private static readonly ObjectPool<Dictionary<string, Guid>> s_pathToMvidMapPool = new(() => new(SolutionState.FilePathComparer));
 
    /// <summary>
    /// Gate around virtually all the data (static and instance) in this type to ensure it is only accessed and updated
    /// atomically.  Specifically, we want to ensure that the static data (<see cref="s_checksumToReferenceSet"/> and
    /// <see cref="s_lastCreatedAnalyzerReferenceSet"/>) is only updated atomically.  And, also, when we're looking at
    /// <see cref="s_lastCreatedAnalyzerReferenceSet"/> to mutate it, that it itself is only mutated atomically.
    /// </summary>
    private static readonly SemaphoreSlim s_gate = new(initialCount: 1);
 
    /// <summary>
    /// Mapping from checksum for a particular set of assembly references, to the dedicated ALC and actual assembly
    /// references corresponding to it.  As long as it is alive, we will try to reuse what is in memory.  But once it is
    /// dropped from memory, we'll clean things up and produce a new one.
    /// </summary>
    /// <remarks>Guarded by <see cref="s_gate"/></remarks>
    private static readonly Dictionary<Checksum, WeakReference<IsolatedAnalyzerReferenceSet>> s_checksumToReferenceSet = [];
 
    /// <summary>
    /// The current isolated reference set we're trying to use to load analyzers in.  We'll keep using the same set
    /// until we run into a conflict that prevents it from being used.  At that point we'll create a new set and use
    /// that one from that point on (and so on).  The old sets will stay alive as long as any AnalyzerReference (or
    /// ISourceGenerator or DiagnosticAnalyzer from it) is alive.  Once all of those are garbage collected, the set
    /// itself can be collected.  At that point it will release it's assembly load context, freeing everything.
    /// </summary>
    /// <remarks>
    /// To determine if we have a conflict, we keep track of the mvid of each <see cref="AnalyzerFileReference"/> when
    /// the set was created.  When trying to reuse the set, we see if any of the references we now have has a different
    /// mvid from that creation point.  If so, we have a conflict and we make a new set.
    /// </remarks>
    /// <remarks>Guarded by <see cref="s_gate"/></remarks>
    private static IsolatedAnalyzerReferenceSet? s_lastCreatedAnalyzerReferenceSet;
 
    private static int s_sweepCount = 0;
 
    /// <summary>
    /// Dedicated loader with its own dedicated ALC that all analyzer references will load their <see
    /// cref="System.Reflection.Assembly"/>s within.
    /// </summary>
    private readonly IAnalyzerAssemblyLoaderInternal _shadowCopyLoader;
 
    /// <summary>
    /// Mapping from <see cref="AnalyzerFileReference.FullPath"/> to the mvid for that reference with this isolated
    /// reference set.  As long as the references we see at those paths have the same mvids, we'll keep using this 
    /// set instance.
    /// </summary>
    /// <remarks>Guarded by <see cref="s_gate"/>.  Note that while the gate is static, this is instance data on the <see
    /// cref="s_lastCreatedAnalyzerReferenceSet"/>.  And we only want to mutate that instance data from one thread at a
    /// time.</remarks>
    private readonly Dictionary<string, Guid> _analyzerFileReferencePathToMvid = [];
 
    /// <summary>
    /// Mapping from synchronization checksum to the isolated analyzer references created for them.  Used to help oop
    /// synchronization retrieve the same set if multiple projects have the same analyzer references (a common case).
    /// </summary>
    /// <remarks>Guarded by <see cref="s_gate"/>.  Note that while the gate is static, this is instance data on the <see
    /// cref="s_lastCreatedAnalyzerReferenceSet"/>.  And we only want to mutate that instance data from one thread at a
    /// time.
    /// 
    /// Stored as an <see cref="IReadOnlyList{T}"/> so this can safely be used as a key in caches that map from a list
    /// of items to some value (for example in an <see cref="ConditionalWeakTable{TKey, TValue}"/>.
    /// </remarks>
    private readonly Dictionary<Checksum, ImmutableArray<AnalyzerReference>> _analyzerReferences = [];
 
    private IsolatedAnalyzerReferenceSet(
        IAnalyzerAssemblyLoaderProvider provider)
    {
        // Make a fresh loader that uses that ALC that will ensure these references are properly isolated.
        _shadowCopyLoader = provider.CreateNewShadowCopyLoader();
    }
 
    /// <summary>
    /// When the last reference this to this reference set finally goes away, it is safe to unload our loader+ALC.
    /// </summary>
    ~IsolatedAnalyzerReferenceSet()
    {
        _shadowCopyLoader.Dispose();
    }
 
    private static void GarbageCollectReleaseReferences_NoLock()
    {
        Contract.ThrowIfTrue(s_gate.CurrentCount != 0, "Lock must be held");
 
        // When we've done some reasonable number of mutations to the dictionary, we'll do a sweep to see if there are
        // entries we can remove.
        //
        // Note: the value 128 was chosen with absolutely no data.  It was to avoid doing linear sweeps on every change,
        // while also still running reasonably often to clear out old entries.
        //
        // Note: clearing out entries isn't critical.  It's really just a KeyValuePair<Checksum, WeakRef(null)>.  So
        // they aren't really large at all.  But it seemed nice to ensure that the dictionary doesn't grow in an
        // unbounded fashion, even if the entries are small.
        if (++s_sweepCount % 128 == 0)
            return;
 
        using var _ = ArrayBuilder<Checksum>.GetInstance(out var checksumsToRemove);
 
        foreach (var (checksum, weakReference) in s_checksumToReferenceSet)
        {
            if (!weakReference.TryGetTarget(out var referenceSet) ||
                referenceSet is null)
            {
                checksumsToRemove.Add(checksum);
            }
        }
 
        foreach (var checksum in checksumsToRemove)
            s_checksumToReferenceSet.Remove(checksum);
    }
 
    private ImmutableArray<AnalyzerReference> GetAnalyzerReferences(Checksum checksum)
        => _analyzerReferences[checksum];
 
    private static AnalyzerReference GetUnderlyingAnalyzerReference(AnalyzerReference initialReference)
        => initialReference is IsolatedAnalyzerFileReference isolatedReference
            ? isolatedReference.UnderlyingAnalyzerFileReference
            : initialReference;
 
    private void AddReferences(
        Checksum checksum,
        ImmutableArray<AnalyzerReference> references,
        Dictionary<string, Guid> filePathToMvid)
    {
        Contract.ThrowIfTrue(_analyzerReferences.ContainsKey(checksum));
        Contract.ThrowIfTrue(s_gate.CurrentCount != 0, "Lock must be held");
 
        var builder = new FixedSizeArrayBuilder<AnalyzerReference>(references.Length);
        foreach (var initialReference in references)
        {
            // If we already have an analyzer reference isolated to another ALC.  Fish out its underlying reference so
            // we can rewrap it for the new ALC we're creating.  We don't want to continually wrap layers of isolated
            // objects.
            var analyzerReference = GetUnderlyingAnalyzerReference(initialReference);
 
            // If we have an existing file reference, make a new one with a different loader/ALC.  Otherwise, it's some
            // other analyzer reference we don't understand (like an in-memory one created in tests).
            var finalReference = analyzerReference is AnalyzerFileReference { FullPath: var fullPath }
                ? new IsolatedAnalyzerFileReference(this, new AnalyzerFileReference(fullPath, _shadowCopyLoader))
                : initialReference;
 
            builder.Add(finalReference);
        }
 
        _analyzerReferences.Add(checksum, builder.MoveToImmutable());
 
        // Ensure we know about all the mvids of these analyzer references as well.  As long as they don't change, we
        // can keep reusing this isolated set.
        foreach (var (filePath, mvid) in filePathToMvid)
        {
            Contract.ThrowIfTrue(HasConflict(filePath, mvid));
            _analyzerFileReferencePathToMvid[filePath] = mvid;
        }
    }
 
    private bool HasConflicts(Dictionary<string, Guid> filePathToMvid)
    {
        foreach (var (filePath, mvid) in filePathToMvid)
        {
            if (HasConflict(filePath, mvid))
                return true;
        }
 
        return false;
    }
 
    private bool HasConflict(string filePath, Guid mvid)
        => _analyzerFileReferencePathToMvid.TryGetValue(filePath, out var existingMvid) && existingMvid != mvid;
 
    public static async partial ValueTask<ImmutableArray<AnalyzerReference>> CreateIsolatedAnalyzerReferencesAsync(
        bool useAsync,
        ImmutableArray<AnalyzerReference> references,
        SolutionServices solutionServices,
        CancellationToken cancellationToken)
    {
        // Fallback to stock behavior if the reloading option is disabled.
        var optionsService = solutionServices.GetRequiredService<IWorkspaceConfigurationService>();
        if (!optionsService.Options.ReloadChangedAnalyzerReferences)
            return await DefaultCreateIsolatedAnalyzerReferencesAsync(references).ConfigureAwait(false);
 
        if (references.Length == 0)
            return [];
 
        var serializerService = solutionServices.GetRequiredService<ISerializerService>();
        var analyzerChecksums = ChecksumCache.GetOrCreateChecksumCollection(references, serializerService, cancellationToken);
 
        return await CreateIsolatedAnalyzerReferencesAsync(
            useAsync,
            analyzerChecksums,
            solutionServices,
            () => Task.FromResult(references),
            cancellationToken).ConfigureAwait(false);
    }
 
    public static async partial ValueTask<ImmutableArray<AnalyzerReference>> CreateIsolatedAnalyzerReferencesAsync(
        bool useAsync,
        ChecksumCollection analyzerChecksums,
        SolutionServices solutionServices,
        Func<Task<ImmutableArray<AnalyzerReference>>> getReferencesAsync,
        CancellationToken cancellationToken)
    {
        // Fallback to stock behavior if the reloading option is disabled.
        var optionsService = solutionServices.GetRequiredService<IWorkspaceConfigurationService>();
        if (!optionsService.Options.ReloadChangedAnalyzerReferences)
            return await DefaultCreateIsolatedAnalyzerReferencesAsync(getReferencesAsync).ConfigureAwait(false);
 
        if (analyzerChecksums.Children.Length == 0)
            return [];
 
        var checksum = analyzerChecksums.Checksum;
 
        // Note: this method will end up fetching or creating an IsolatedAssemblyReferenceSet for this checksum.  
        // We'll then return the AnalyzerReferences from within it.  These AnalyzerReferences (which will normally all
        // be IsolatedAnalyzerFileReferences) will themselves root the IsolatedAssemblyReferenceSet, as will all the
        // DiagnosticAnalyzers and ISourceGenerators returned down the line from the IsolatedAnalyzerFileReferences.
 
        // First, see if these were already computed and stored.
        using (useAsync
            ? await s_gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false)
            : s_gate.DisposableWait(cancellationToken))
        {
            if (s_checksumToReferenceSet.TryGetValue(checksum, out var weakIsolatedReferenceSet) &&
                weakIsolatedReferenceSet.TryGetTarget(out var isolatedAssemblyReferenceSet))
            {
                return isolatedAssemblyReferenceSet.GetAnalyzerReferences(checksum);
            }
        }
 
        // Not already stored.  Fetch the actual references.
        var analyzerReferences = await getReferencesAsync().ConfigureAwait(false);
        var assemblyLoaderProvider = solutionServices.GetRequiredService<IAnalyzerAssemblyLoaderProvider>();
 
        using (useAsync
           ? await s_gate.DisposableWaitAsync(cancellationToken).ConfigureAwait(false)
           : s_gate.DisposableWait(cancellationToken))
        {
            // Check again to see if another thread beat us.
            if (s_checksumToReferenceSet.TryGetValue(checksum, out var weakIsolatedReferenceSet) &&
                weakIsolatedReferenceSet.TryGetTarget(out var isolatedAssemblyReferenceSet))
            {
                return isolatedAssemblyReferenceSet.GetAnalyzerReferences(checksum);
            }
 
            // This set of references have not been computed yet.  We have three options:
            //
            // 1. These are the very first time we're seeing any references.  Create a fresh isolated set, and add these new
            //    reference to it.  New references can also be added to this in the future as long as there are no conflicts
            //    with what's in the set already.
            //
            // 2. We have already created an isolated set.  If these new analyzer references conflict with any in the
            //    current set, we create a new set for these and future references to go into.
            //
            // 3. Otherwise, we have an existing set and it has no conflicts.  Add to it directly.
 
            // Figure out the mvids for all the analyzer references we're being asked about.
            using var _ = s_pathToMvidMapPool.GetPooledObject(out var pathToMvidMap);
            PopulateFilePathToMvidMap(analyzerReferences, pathToMvidMap);
 
            // Create initial set if we don't have one.
            s_lastCreatedAnalyzerReferenceSet ??= new(assemblyLoaderProvider);
 
            // If there's an mvid conflict, create a new set.
            if (s_lastCreatedAnalyzerReferenceSet.HasConflicts(pathToMvidMap))
                s_lastCreatedAnalyzerReferenceSet = new(assemblyLoaderProvider);
 
            // Now add these references/mvids to the isolated alc.
            s_lastCreatedAnalyzerReferenceSet.AddReferences(checksum, analyzerReferences, pathToMvidMap);
            s_checksumToReferenceSet[checksum] = new(s_lastCreatedAnalyzerReferenceSet);
 
            // Do some cleaning up of old dictionary entries that are no longer in use.
            GarbageCollectReleaseReferences_NoLock();
 
            return s_lastCreatedAnalyzerReferenceSet.GetAnalyzerReferences(checksum);
        }
 
        static void PopulateFilePathToMvidMap(
            ImmutableArray<AnalyzerReference> analyzerReferences,
            Dictionary<string, Guid> pathToMvidMap)
        {
            foreach (var initialReference in analyzerReferences)
            {
                // Can ignore all other analyzer reference types.  This is only about analyzer references changing on disk.
                var analyzerReference = GetUnderlyingAnalyzerReference(initialReference);
                if (analyzerReference is AnalyzerFileReference analyzerFileReference)
                    pathToMvidMap[analyzerFileReference.FullPath] = TryGetFileReferenceMvid(analyzerFileReference.FullPath);
            }
        }
    }
}
 
#endif