#nullable enable
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using BTCPayServer.Plugins.Dotnet.LibraryModel;
namespace BTCPayServer.Plugins.Dotnet.Loader
{
///
/// A builder for creating an instance of .
///
public class AssemblyLoadContextBuilder
{
private readonly List _additionalProbingPaths = new();
private readonly List _resourceProbingPaths = new();
private readonly List _resourceProbingSubpaths = new();
private readonly Dictionary _managedLibraries = new(StringComparer.Ordinal);
private readonly Dictionary _nativeLibraries = new(StringComparer.Ordinal);
private readonly HashSet _privateAssemblies = new(StringComparer.Ordinal);
private readonly HashSet _defaultAssemblies = new(StringComparer.Ordinal);
private AssemblyLoadContext _defaultLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default;
private string? _mainAssemblyPath;
private bool _preferDefaultLoadContext;
private bool _lazyLoadReferences;
private bool _isCollectible;
private bool _loadInMemory;
private bool _shadowCopyNativeLibraries;
///
/// Creates an assembly load context using settings specified on the builder.
///
/// A new ManagedLoadContext.
public AssemblyLoadContext Build()
{
var resourceProbingPaths = new List(_resourceProbingPaths);
foreach (var additionalPath in _additionalProbingPaths)
{
foreach (var subPath in _resourceProbingSubpaths)
{
resourceProbingPaths.Add(Path.Combine(additionalPath, subPath));
}
}
if (_mainAssemblyPath == null)
{
throw new InvalidOperationException($"Missing required property. You must call '{nameof(SetMainAssemblyPath)}' to configure the default assembly.");
}
return new ManagedLoadContext(
_mainAssemblyPath,
_managedLibraries,
_nativeLibraries,
_privateAssemblies,
_defaultAssemblies,
_additionalProbingPaths,
resourceProbingPaths,
_defaultLoadContext,
_preferDefaultLoadContext,
_lazyLoadReferences,
_isCollectible,
_loadInMemory,
_shadowCopyNativeLibraries);
}
///
/// Set the file path to the main assembly for the context. This is used as the starting point for loading
/// other assemblies. The directory that contains it is also known as the 'app local' directory.
///
/// The file path. Must not be null or empty. Must be an absolute path.
/// The builder.
public AssemblyLoadContextBuilder SetMainAssemblyPath(string path)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Argument must not be null or empty.", nameof(path));
}
if (!Path.IsPathRooted(path))
{
throw new ArgumentException("Argument must be a full path.", nameof(path));
}
_mainAssemblyPath = path;
return this;
}
///
/// Replaces the default used by the .
/// Use this feature if the of the is not the Runtime's default load context.
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) !=
///
/// The context to set.
/// The builder.
public AssemblyLoadContextBuilder SetDefaultContext(AssemblyLoadContext context)
{
_defaultLoadContext = context ?? throw new ArgumentException($"Bad Argument: AssemblyLoadContext in {nameof(AssemblyLoadContextBuilder)}.{nameof(SetDefaultContext)} is null.");
return this;
}
///
/// Instructs the load context to prefer a private version of this assembly, even if that version is
/// different from the version used by the host application.
/// Use this when you do not need to exchange types created from within the load context with other contexts
/// or the default app context.
///
/// This may mean the types loaded from
/// this assembly will not match the types from an assembly with the same name, but different version,
/// in the host application.
///
///
/// For example, if the host application has a type named Foo from assembly Banana, Version=1.0.0.0
/// and the load context prefers a private version of Banan, Version=2.0.0.0, when comparing two objects,
/// one created by the host (Foo1) and one created from within the load context (Foo2), they will not have the same
/// type. Foo1.GetType() != Foo2.GetType()
///
///
/// The name of the assembly.
/// The builder.
public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assemblyName)
{
if (assemblyName.Name != null)
{
_privateAssemblies.Add(assemblyName.Name);
}
return this;
}
///
/// Instructs the load context to first attempt to load assemblies by this name from the default app context, even
/// if other assemblies in this load context express a dependency on a higher or lower version.
/// Use this when you need to exchange types created from within the load context with other contexts
/// or the default app context.
///
/// The name of the assembly.
/// The builder.
public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName)
{
// Lazy loaded references have dependencies resolved as they are loaded inside the actual Load Context.
if (_lazyLoadReferences)
{
if (assemblyName.Name != null && !_defaultAssemblies.Contains(assemblyName.Name))
{
_defaultAssemblies.Add(assemblyName.Name);
var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName);
foreach (var reference in assembly.GetReferencedAssemblies())
{
if (reference.Name != null)
{
_defaultAssemblies.Add(reference.Name);
}
}
}
return this;
}
var names = new Queue();
names.Enqueue(assemblyName);
while (names.TryDequeue(out var name))
{
if (name.Name == null || _defaultAssemblies.Contains(name.Name))
{
// base cases
continue;
}
_defaultAssemblies.Add(name.Name);
// Load and find all dependencies of default assemblies.
// This sacrifices some performance for determinism in how transitive
// dependencies will be shared between host and plugin.
var assembly = _defaultLoadContext.LoadFromAssemblyName(name);
foreach (var reference in assembly.GetReferencedAssemblies())
{
names.Enqueue(reference);
}
}
return this;
}
///
/// Instructs the load context to first search for binaries from the default app context, even
/// if other assemblies in this load context express a dependency on a higher or lower version.
/// Use this when you need to exchange types created from within the load context with other contexts
/// or the default app context.
///
/// This may mean the types loaded from within the context are force-downgraded to the version provided
/// by the host. can be used to selectively identify binaries
/// which should not be loaded from the default load context.
///
///
/// When true, first attemp to load binaries from the default load context.
/// The builder.
public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoadContext)
{
_preferDefaultLoadContext = preferDefaultLoadContext;
return this;
}
///
/// Instructs the load context to lazy load dependencies of all shared assemblies.
/// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded
/// between the plugin and the host.
///
/// Please be aware of the danger of using this option:
///
/// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
///
///
/// True to lazy load, else false.
/// The builder.
public AssemblyLoadContextBuilder IsLazyLoaded(bool isLazyLoaded)
{
_lazyLoadReferences = isLazyLoaded;
return this;
}
///
/// Add a managed library to the load context.
///
/// The managed library.
/// The builder.
public AssemblyLoadContextBuilder AddManagedLibrary(ManagedLibrary library)
{
ValidateRelativePath(library.AdditionalProbingPath);
if (library.Name.Name != null)
{
_managedLibraries.Add(library.Name.Name, library);
}
return this;
}
///
/// Add a native library to the load context.
///
///
///
public AssemblyLoadContextBuilder AddNativeLibrary(NativeLibrary library)
{
ValidateRelativePath(library.AppLocalPath);
ValidateRelativePath(library.AdditionalProbingPath);
_nativeLibraries.Add(library.Name, library);
return this;
}
///
/// Add a that should be used to search for native and managed libraries.
///
/// The file path. Must be a full file path.
/// The builder
public AssemblyLoadContextBuilder AddProbingPath(string path)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Value must not be null or empty.", nameof(path));
}
if (!Path.IsPathRooted(path))
{
throw new ArgumentException("Argument must be a full path.", nameof(path));
}
_additionalProbingPaths.Add(path);
return this;
}
///
/// Add a that should be use to search for resource assemblies (aka satellite assemblies).
///
/// The file path. Must be a full file path.
/// The builder
public AssemblyLoadContextBuilder AddResourceProbingPath(string path)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Value must not be null or empty.", nameof(path));
}
if (!Path.IsPathRooted(path))
{
throw new ArgumentException("Argument must be a full path.", nameof(path));
}
_resourceProbingPaths.Add(path);
return this;
}
///
/// Enable unloading the assembly load context.
///
/// The builder
public AssemblyLoadContextBuilder EnableUnloading()
{
_isCollectible = true;
return this;
}
///
/// Read .dll files into memory to avoid locking the files.
/// This is not as efficient, so is not enabled by default, but is required for scenarios
/// like hot reloading.
///
/// The builder
public AssemblyLoadContextBuilder PreloadAssembliesIntoMemory()
{
_loadInMemory = true; // required to prevent dotnet from locking loaded files
return this;
}
///
/// Shadow copy native libraries (unmanaged DLLs) to avoid locking of these files.
/// This is not as efficient, so is not enabled by default, but is required for scenarios
/// like hot reloading of plugins dependent on native libraries.
///
/// The builder
public AssemblyLoadContextBuilder ShadowCopyNativeLibraries()
{
_shadowCopyNativeLibraries = true;
return this;
}
///
/// Add a that should be use to search for resource assemblies (aka satellite assemblies)
/// relative to any paths specified as
///
/// The file path. Must not be a full file path since it will be appended to additional probing path roots.
/// The builder
internal AssemblyLoadContextBuilder AddResourceProbingSubpath(string path)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Value must not be null or empty.", nameof(path));
}
if (Path.IsPathRooted(path))
{
throw new ArgumentException("Argument must be not a full path.", nameof(path));
}
_resourceProbingSubpaths.Add(path);
return this;
}
private static void ValidateRelativePath(string probingPath)
{
if (string.IsNullOrEmpty(probingPath))
{
throw new ArgumentException("Value must not be null or empty.", nameof(probingPath));
}
if (Path.IsPathRooted(probingPath))
{
throw new ArgumentException("Argument must be a relative path.", nameof(probingPath));
}
}
}
}