File: DiagnosticAnalyzer\AnalyzerAssemblyLoader.Core.cs
Web Access
Project: src\src\Compilers\Core\Portable\Microsoft.CodeAnalysis.csproj (Microsoft.CodeAnalysis)
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.PooledObjects;
 
namespace Microsoft.CodeAnalysis
{
    internal sealed partial class AnalyzerAssemblyLoader
    {
        internal static IAnalyzerAssemblyResolver DiskAnalyzerAssemblyResolver => DiskResolver.Instance;
        internal static IAnalyzerAssemblyResolver StreamAnalyzerAssemblyResolver => StreamResolver.Instance;
 
        /// <summary>
        /// Map of resolved directory paths to load contexts that manage their assemblies.
        /// </summary>
        private readonly Dictionary<string, DirectoryLoadContext> _loadContextByDirectory = new Dictionary<string, DirectoryLoadContext>(GeneratedPathComparer);
 
        public IAnalyzerAssemblyResolver CompilerAnalyzerAssemblyResolver { get; }
        public AssemblyLoadContext CompilerLoadContext { get; }
        public ImmutableArray<IAnalyzerAssemblyResolver> AnalyzerAssemblyResolvers { get; }
 
        internal AnalyzerAssemblyLoader()
            : this(pathResolvers: [])
        {
        }
 
        internal AnalyzerAssemblyLoader(ImmutableArray<IAnalyzerPathResolver> pathResolvers)
            : this(pathResolvers, assemblyResolvers: [DiskAnalyzerAssemblyResolver], compilerLoadContext: null)
        {
        }
 
        /// <summary>
        /// Create a new <see cref="AnalyzerAssemblyLoader"/> with the given resolvers.
        /// </summary>
        /// <param name="compilerLoadContext">This is the <see cref="AssemblyLoadContext"/> where the compiler resides. This parameter
        /// is primarily used for testing purposes but is also useful in hosted scenarios where the compiler may be loaded outside
        /// the default context. When null this will be the <see cref="AssemblyLoadContext"/> the compiler currently resides
        /// in </param>
        /// <exception cref="ArgumentException"></exception>
        internal AnalyzerAssemblyLoader(
            ImmutableArray<IAnalyzerPathResolver> pathResolvers,
            ImmutableArray<IAnalyzerAssemblyResolver> assemblyResolvers,
            AssemblyLoadContext? compilerLoadContext)
        {
            if (assemblyResolvers.Length == 0)
            {
                throw new ArgumentException("Cannot be empty", nameof(assemblyResolvers));
            }
 
            CompilerLoadContext = compilerLoadContext ?? AssemblyLoadContext.GetLoadContext(typeof(SyntaxTree).GetTypeInfo().Assembly)!;
            CompilerAnalyzerAssemblyResolver = new CompilerResolver(CompilerLoadContext);
            AnalyzerPathResolvers = pathResolvers;
 
            // The CompilerAnalyzerAssemblyResolver must be first here as the host is _always_ given a chance
            // to resolve the assembly before any other resolver. This is crucial to allow for items like
            // unification of System.Collections.Immutable or other core assemblies for a host.
            AnalyzerAssemblyResolvers = [CompilerAnalyzerAssemblyResolver, .. assemblyResolvers];
        }
 
        public bool IsHostAssembly(Assembly assembly)
        {
            CheckIfDisposed();
 
            var alc = AssemblyLoadContext.GetLoadContext(assembly);
            return alc == CompilerLoadContext || alc == AssemblyLoadContext.Default;
        }
 
        private partial Assembly Load(AssemblyName assemblyName, string resolvedPath)
        {
            DirectoryLoadContext? loadContext;
 
            var fullDirectoryPath = Path.GetDirectoryName(resolvedPath) ?? throw new ArgumentException(message: null, paramName: nameof(resolvedPath));
            lock (_guard)
            {
                if (!_loadContextByDirectory.TryGetValue(fullDirectoryPath, out loadContext))
                {
                    CodeAnalysisEventSource.Log.CreateAssemblyLoadContext(fullDirectoryPath);
                    loadContext = new DirectoryLoadContext(fullDirectoryPath, this);
                    _loadContextByDirectory[fullDirectoryPath] = loadContext;
                }
            }
 
            return loadContext.LoadFromAssemblyName(assemblyName);
        }
 
        /// <summary>
        /// Is this a registered analyzer file path that the loader knows about.
        /// 
        /// Note: this is using resolved paths, not the original file paths
        /// </summary>
        private bool IsRegisteredAnalyzerPath(string resolvedPath)
        {
            CheckIfDisposed();
 
            lock (_guard)
            {
                return _resolvedToOriginalPathMap.ContainsKey(resolvedPath);
            }
        }
 
        private string? GetAssemblyLoadPath(AssemblyName assemblyName, string directory)
        {
            // Prefer registered dependencies in the same directory first.
            var simpleName = assemblyName.Name!;
            var assemblyPath = Path.Combine(directory, simpleName + ".dll");
            if (IsRegisteredAnalyzerPath(assemblyPath))
            {
                return assemblyPath;
            }
 
            // Next if this is a resource assembly for a known assembly then load it from the 
            // appropriate sub directory if it exists
            //
            // Note: when loading from disk the .NET runtime has a fallback step that will handle
            // satellite assembly loading if the call to Load(satelliteAssemblyName) fails. This
            // loader has a mode where it loads from Stream though and the runtime will not handle
            // that automatically. Rather than bifurcate our loading behavior between Disk and
            // Stream both modes just handle satellite loading directly
            if (assemblyName.CultureInfo is not null && simpleName.EndsWith(".resources", SimpleNameComparer.Comparison))
            {
                var analyzerFileName = Path.ChangeExtension(simpleName, ".dll");
                var analyzerFilePath = Path.Combine(directory, analyzerFileName);
                return GetSatelliteLoadPath(analyzerFilePath, assemblyName.CultureInfo);
            }
 
            // Next prefer registered dependencies from other directories. Ideally this would not
            // be necessary but msbuild target defaults have caused a number of customers to 
            // fall into this path. See discussion here for where it comes up
            // https://github.com/dotnet/roslyn/issues/56442
            var (_, bestResolvedPath) = GetBestResolvedPath(assemblyName);
            if (bestResolvedPath is not null)
            {
                return bestResolvedPath;
            }
 
            // No analyzer registered this dependency. Time to fail
            return null;
        }
 
        private partial bool IsMatch(AssemblyName requestedName, AssemblyName candidateName) =>
            requestedName.Name == candidateName.Name;
 
        internal DirectoryLoadContext[] GetDirectoryLoadContextsSnapshot()
        {
            CheckIfDisposed();
 
            lock (_guard)
            {
                return _loadContextByDirectory.Values.OrderBy(v => v.Directory).ToArray();
            }
        }
 
        private partial void DisposeWorker()
        {
            var contexts = ArrayBuilder<DirectoryLoadContext>.GetInstance();
            lock (_guard)
            {
                foreach (var (_, context) in _loadContextByDirectory)
                    contexts.Add(context);
 
                _loadContextByDirectory.Clear();
            }
 
            foreach (var context in contexts)
            {
                try
                {
                    context.Unload();
                    CodeAnalysisEventSource.Log.DisposeAssemblyLoadContext(context.Directory);
                }
                catch (Exception ex) when (FatalError.ReportAndCatch(ex, ErrorSeverity.Critical))
                {
                    CodeAnalysisEventSource.Log.DisposeAssemblyLoadContextException(context.Directory, ex.ToString());
                }
            }
 
            contexts.Free();
        }
 
        internal sealed class DirectoryLoadContext : AssemblyLoadContext
        {
            internal string Directory { get; }
            private readonly AnalyzerAssemblyLoader _loader;
 
            public DirectoryLoadContext(string directory, AnalyzerAssemblyLoader loader)
                : base(isCollectible: true)
            {
                Directory = directory;
                _loader = loader;
            }
 
            protected override Assembly? Load(AssemblyName assemblyName)
            {
                foreach (var resolver in _loader.AnalyzerAssemblyResolvers)
                {
                    var assembly = resolver.Resolve(_loader, assemblyName, this, Directory);
                    if (assembly is not null)
                    {
                        return assembly;
                    }
                }
 
                return null;
            }
 
            protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
            {
                var assemblyPath = Path.Combine(Directory, unmanagedDllName + ".dll");
                if (_loader.IsRegisteredAnalyzerPath(assemblyPath))
                {
                    return LoadUnmanagedDllFromPath(assemblyPath);
                }
 
                return IntPtr.Zero;
            }
        }
 
        /// <summary>
        /// A resolver which allows a passed in <see cref="AssemblyLoadContext"/> from the compiler 
        /// to control assembly resolution. This is important because there are many exchange types
        /// that need to unify across the multiple analyzer ALCs. These include common types from
        /// <c>Microsoft.CodeAnalysis.dll</c> etc, as well as platform assemblies provided by a 
        /// host such as visual studio.
        /// </summary>
        /// <remarks>
        /// This resolver essentially forces any assembly that was loaded as a 'core' part of the
        /// compiler to be shared across analyzers, and not loaded multiple times into each individual
        /// analyzer ALC, even if the analyzer itself shipped a copy of said assembly.
        /// </remarks>
        /// <param name="compilerContext">The <see cref="AssemblyLoadContext"/> that the core
        /// compiler assemblies are already loaded into.</param>
        private sealed class CompilerResolver(AssemblyLoadContext compilerContext) : IAnalyzerAssemblyResolver
        {
            private readonly AssemblyLoadContext _compilerAlc = compilerContext;
 
            public Assembly? Resolve(AnalyzerAssemblyLoader loader, AssemblyName assemblyName, AssemblyLoadContext directoryContext, string directory)
            {
                try
                {
                    return _compilerAlc.LoadFromAssemblyName(assemblyName);
                }
                catch
                {
                    // The LoadFromAssemblyName method will throw if the assembly cannot be found. Need
                    // to catch this exception and return null to satisfy the interface contract.
                    return null;
                }
            }
        }
 
        private sealed class DiskResolver : IAnalyzerAssemblyResolver
        {
            public static readonly IAnalyzerAssemblyResolver Instance = new DiskResolver();
            public Assembly? Resolve(AnalyzerAssemblyLoader loader, AssemblyName assemblyName, AssemblyLoadContext directoryContext, string directory)
            {
                var assemblyPath = loader.GetAssemblyLoadPath(assemblyName, directory);
                return assemblyPath is not null ? directoryContext.LoadFromAssemblyPath(assemblyPath) : null;
            }
        }
 
        /// <summary>
        /// This loads the assemblies from a <see cref="Stream"/> which is advantageous because it does
        /// not lock the underlying assembly on disk.
        /// </summary>
        /// <remarks>
        /// This should be avoided on Windows. Yes <see cref="DiskResolver"/> locks files on disks but it also
        /// amortizes the cost of AV scanning the assemblies. When loading from <see cref="Stream"/>
        /// the AV will scan the assembly every single time. That cost is significant and easily shows up in
        /// performance profiles.
        /// </remarks>
        private sealed class StreamResolver : IAnalyzerAssemblyResolver
        {
            public static readonly IAnalyzerAssemblyResolver Instance = new StreamResolver();
            public Assembly? Resolve(AnalyzerAssemblyLoader loader, AssemblyName assemblyName, AssemblyLoadContext directoryContext, string directory)
            {
                var assemblyPath = loader.GetAssemblyLoadPath(assemblyName, directory);
                if (assemblyPath is null)
                {
                    return null;
                }
 
                using var stream = File.Open(assemblyPath, FileMode.Open, FileAccess.Read, FileShare.Read);
                return directoryContext.LoadFromStream(stream);
            }
        }
    }
}
 
#endif