File: ExportProviderBuilder.cs
Web Access
Project: src\src\Workspaces\Remote\Core\Microsoft.CodeAnalysis.Remote.Workspaces.csproj (Microsoft.CodeAnalysis.Remote.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.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Threading;
using Microsoft.VisualStudio.Composition;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Remote;
 
internal abstract class ExportProviderBuilder(
    ImmutableArray<string> assemblyPaths,
    Resolver resolver,
    string cacheDirectory,
    string catalogPrefix)
{
    private const string CatalogSuffix = ".mef-composition";
 
    protected ImmutableArray<string> AssemblyPaths { get; } = assemblyPaths;
    protected Resolver Resolver { get; } = resolver;
    protected string CacheDirectory { get; } = cacheDirectory;
    protected string CatalogPrefix { get; } = catalogPrefix;
 
    protected abstract void LogError(string message);
    protected abstract void LogTrace(string message);
 
    protected virtual async Task<ExportProvider> CreateExportProviderAsync(CancellationToken cancellationToken)
    {
        // Get the cached MEF composition or create a new one.
        var exportProviderFactory = await GetCompositionConfigurationAsync(cancellationToken).ConfigureAwait(false);
 
        // Create an export provider, which represents a unique container of values.
        // You can create as many of these as you want, but typically an app needs just one.
        var exportProvider = exportProviderFactory.CreateExportProvider();
 
        return exportProvider;
    }
 
    private async Task<IExportProviderFactory> GetCompositionConfigurationAsync(CancellationToken cancellationToken)
    {
        // Determine the path to the MEF composition cache file for the given assembly paths.
        var compositionCacheFile = GetCompositionCacheFilePath();
 
        // Try to load a cached composition.
        try
        {
            if (File.Exists(compositionCacheFile))
            {
                LogTrace($"Loading cached MEF catalog: {compositionCacheFile}");
 
                CachedComposition cachedComposition = new();
                using FileStream cacheStream = new(compositionCacheFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
                var exportProviderFactory = await cachedComposition.LoadExportProviderFactoryAsync(cacheStream, Resolver, cancellationToken).ConfigureAwait(false);
 
                return exportProviderFactory;
            }
        }
        catch (Exception ex)
        {
            // Log the error, and move on to recover by recreating the MEF composition.
            LogError($"Loading cached MEF composition failed: {ex}");
        }
 
        LogTrace($"Composing MEF catalog using:{Environment.NewLine}{string.Join($"    {Environment.NewLine}", AssemblyPaths)}.");
 
        var discovery = PartDiscovery.Combine(
            Resolver,
            new AttributedPartDiscovery(Resolver, isNonPublicSupported: true), // "NuGet MEF" attributes (Microsoft.Composition)
            new AttributedPartDiscoveryV1(Resolver));
 
        var parts = await discovery.CreatePartsAsync(AssemblyPaths, progress: null, cancellationToken).ConfigureAwait(false);
        var catalog = ComposableCatalog.Create(Resolver)
            .AddParts(parts)
            .WithCompositionService(); // Makes an ICompositionService export available to MEF parts to import
 
        // Assemble the parts into a valid graph.
        var config = CompositionConfiguration.Create(catalog);
 
        // Verify we only have expected errors.
 
        ThrowOnUnexpectedErrors(config, catalog);
 
        // Try to cache the composition.
        _ = WriteCompositionCacheAsync(compositionCacheFile, config, cancellationToken).ReportNonFatalErrorAsync();
 
        // Prepare an ExportProvider factory based on this graph.
        return config.CreateExportProviderFactory();
    }
 
    /// <summary>
    /// Returns the path to the MEF composition cache file. Inputs used to determine the file name include:
    /// 1) The given assembly paths
    /// 2) The last write times of the given assembly paths
    /// 3) The .NET runtime major version
    /// </summary>
    private string GetCompositionCacheFilePath()
    {
        return Path.Combine(CacheDirectory, $"{CatalogPrefix}.{ComputeAssemblyHash(AssemblyPaths)}{CatalogSuffix}");
 
        static string ComputeAssemblyHash(ImmutableArray<string> assemblyPaths)
        {
            // Ensure AssemblyPaths are always in the same order.
            assemblyPaths = assemblyPaths.Sort(StringComparer.Ordinal);
 
            var hashContents = new StringBuilder();
 
            // This should vary based on .NET runtime major version so that as some of our processes switch between our target
            // .NET version and the user's selected SDK runtime version (which may be newer), the MEF cache is kept isolated.
            // This can be important when the MEF catalog records full assembly names such as "System.Runtime, 8.0.0.0" yet
            // we might be running on .NET 7 or .NET 8, depending on the particular session and user settings.
            hashContents.Append(Environment.Version.Major);
 
            foreach (var assemblyPath in assemblyPaths)
            {
                // Include assembly path in the hash so that changes to the set of included
                // assemblies cause the composition to be rebuilt.
                hashContents.Append(assemblyPath);
                // Include the last write time in the hash so that newer assemblies written
                // to the same location cause the composition to be rebuilt.
                hashContents.Append(File.GetLastWriteTimeUtc(assemblyPath).ToString("F"));
            }
 
            // Create base64 string of the hash.
            var hashAsBase64String = Checksum.Create(hashContents.ToString()).ToString();
 
            // Convert to filename safe base64 string.
            return hashAsBase64String.Replace('+', '-').Replace('/', '_').TrimEnd('=');
        }
    }
 
    protected virtual async Task WriteCompositionCacheAsync(string compositionCacheFile, CompositionConfiguration config, CancellationToken cancellationToken)
    {
        // Generally, it's not a hard failure if this code doesn't execute to completion or even fails. The end effect would simply 
        // either be a non-existent or invalid file cached to disk. In the case of the file not getting cached, the next VS session
        // will just detect the file doesn't exist and attempt to recreate the cache. In the case where the cached file contents are
        // invalid, the next VS session will throw when attempting to read in the cached contents, and again, just recreate the cache.
        try
        {
            await Task.Yield().ConfigureAwait(false);
 
            var directory = Path.GetDirectoryName(compositionCacheFile)!;
            var directoryInfo = Directory.CreateDirectory(directory);
            PerformCacheDirectoryCleanup(directoryInfo, cancellationToken);
 
            CachedComposition cachedComposition = new();
            var tempFilePath = Path.Combine(directory, Path.GetRandomFileName());
            using (FileStream cacheStream = new(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
            {
                await cachedComposition.SaveAsync(config, cacheStream, cancellationToken).ConfigureAwait(false);
            }
 
            File.Move(tempFilePath, compositionCacheFile);
        }
        catch (Exception ex)
        {
            LogError($"Failed to save MEF cache: {ex}");
        }
    }
 
    protected virtual void PerformCacheDirectoryCleanup(DirectoryInfo directoryInfo, CancellationToken cancellationToken)
    {
        // Delete any existing cached files.
        foreach (var fileInfo in directoryInfo.EnumerateFiles())
        {
            // Failing to delete any file is fine, we'll just try again the next VS session in which we attempt
            // to write a new cache
            IOUtilities.PerformIO(fileInfo.Delete);
            cancellationToken.ThrowIfCancellationRequested();
        }
    }
 
    protected abstract bool ContainsUnexpectedErrors(IEnumerable<string> erroredParts, ImmutableList<PartDiscoveryException> partDiscoveryExceptions);
 
    private void ThrowOnUnexpectedErrors(CompositionConfiguration configuration, ComposableCatalog catalog)
    {
        // Verify that we have exactly the MEF errors that we expect.  If we have less or more this needs to be updated to assert the expected behavior.
        var erroredParts = configuration.CompositionErrors.FirstOrDefault()?.SelectMany(error => error.Parts).Select(part => part.Definition.Type.Name) ?? [];
 
        if (ContainsUnexpectedErrors(erroredParts, catalog.DiscoveredParts.DiscoveryErrors))
        {
            try
            {
                catalog.DiscoveredParts.ThrowOnErrors();
                configuration.ThrowOnErrors();
            }
            catch (CompositionFailedException ex)
            {
                // The ToString for the composition failed exception doesn't output a nice set of errors by default, so log it separately
                LogError($"Encountered errors in the MEF composition:{Environment.NewLine}{ex.ErrorsAsString}");
                throw;
            }
        }
    }
}