File: Services\ExtensionAssemblyManager.cs
Web Access
Project: src\src\LanguageServer\Microsoft.CodeAnalysis.LanguageServer\Microsoft.CodeAnalysis.LanguageServer.csproj (Microsoft.CodeAnalysis.LanguageServer)
// 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.Collections.Immutable;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis.LanguageServer.StarredSuggestions;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.Extensions.Logging;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Services;
 
/// <summary>
/// Manages extension assembly loading.  Extensions are isolated from one another and from the host
/// using assembly load contexts per extension.  This allows extensions to:
///   1.  Load their own version of a dependencies by shipping the dependent assembly inside the extension assembly folder.
///       These assemblies will not be visible to the host or other extensions.
///   2.  Load the host version of a dependency by not shipping the dependent assembly in the extension assembly folder.
///       Useful when the extension wants to use state from the assembly setup by the host (e.g. VSTelemetry).
/// 
/// The extension load contexts are defined per directory, so if two extension dlls come from the same directory,
/// they will share the same load context.
/// 
/// A couple of additional notes:
///   1.  Only the explicitly provided extension assembly paths are loaded into the MEF catalog.  If an extension wants
///       to contribute multiple assemblies to the catalog, each assembly must be passed as an extension assembly path.
///   2.  If an extension assembly contains an analyzer, we will re-use the same extension load context to load the analyzer.
/// </summary>
internal sealed class ExtensionAssemblyManager
{
    private readonly ImmutableDictionary<string, AssemblyLoadContext> _directoryLoadContexts;
 
    private readonly ImmutableDictionary<string, AssemblyLoadContext> _assemblyFullNameToLoadContext;
 
    public ImmutableArray<string> ExtensionAssemblyPaths { get; }
 
    public ExtensionAssemblyManager(ImmutableDictionary<string, AssemblyLoadContext> directoryLoadContexts,
        ImmutableDictionary<string, AssemblyLoadContext> assemblyFullNameToLoadContext,
        ImmutableArray<string> extensionAssemblyPaths)
    {
        _directoryLoadContexts = directoryLoadContexts;
        _assemblyFullNameToLoadContext = assemblyFullNameToLoadContext;
        ExtensionAssemblyPaths = extensionAssemblyPaths;
    }
 
    public static ExtensionAssemblyManager Create(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory)
    {
        var logger = loggerFactory.CreateLogger<ExportProviderBuilder>();
        if (serverConfiguration.DevKitDependencyPath is not null)
        {
            // Ensure the Roslyn DevKit assemblies are loaded into the default ALC so that extensions can use them.
            ResolveDevKitAssemblies(serverConfiguration.DevKitDependencyPath, loggerFactory);
        }
 
        var directoryLoadContexts = new Dictionary<string, AssemblyLoadContext>(StringComparer.Ordinal);
        var assemblyFullNameToLoadContext = new Dictionary<string, AssemblyLoadContext>(StringComparer.Ordinal);
        using var _ = ArrayBuilder<string>.GetInstance(out var validExtensionAssemblies);
 
        if (serverConfiguration.StarredCompletionsPath is not null)
        {
            // HACK: Load the intellicode dll as an extension, but importantly do not add it to the valid extension assemblies set.
            // While we do want to load it into its own ALC because it comes from a different ship vehicle, we do not want it
            // to contribute to the MEF catalog / analyzers as a 'normal' extension would.  Instead it gets reflection loaded elsewhere.
            //
            // We should migrate the intellicode completion provider to be a normal extension component with MEF provided parts,
            // but it requires changes to the intellicode vscode extension and here to access our IServiceBroker instance via MEF.
            var starredCompletionsComponentDll = StarredCompletionAssemblyHelper.GetStarredCompletionAssemblyPath(serverConfiguration.StarredCompletionsPath);
            Contract.ThrowIfFalse(TryGetOrCreateLoadContext(starredCompletionsComponentDll));
        }
 
        foreach (var assemblyFilePath in serverConfiguration.ExtensionAssemblyPaths)
        {
            if (TryGetOrCreateLoadContext(assemblyFilePath))
            {
                validExtensionAssemblies.Add(assemblyFilePath);
            }
        }
 
        return new ExtensionAssemblyManager(directoryLoadContexts.ToImmutableDictionary(), assemblyFullNameToLoadContext.ToImmutableDictionary(), validExtensionAssemblies.ToImmutable());
 
        bool TryGetOrCreateLoadContext(string assemblyFilePath)
        {
            // Verify that the path is something we can load.
            // If it's not, log helpful error messages and no-op.  We do not want to take down the server if an extension fails to load.
            if (!File.Exists(assemblyFilePath))
            {
                logger.LogError("Extension path {assemblyFilePath} does not exist", assemblyFilePath);
                return false;
            }
 
            var directory = Path.GetDirectoryName(assemblyFilePath);
            if (directory == null)
            {
                logger.LogError("Failed to get directory from {assemblyFilePath}", assemblyFilePath);
                return false;
            }
 
            var assemblyFullName = AssemblyName.GetAssemblyName(assemblyFilePath).FullName;
            if (assemblyFullNameToLoadContext.TryGetValue(assemblyFullName, out var existingContext))
            {
                // MEF relies on type full names (*without* their assembly names) to identify parts.
                // If an extension assembly is added to the catalog twice, then we will almost certainly get duplicate MEF parts
                // which breaks consumers who are only expecting one part.
                //
                // We validate this constraint here by checking for duplicate extension assembly full names.
                logger.LogError(
                    "{assemblyFilePath} with assembly name {assemblyName} conflicts with extension loaded from {existingContextName}",
                    assemblyFilePath, assemblyFullName, existingContext.Name);
                return false;
            }
 
            if (!directoryLoadContexts.TryGetValue(directory, out var directoryContext))
            {
                directoryContext = GetOrCreateDirectoryContext(directory, assemblyFilePath);
            }
 
            assemblyFullNameToLoadContext.Add(assemblyFullName, directoryContext);
            return true;
        }
 
        AssemblyLoadContext GetOrCreateDirectoryContext(string directory, string assemblyFilePath)
        {
            if (directoryLoadContexts.TryGetValue(directory, out var directoryContext))
            {
                logger.LogTrace("Reusing {contextName} load context for {assemblyFilePath}", directoryContext.Name, assemblyFilePath);
                return directoryContext;
            }
 
            // Create an extension assembly load context for the directory that the extension is in.
            logger.LogTrace("Creating {contextName} load context for {assemblyFilePath}", directory, assemblyFilePath);
            var loadContext = new ExtensionAssemblyLoadContext(directory, loggerFactory);
            directoryLoadContexts.Add(directory, loadContext);
            return loadContext;
        }
    }
 
    private static void ResolveDevKitAssemblies(string devKitDependencyPath, ILoggerFactory loggerFactory)
    {
        var devKitDependencyDirectory = Path.GetDirectoryName(devKitDependencyPath);
        Contract.ThrowIfNull(devKitDependencyDirectory);
        var logger = loggerFactory.CreateLogger("DevKitAssemblyResolver");
 
        AssemblyLoadContext.Default.Resolving += (context, assemblyName) =>
        {
            var simpleName = assemblyName.Name!;
            var assemblyPath = Path.Combine(devKitDependencyDirectory, simpleName + ".dll");
            if (File.Exists(assemblyPath))
            {
                logger.LogTrace("Loading {assembly} from DevKit directory", simpleName);
                return context.LoadFromAssemblyPath(assemblyPath);
            }
 
            return null;
        };
    }
 
    /// <summary>
    /// Loads an assembly from an assembly file path into the extension load context for the assembly's directory.
    /// If the directory containing the assembly file path is not an extension directory, this will return null.
    /// </summary>
    public Assembly? TryLoadAssemblyInExtensionContext(string assemblyFilePath)
    {
        var directory = Path.GetDirectoryName(assemblyFilePath);
        if (directory == null)
        {
            return null;
        }
 
        if (_directoryLoadContexts.TryGetValue(directory, out var loadContext))
        {
            return loadContext.LoadFromAssemblyPath(assemblyFilePath);
        }
 
        return null;
    }
 
    /// <summary>
    /// Loads an assembly from an assembly name in the extension load context.
    /// This will look for an extension assembly with the same assembly full name.
    /// </summary>
    public Assembly? TryLoadAssemblyInExtensionContext(AssemblyName assemblyName)
    {
        var assemblyFullName = assemblyName.FullName;
        if (_assemblyFullNameToLoadContext.TryGetValue(assemblyFullName, out var loadContext))
        {
            return loadContext.LoadFromAssemblyName(assemblyName);
        }
 
        return null;
    }
 
    /// <summary>
    /// Load context that will search the extension directory for the assembly to load.
    /// If the assembly is not found in the extension context it will continue with
    /// normal assembly loading to check the host (or potentially other extensions) for the assembly.
    /// </summary>
    private sealed class ExtensionAssemblyLoadContext : AssemblyLoadContext
    {
        private readonly string _extensionDirectory;
        private readonly ILogger _logger;
 
        public ExtensionAssemblyLoadContext(string extensionDirectory, ILoggerFactory loggerFactory) : base(extensionDirectory)
        {
            _extensionDirectory = extensionDirectory;
            _logger = loggerFactory.CreateLogger($"ALC-{extensionDirectory}");
        }
 
        protected override Assembly? Load(AssemblyName assemblyName)
        {
            var simpleName = assemblyName.Name!;
            var assemblyPath = Path.Combine(_extensionDirectory, simpleName + ".dll");
            if (File.Exists(assemblyPath))
            {
                _logger.LogTrace("Loading {assembly} in this load context", simpleName);
                return LoadFromAssemblyPath(assemblyPath);
            }
 
            // This assembly isn't provided by this extension, continue with normal assembly loading
            // to check other extensions or the host for this assembly.
            _logger.LogTrace("{assembly} not found in this load context", simpleName);
            return null;
        }
    }
}