Plugins can load assemblies from their dependent plugins (#6851)

This commit is contained in:
Nicolas Dorier
2025-07-17 22:38:40 +09:00
committed by GitHub
parent 86881ba5a3
commit e77d785358
18 changed files with 2012 additions and 91 deletions

View File

@@ -27,7 +27,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BTCPayServer.NETCore.Plugins.Mvc" Version="1.4.4" />
<ProjectReference Include="..\BTCPayServer\BTCPayServer.csproj" /> <ProjectReference Include="..\BTCPayServer\BTCPayServer.csproj" />
<None Include="icon.png" Pack="true" PackagePath="\" /> <None Include="icon.png" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>

View File

@@ -9,7 +9,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using McMaster.NETCore.Plugins; using BTCPayServer.Plugins.Dotnet;
using NBitcoin.Crypto; using NBitcoin.Crypto;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBitcoin.Secp256k1; using NBitcoin.Secp256k1;

View File

@@ -62,7 +62,6 @@
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="LNURL" Version="0.0.36" /> <PackageReference Include="LNURL" Version="0.0.36" />
<PackageReference Include="MailKit" Version="4.8.0" /> <PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="BTCPayServer.NETCore.Plugins.Mvc" Version="1.4.4" />
<PackageReference Include="QRCoder" Version="1.6.0" /> <PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" /> <PackageReference Include="System.IO.Pipelines" Version="8.0.0" />
<PackageReference Include="NBitpayClient" Version="1.0.0.39" /> <PackageReference Include="NBitpayClient" Version="1.0.0.39" />

View File

@@ -0,0 +1,42 @@
// 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.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Plugins.Dotnet.Internal
{
internal class Debouncer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly TimeSpan _waitTime;
private int _counter;
public Debouncer(TimeSpan waitTime)
{
_waitTime = waitTime;
}
public void Execute(Action action)
{
var current = Interlocked.Increment(ref _counter);
Task.Delay(_waitTime).ContinueWith(task =>
{
// Is this the last task that was queued?
if (current == _counter && !_cts.IsCancellationRequested)
{
action();
}
task.Dispose();
}, _cts.Token);
}
public void Dispose()
{
_cts.Cancel();
}
}
}

View File

@@ -0,0 +1,47 @@
// 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.Diagnostics;
using System.Runtime.InteropServices;
namespace BTCPayServer.Plugins.Dotnet.Internal
{
internal class PlatformInformation
{
public static readonly string[] NativeLibraryExtensions;
public static readonly string[] NativeLibraryPrefixes;
public static readonly string[] ManagedAssemblyExtensions = new[]
{
".dll",
".ni.dll",
".exe",
".ni.exe"
};
static PlatformInformation()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
NativeLibraryPrefixes = new[] { "" };
NativeLibraryExtensions = new[] { ".dll" };
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
NativeLibraryPrefixes = new[] { "", "lib", };
NativeLibraryExtensions = new[] { ".dylib" };
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
NativeLibraryPrefixes = new[] { "", "lib" };
NativeLibraryExtensions = new[] { ".so", ".so.1" };
}
else
{
Debug.Fail("Unknown OS type");
NativeLibraryPrefixes = Array.Empty<string>();
NativeLibraryExtensions = Array.Empty<string>();
}
}
}
}

View File

@@ -0,0 +1,11 @@
#nullable enable
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace BTCPayServer.Plugins.Dotnet.Internal
{
internal class RuntimeConfig
{
public RuntimeOptions? runtimeOptions { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
#nullable enable
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace BTCPayServer.Plugins.Dotnet.Internal
{
internal class RuntimeOptions
{
public string? Tfm { get; set; }
public string[]? AdditionalProbingPaths { get; set; }
}
}

View File

@@ -0,0 +1,73 @@
// 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.Diagnostics;
using System.IO;
using System.Reflection;
namespace BTCPayServer.Plugins.Dotnet.LibraryModel
{
/// <summary>
/// Represents a managed, .NET assembly.
/// </summary>
[DebuggerDisplay("{Name} = {AdditionalProbingPath}")]
public class ManagedLibrary
{
private ManagedLibrary(AssemblyName name, string additionalProbingPath, string appLocalPath)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath));
AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath));
}
/// <summary>
/// Name of the managed library
/// </summary>
public AssemblyName Name { get; private set; }
/// <summary>
/// Contains path to file within an additional probing path root. This is typically a combination
/// of the NuGet package ID (lowercased), version, and path within the package.
/// <para>
/// For example, <c>microsoft.data.sqlite/1.0.0/lib/netstandard1.3/Microsoft.Data.Sqlite.dll</c>
/// </para>
/// </summary>
public string AdditionalProbingPath { get; private set; }
/// <summary>
/// Contains path to file within a deployed, framework-dependent application.
/// <para>
/// For most managed libraries, this will be the file name.
/// For example, <c>MyPlugin1.dll</c>.
/// </para>
/// <para>
/// For runtime-specific managed implementations, this may include a sub folder path.
/// For example, <c>runtimes/win/lib/netcoreapp2.0/System.Diagnostics.EventLog.dll</c>
/// </para>
/// </summary>
public string AppLocalPath { get; private set; }
/// <summary>
/// Create an instance of <see cref="ManagedLibrary" /> from a NuGet package.
/// </summary>
/// <param name="packageId">The name of the package.</param>
/// <param name="packageVersion">The version of the package.</param>
/// <param name="assetPath">The path within the NuGet package.</param>
/// <returns></returns>
public static ManagedLibrary CreateFromPackage(string packageId, string packageVersion, string assetPath)
{
// When the asset comes from "lib/$tfm/", Microsoft.NET.Sdk will flatten this during publish based on the most compatible TFM.
// The SDK will not flatten managed libraries found under runtimes/
var appLocalPath = assetPath.StartsWith("lib/")
? Path.GetFileName(assetPath)
: assetPath;
return new ManagedLibrary(
new AssemblyName(Path.GetFileNameWithoutExtension(assetPath)),
Path.Combine(packageId.ToLowerInvariant(), packageVersion, assetPath),
appLocalPath
);
}
}
}

View File

@@ -0,0 +1,69 @@
// 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.Diagnostics;
using System.IO;
namespace BTCPayServer.Plugins.Dotnet.LibraryModel
{
/// <summary>
/// Represents an unmanaged library, such as `libsqlite3`, which may need to be loaded
/// for P/Invoke to work.
/// </summary>
[DebuggerDisplay("{Name} = {AdditionalProbingPath}")]
public class NativeLibrary
{
private NativeLibrary(string name, string appLocalPath, string additionalProbingPath)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath));
AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath));
}
/// <summary>
/// Name of the native library. This should match the name of the P/Invoke call.
/// <para>
/// For example, if specifying `[DllImport("sqlite3")]`, <see cref="Name" /> should be <c>sqlite3</c>.
/// This may not match the exact file name as loading will attempt variations on the name according
/// to OS convention. On Windows, P/Invoke will attempt to load `sqlite3.dll`. On macOS, it will
/// attempt to find `sqlite3.dylib` and `libsqlite3.dylib`. On Linux, it will attempt to find
/// `sqlite3.so` and `libsqlite3.so`.
/// </para>
/// </summary>
public string Name { get; private set; }
/// <summary>
/// Contains path to file within a deployed, framework-dependent application
/// <para>
/// For example, <c>runtimes/linux-x64/native/libsqlite.so</c>
/// </para>
/// </summary>
public string AppLocalPath { get; private set; }
/// <summary>
/// Contains path to file within an additional probing path root. This is typically a combination
/// of the NuGet package ID (lowercased), version, and path within the package.
/// <para>
/// For example, <c>sqlite/3.13.3/runtimes/linux-x64/native/libsqlite.so</c>
/// </para>
/// </summary>
public string AdditionalProbingPath { get; private set; }
/// <summary>
/// Create an instance of <see cref="NativeLibrary" /> from a NuGet package.
/// </summary>
/// <param name="packageId">The name of the package.</param>
/// <param name="packageVersion">The version of the package.</param>
/// <param name="assetPath">The path within the NuGet package.</param>
/// <returns></returns>
public static NativeLibrary CreateFromPackage(string packageId, string packageVersion, string assetPath)
{
return new NativeLibrary(
Path.GetFileNameWithoutExtension(assetPath),
assetPath,
Path.Combine(packageId.ToLowerInvariant(), packageVersion, assetPath)
);
}
}
}

View File

@@ -0,0 +1,369 @@
#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
{
/// <summary>
/// A builder for creating an instance of <see cref="AssemblyLoadContext" />.
/// </summary>
public class AssemblyLoadContextBuilder
{
private readonly List<string> _additionalProbingPaths = new();
private readonly List<string> _resourceProbingPaths = new();
private readonly List<string> _resourceProbingSubpaths = new();
private readonly Dictionary<string, ManagedLibrary> _managedLibraries = new(StringComparer.Ordinal);
private readonly Dictionary<string, NativeLibrary> _nativeLibraries = new(StringComparer.Ordinal);
private readonly HashSet<string> _privateAssemblies = new(StringComparer.Ordinal);
private readonly HashSet<string> _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;
/// <summary>
/// Creates an assembly load context using settings specified on the builder.
/// </summary>
/// <returns>A new ManagedLoadContext.</returns>
public AssemblyLoadContext Build()
{
var resourceProbingPaths = new List<string>(_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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="path">The file path. Must not be null or empty. Must be an absolute path.</param>
/// <returns>The builder.</returns>
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;
}
/// <summary>
/// Replaces the default <see cref="AssemblyLoadContext"/> used by the <see cref="AssemblyLoadContextBuilder"/>.
/// Use this feature if the <see cref="AssemblyLoadContext"/> of the <see cref="Assembly"/> is not the Runtime's default load context.
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != <see cref="AssemblyLoadContext.Default"/>
/// </summary>
/// <param name="context">The context to set.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder SetDefaultContext(AssemblyLoadContext context)
{
_defaultLoadContext = context ?? throw new ArgumentException($"Bad Argument: AssemblyLoadContext in {nameof(AssemblyLoadContextBuilder)}.{nameof(SetDefaultContext)} is null.");
return this;
}
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// For example, if the host application has a type named <c>Foo</c> from assembly <c>Banana, Version=1.0.0.0</c>
/// and the load context prefers a private version of <c>Banan, Version=2.0.0.0</c>, 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. <c>Foo1.GetType() != Foo2.GetType()</c>
/// </para>
/// </summary>
/// <param name="assemblyName">The name of the assembly.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assemblyName)
{
if (assemblyName.Name != null)
{
_privateAssemblies.Add(assemblyName.Name);
}
return this;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="assemblyName">The name of the assembly.</param>
/// <returns>The builder.</returns>
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<AssemblyName>();
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;
}
/// <summary>
/// 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.
/// <para>
/// This may mean the types loaded from within the context are force-downgraded to the version provided
/// by the host. <seealso cref="PreferLoadContextAssembly" /> can be used to selectively identify binaries
/// which should not be loaded from the default load context.
/// </para>
/// </summary>
/// <param name="preferDefaultLoadContext">When true, first attemp to load binaries from the default load context.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoadContext)
{
_preferDefaultLoadContext = preferDefaultLoadContext;
return this;
}
/// <summary>
/// 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:
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873">
/// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
/// </seealso>
/// </summary>
/// <param name="isLazyLoaded">True to lazy load, else false.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder IsLazyLoaded(bool isLazyLoaded)
{
_lazyLoadReferences = isLazyLoaded;
return this;
}
/// <summary>
/// Add a managed library to the load context.
/// </summary>
/// <param name="library">The managed library.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder AddManagedLibrary(ManagedLibrary library)
{
ValidateRelativePath(library.AdditionalProbingPath);
if (library.Name.Name != null)
{
_managedLibraries.Add(library.Name.Name, library);
}
return this;
}
/// <summary>
/// Add a native library to the load context.
/// </summary>
/// <param name="library"></param>
/// <returns></returns>
public AssemblyLoadContextBuilder AddNativeLibrary(NativeLibrary library)
{
ValidateRelativePath(library.AppLocalPath);
ValidateRelativePath(library.AdditionalProbingPath);
_nativeLibraries.Add(library.Name, library);
return this;
}
/// <summary>
/// Add a <paramref name="path"/> that should be used to search for native and managed libraries.
/// </summary>
/// <param name="path">The file path. Must be a full file path.</param>
/// <returns>The builder</returns>
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;
}
/// <summary>
/// Add a <paramref name="path"/> that should be use to search for resource assemblies (aka satellite assemblies).
/// </summary>
/// <param name="path">The file path. Must be a full file path.</param>
/// <returns>The builder</returns>
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;
}
/// <summary>
/// Enable unloading the assembly load context.
/// </summary>
/// <returns>The builder</returns>
public AssemblyLoadContextBuilder EnableUnloading()
{
_isCollectible = true;
return this;
}
/// <summary>
/// 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.
/// </summary>
/// <returns>The builder</returns>
public AssemblyLoadContextBuilder PreloadAssembliesIntoMemory()
{
_loadInMemory = true; // required to prevent dotnet from locking loaded files
return this;
}
/// <summary>
/// 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.
/// </summary>
/// <returns>The builder</returns>
public AssemblyLoadContextBuilder ShadowCopyNativeLibraries()
{
_shadowCopyNativeLibraries = true;
return this;
}
/// <summary>
/// Add a <paramref name="path"/> that should be use to search for resource assemblies (aka satellite assemblies)
/// relative to any paths specified as <see cref="AddProbingPath"/>
/// </summary>
/// <param name="path">The file path. Must not be a full file path since it will be appended to additional probing path roots.</param>
/// <returns>The builder</returns>
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));
}
}
}
}

View File

@@ -0,0 +1,410 @@
#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.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using BTCPayServer.Plugins.Dotnet.Internal;
using BTCPayServer.Plugins.Dotnet.LibraryModel;
namespace BTCPayServer.Plugins.Dotnet.Loader
{
/// <summary>
/// An implementation of <see cref="AssemblyLoadContext" /> which attempts to load managed and native
/// binaries at runtime immitating some of the behaviors of corehost.
/// </summary>
[DebuggerDisplay("'{Name}' ({_mainAssemblyPath})")]
internal class ManagedLoadContext : AssemblyLoadContext
{
private readonly string _basePath;
private readonly string _mainAssemblyPath;
private readonly IReadOnlyDictionary<string, ManagedLibrary> _managedAssemblies;
private readonly IReadOnlyDictionary<string, NativeLibrary> _nativeLibraries;
private readonly IReadOnlyCollection<string> _privateAssemblies;
private readonly ICollection<string> _defaultAssemblies;
private readonly IReadOnlyCollection<string> _additionalProbingPaths;
private readonly bool _preferDefaultLoadContext;
private readonly string[] _resourceRoots;
private readonly bool _loadInMemory;
private readonly bool _lazyLoadReferences;
private readonly List<AssemblyLoadContext> _assemblyLoadContexts = new();
private readonly AssemblyDependencyResolver _dependencyResolver;
private readonly bool _shadowCopyNativeLibraries;
private readonly string _unmanagedDllShadowCopyDirectoryPath;
public ManagedLoadContext(string mainAssemblyPath,
IReadOnlyDictionary<string, ManagedLibrary> managedAssemblies,
IReadOnlyDictionary<string, NativeLibrary> nativeLibraries,
IReadOnlyCollection<string> privateAssemblies,
IReadOnlyCollection<string> defaultAssemblies,
IReadOnlyCollection<string> additionalProbingPaths,
IReadOnlyCollection<string> resourceProbingPaths,
AssemblyLoadContext defaultLoadContext,
bool preferDefaultLoadContext,
bool lazyLoadReferences,
bool isCollectible,
bool loadInMemory,
bool shadowCopyNativeLibraries)
: base(Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible)
{
if (resourceProbingPaths == null)
{
throw new ArgumentNullException(nameof(resourceProbingPaths));
}
_mainAssemblyPath = mainAssemblyPath ?? throw new ArgumentNullException(nameof(mainAssemblyPath));
_dependencyResolver = new AssemblyDependencyResolver(mainAssemblyPath);
_basePath = Path.GetDirectoryName(mainAssemblyPath) ?? throw new ArgumentException(nameof(mainAssemblyPath));
_managedAssemblies = managedAssemblies ?? throw new ArgumentNullException(nameof(managedAssemblies));
_privateAssemblies = privateAssemblies ?? throw new ArgumentNullException(nameof(privateAssemblies));
_defaultAssemblies = defaultAssemblies != null ? defaultAssemblies.ToList() : throw new ArgumentNullException(nameof(defaultAssemblies));
_nativeLibraries = nativeLibraries ?? throw new ArgumentNullException(nameof(nativeLibraries));
_additionalProbingPaths = additionalProbingPaths ?? throw new ArgumentNullException(nameof(additionalProbingPaths));
_assemblyLoadContexts.Add(defaultLoadContext);
_preferDefaultLoadContext = preferDefaultLoadContext;
_loadInMemory = loadInMemory;
_lazyLoadReferences = lazyLoadReferences;
_resourceRoots = new[] { _basePath }
.Concat(resourceProbingPaths)
.ToArray();
_shadowCopyNativeLibraries = shadowCopyNativeLibraries;
_unmanagedDllShadowCopyDirectoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
if (shadowCopyNativeLibraries)
{
Unloading += _ => OnUnloaded();
}
}
public void AddAssemblyLoadContexts(IEnumerable<AssemblyLoadContext> assemblyLoadContexts) => _assemblyLoadContexts.AddRange(assemblyLoadContexts);
/// <summary>
/// Load an assembly.
/// </summary>
/// <param name="assemblyName"></param>
/// <returns></returns>
protected override Assembly? Load(AssemblyName assemblyName)
{
if (assemblyName.Name == null)
{
// not sure how to handle this case. It's technically possible.
return null;
}
if ((_preferDefaultLoadContext || _defaultAssemblies.Contains(assemblyName.Name)) && !_privateAssemblies.Contains(assemblyName.Name))
{
var name = new AssemblyName(assemblyName.Name);
var assembly = _assemblyLoadContexts.Select(p => TryLoadFromAssemblyName(p, name)).FirstOrDefault(a => a is not null);
if (assembly is not null)
return assembly;
}
var resolvedPath = _dependencyResolver.ResolveAssemblyToPath(assemblyName);
if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
{
return LoadAssemblyFromFilePath(resolvedPath);
}
// Resource assembly binding does not use the TPA. Instead, it probes PLATFORM_RESOURCE_ROOTS (a list of folders)
// for $folder/$culture/$assemblyName.dll
// See https://github.com/dotnet/coreclr/blob/3fca50a36e62a7433d7601d805d38de6baee7951/src/binder/assemblybinder.cpp#L1232-L1290
if (!string.IsNullOrEmpty(assemblyName.CultureName) && !string.Equals("neutral", assemblyName.CultureName))
{
foreach (var resourceRoot in _resourceRoots)
{
var resourcePath = Path.Combine(resourceRoot, assemblyName.CultureName, assemblyName.Name + ".dll");
if (File.Exists(resourcePath))
{
return LoadAssemblyFromFilePath(resourcePath);
}
}
return null;
}
if (_managedAssemblies.TryGetValue(assemblyName.Name, out var library) && library != null)
{
if (SearchForLibrary(library, out var path) && path != null)
{
return LoadAssemblyFromFilePath(path);
}
}
else
{
// if an assembly was not listed in the list of known assemblies,
// fallback to the load context base directory
var dllName = assemblyName.Name + ".dll";
foreach (var probingPath in _additionalProbingPaths.Prepend(_basePath))
{
var localFile = Path.Combine(probingPath, dllName);
if (File.Exists(localFile))
{
return LoadAssemblyFromFilePath(localFile);
}
}
}
return null;
}
private Assembly? TryLoadFromAssemblyName(AssemblyLoadContext context, AssemblyName name)
{
// If default context is preferred, check first for types in the default context unless the dependency has been declared as private
try
{
var defaultAssembly = context.LoadFromAssemblyName(name);
if (defaultAssembly != null)
{
// Add referenced assemblies to the list of default assemblies.
// This is basically lazy loading
if (_lazyLoadReferences)
{
foreach (var reference in defaultAssembly.GetReferencedAssemblies())
{
if (reference.Name != null && !_defaultAssemblies.Contains(reference.Name))
{
_defaultAssemblies.Add(reference.Name);
}
}
}
// Older versions used to return null here such that returned assembly would be resolved from the default ALC.
// However, with the addition of custom default ALCs, the Default ALC may not be the user's chosen ALC when
// this context was built. As such, we simply return the Assembly from the user's chosen default load context.
return defaultAssembly;
}
}
catch
{
// Swallow errors in loading from the default context
}
return null;
}
public Assembly LoadAssemblyFromFilePath(string path)
{
if (!_loadInMemory)
{
return LoadFromAssemblyPath(path);
}
using var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var pdbPath = Path.ChangeExtension(path, ".pdb");
if (File.Exists(pdbPath))
{
using var pdbFile = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return LoadFromStream(file, pdbFile);
}
return LoadFromStream(file);
}
/// <summary>
/// Loads the unmanaged binary using configured list of native libraries.
/// </summary>
/// <param name="unmanagedDllName"></param>
/// <returns></returns>
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var resolvedPath = _dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
{
return LoadUnmanagedDllFromResolvedPath(resolvedPath, normalizePath: false);
}
foreach (var prefix in PlatformInformation.NativeLibraryPrefixes)
{
if (_nativeLibraries.TryGetValue(prefix + unmanagedDllName, out var library))
{
if (SearchForLibrary(library, prefix, out var path) && path != null)
{
return LoadUnmanagedDllFromResolvedPath(path);
}
}
else
{
// coreclr allows code to use [DllImport("sni")] or [DllImport("sni.dll")]
// This library treats the file name without the extension as the lookup name,
// so this loop is necessary to check if the unmanaged name matches a library
// when the file extension has been trimmed.
foreach (var suffix in PlatformInformation.NativeLibraryExtensions)
{
if (!unmanagedDllName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// check to see if there is a library entry for the library without the file extension
var trimmedName = unmanagedDllName.Substring(0, unmanagedDllName.Length - suffix.Length);
if (_nativeLibraries.TryGetValue(prefix + trimmedName, out library))
{
if (SearchForLibrary(library, prefix, out var path) && path != null)
{
return LoadUnmanagedDllFromResolvedPath(path);
}
}
else
{
// fallback to native assets which match the file name in the plugin base directory
var prefixSuffixDllName = prefix + unmanagedDllName + suffix;
var prefixDllName = prefix + unmanagedDllName;
foreach (var probingPath in _additionalProbingPaths.Prepend(_basePath))
{
var localFile = Path.Combine(probingPath, prefixSuffixDllName);
if (File.Exists(localFile))
{
return LoadUnmanagedDllFromResolvedPath(localFile);
}
var localFileWithoutSuffix = Path.Combine(probingPath, prefixDllName);
if (File.Exists(localFileWithoutSuffix))
{
return LoadUnmanagedDllFromResolvedPath(localFileWithoutSuffix);
}
}
}
}
}
}
return base.LoadUnmanagedDll(unmanagedDllName);
}
private bool SearchForLibrary(ManagedLibrary library, out string? path)
{
// 1. Check for in _basePath + app local path
var localFile = Path.Combine(_basePath, library.AppLocalPath);
if (File.Exists(localFile))
{
path = localFile;
return true;
}
// 2. Search additional probing paths
foreach (var searchPath in _additionalProbingPaths)
{
var candidate = Path.Combine(searchPath, library.AdditionalProbingPath);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
// 3. Search in base path
foreach (var ext in PlatformInformation.ManagedAssemblyExtensions)
{
var local = Path.Combine(_basePath, library.Name.Name + ext);
if (File.Exists(local))
{
path = local;
return true;
}
}
path = null;
return false;
}
private bool SearchForLibrary(NativeLibrary library, string prefix, out string? path)
{
// 1. Search in base path
foreach (var ext in PlatformInformation.NativeLibraryExtensions)
{
var candidate = Path.Combine(_basePath, $"{prefix}{library.Name}{ext}");
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
// 2. Search in base path + app local (for portable deployments of netcoreapp)
var local = Path.Combine(_basePath, library.AppLocalPath);
if (File.Exists(local))
{
path = local;
return true;
}
// 3. Search additional probing paths
foreach (var searchPath in _additionalProbingPaths)
{
var candidate = Path.Combine(searchPath, library.AdditionalProbingPath);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
path = null;
return false;
}
private IntPtr LoadUnmanagedDllFromResolvedPath(string unmanagedDllPath, bool normalizePath = true)
{
if (normalizePath)
{
unmanagedDllPath = Path.GetFullPath(unmanagedDllPath);
}
return _shadowCopyNativeLibraries
? LoadUnmanagedDllFromShadowCopy(unmanagedDllPath)
: LoadUnmanagedDllFromPath(unmanagedDllPath);
}
private IntPtr LoadUnmanagedDllFromShadowCopy(string unmanagedDllPath)
{
var shadowCopyDllPath = CreateShadowCopy(unmanagedDllPath);
return LoadUnmanagedDllFromPath(shadowCopyDllPath);
}
private string CreateShadowCopy(string dllPath)
{
Directory.CreateDirectory(_unmanagedDllShadowCopyDirectoryPath);
var dllFileName = Path.GetFileName(dllPath);
var shadowCopyPath = Path.Combine(_unmanagedDllShadowCopyDirectoryPath, dllFileName);
if (!File.Exists(shadowCopyPath))
{
File.Copy(dllPath, shadowCopyPath);
}
return shadowCopyPath;
}
private void OnUnloaded()
{
if (!_shadowCopyNativeLibraries || !Directory.Exists(_unmanagedDllShadowCopyDirectoryPath))
{
return;
}
// Attempt to delete shadow copies
try
{
Directory.Delete(_unmanagedDllShadowCopyDirectoryPath, recursive: true);
}
catch (Exception)
{
// Files might be locked by host process. Nothing we can do about it, I guess.
}
}
}
}

View File

@@ -0,0 +1,131 @@
#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.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using BTCPayServer.Plugins.Dotnet.Internal;
namespace BTCPayServer.Plugins.Dotnet.Loader
{
/// <summary>
/// Extensions for creating a load context using settings from a runtimeconfig.json file
/// </summary>
public static class RuntimeConfigExtensions
{
private const string JsonExt = ".json";
private static readonly JsonSerializerOptions s_serializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Adds additional probing paths to a managed load context using settings found in the runtimeconfig.json
/// and runtimeconfig.dev.json files.
/// </summary>
/// <param name="builder">The context builder</param>
/// <param name="runtimeConfigPath">The path to the runtimeconfig.json file</param>
/// <param name="includeDevConfig">Also read runtimeconfig.dev.json file, if present.</param>
/// <param name="error">The error, if one occurs while parsing runtimeconfig.json</param>
/// <returns>The builder.</returns>
public static AssemblyLoadContextBuilder TryAddAdditionalProbingPathFromRuntimeConfig(
this AssemblyLoadContextBuilder builder,
string runtimeConfigPath,
bool includeDevConfig,
out Exception? error)
{
error = null;
try
{
var config = TryReadConfig(runtimeConfigPath);
if (config == null)
{
return builder;
}
RuntimeConfig? devConfig = null;
if (includeDevConfig)
{
var configDevPath = runtimeConfigPath.Substring(0, runtimeConfigPath.Length - JsonExt.Length) + ".dev.json";
devConfig = TryReadConfig(configDevPath);
}
var tfm = config.runtimeOptions?.Tfm ?? devConfig?.runtimeOptions?.Tfm;
if (config.runtimeOptions != null)
{
AddProbingPaths(builder, config.runtimeOptions, tfm);
}
if (devConfig?.runtimeOptions != null)
{
AddProbingPaths(builder, devConfig.runtimeOptions, tfm);
}
if (tfm != null)
{
var dotnet = Process.GetCurrentProcess().MainModule?.FileName;
if (dotnet != null && string.Equals(Path.GetFileNameWithoutExtension(dotnet), "dotnet", StringComparison.OrdinalIgnoreCase))
{
var dotnetHome = Path.GetDirectoryName(dotnet);
if (dotnetHome != null)
{
builder.AddProbingPath(Path.Combine(dotnetHome, "store", RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), tfm));
}
}
}
}
catch (Exception ex)
{
error = ex;
}
return builder;
}
private static void AddProbingPaths(AssemblyLoadContextBuilder builder, RuntimeOptions options, string? tfm)
{
if (options.AdditionalProbingPaths == null)
{
return;
}
foreach (var item in options.AdditionalProbingPaths)
{
var path = item;
if (path.Contains("|arch|"))
{
path = path.Replace("|arch|", RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant());
}
if (path.Contains("|tfm|"))
{
if (tfm == null)
{
// We don't have enough information to parse this
continue;
}
path = path.Replace("|tfm|", tfm);
}
builder.AddProbingPath(path);
}
}
private static RuntimeConfig? TryReadConfig(string path)
{
try
{
var file = File.ReadAllBytes(path);
return JsonSerializer.Deserialize<RuntimeConfig>(file, s_serializerOptions);
}
catch
{
return null;
}
}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Plugins.Dotnet
{
/// <summary>
/// Extends the MVC builder.
/// </summary>
public static class MvcPluginExtensions
{
/// <summary>
/// Loads controllers and razor pages from a plugin assembly.
/// <para>
/// This creates a loader with <see cref="PluginConfig.PreferSharedTypes" /> set to <c>true</c>.
/// If you need more control over shared types, use <see cref="AddPluginLoader" /> instead.
/// </para>
/// </summary>
/// <param name="mvcBuilder">The MVC builder</param>
/// <param name="assemblyFile">Full path the main .dll file for the plugin.</param>
/// <returns>The builder</returns>
public static IMvcBuilder AddPluginFromAssemblyFile(this IMvcBuilder mvcBuilder, string assemblyFile)
{
var plugin = PluginLoader.CreateFromAssemblyFile(
assemblyFile, // create a plugin from for the .dll file
config =>
// this ensures that the version of MVC is shared between this app and the plugin
config.PreferSharedTypes = true);
return mvcBuilder.AddPluginLoader(plugin);
}
/// <summary>
/// Loads controllers and razor pages from a plugin loader.
/// <para>
/// In order for this to work, the PluginLoader instance must be configured to share the types
/// <see cref="ProvideApplicationPartFactoryAttribute" /> and <see cref="RelatedAssemblyAttribute" />
/// (comes from Microsoft.AspNetCore.Mvc.Core.dll). The easiest way to ensure that is done correctly
/// is to set <see cref="PluginConfig.PreferSharedTypes" /> to <c>true</c>.
/// </para>
/// </summary>
/// <param name="mvcBuilder">The MVC builder</param>
/// <param name="pluginLoader">An instance of PluginLoader.</param>
/// <returns>The builder</returns>
public static IMvcBuilder AddPluginLoader(this IMvcBuilder mvcBuilder, PluginLoader pluginLoader)
{
var pluginAssembly = pluginLoader.LoadDefaultAssembly();
// This loads MVC application parts from plugin assemblies
var partFactory = ApplicationPartFactory.GetApplicationPartFactory(pluginAssembly);
foreach (var part in partFactory.GetApplicationParts(pluginAssembly))
{
mvcBuilder.PartManager.ApplicationParts.Add(part);
}
// This piece finds and loads related parts, such as MvcAppPlugin1.Views.dll.
var relatedAssembliesAttrs = pluginAssembly.GetCustomAttributes<RelatedAssemblyAttribute>();
foreach (var attr in relatedAssembliesAttrs)
{
var assembly = pluginLoader.LoadAssembly(attr.AssemblyFileName);
partFactory = ApplicationPartFactory.GetApplicationPartFactory(assembly);
foreach (var part in partFactory.GetApplicationParts(assembly))
{
mvcBuilder.PartManager.ApplicationParts.Add(part);
}
}
return mvcBuilder;
}
}
}

View File

@@ -0,0 +1,124 @@
// 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;
namespace BTCPayServer.Plugins.Dotnet
{
/// <summary>
/// Represents the configuration for a .NET Core plugin.
/// </summary>
public class PluginConfig
{
/// <summary>
/// Initializes a new instance of <see cref="PluginConfig" />
/// </summary>
/// <param name="mainAssemblyPath">The full file path to the main assembly for the plugin.</param>
public PluginConfig(string mainAssemblyPath)
{
if (string.IsNullOrEmpty(mainAssemblyPath))
{
throw new ArgumentException("Value must be null or not empty", nameof(mainAssemblyPath));
}
if (!Path.IsPathRooted(mainAssemblyPath))
{
throw new ArgumentException("Value must be an absolute file path", nameof(mainAssemblyPath));
}
MainAssemblyPath = mainAssemblyPath;
}
/// <summary>
/// The file path to the main assembly.
/// </summary>
public string MainAssemblyPath { get; }
/// <summary>
/// A list of assemblies which should be treated as private.
/// </summary>
public ICollection<AssemblyName> PrivateAssemblies { get; protected set; } = new List<AssemblyName>();
/// <summary>
/// A list of assemblies which should be unified between the host and the plugin.
/// </summary>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
public ICollection<AssemblyName> SharedAssemblies { get; protected set; } = new List<AssemblyName>();
/// <summary>
/// Attempt to unify all types from a plugin with the host.
/// <para>
/// This does not guarantee types will unify.
/// </para>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
/// </summary>
public bool PreferSharedTypes { get; set; }
/// <summary>
/// If enabled, will 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:
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873">
/// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
/// </seealso>
/// </summary>
public bool IsLazyLoaded { get; set; } = false;
/// <summary>
/// If set, replaces the default <see cref="AssemblyLoadContext"/> used by the <see cref="PluginLoader"/>.
/// Use this feature if the <see cref="AssemblyLoadContext"/> of the <see cref="Assembly"/> is not the Runtime's default load context.
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != <see cref="AssemblyLoadContext.Default"/>
/// </summary>
public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default;
private bool _isUnloadable;
/// <summary>
/// The plugin can be unloaded from memory.
/// </summary>
public bool IsUnloadable
{
get => _isUnloadable || EnableHotReload;
set => _isUnloadable = value;
}
private bool _loadInMemory;
/// <summary>
/// Loads assemblies into memory in order to not lock files.
/// As example use case here would be: no hot reloading but able to
/// replace files and reload manually at later time
/// </summary>
public bool LoadInMemory
{
get => _loadInMemory || EnableHotReload;
set => _loadInMemory = value;
}
/// <summary>
/// When any of the loaded files changes on disk, the plugin will be reloaded.
/// Use the event <see cref="PluginLoader.Reloaded" /> to be notified of changes.
/// </summary>
/// <remarks>
/// It will load assemblies into memory in order to not lock files
/// <see cref="LoadInMemory"/>
/// </remarks>
public bool EnableHotReload { get; set; }
/// <summary>
/// Specifies the delay to reload a plugin, after file changes have been detected.
/// Default value is 200 milliseconds.
/// </summary>
public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200);
}
}

View File

@@ -0,0 +1,401 @@
#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.Linq;
using System.Reflection;
using System.Runtime.Loader;
using BTCPayServer.Plugins.Dotnet.Internal;
using BTCPayServer.Plugins.Dotnet.Loader;
namespace BTCPayServer.Plugins.Dotnet
{
/// <summary>
/// This loader attempts to load binaries for execution (both managed assemblies and native libraries)
/// in the same way that .NET Core would if they were originally part of the .NET Core application.
/// <para>
/// This loader reads configuration files produced by .NET Core (.deps.json and runtimeconfig.json)
/// as well as a custom file (*.config files). These files describe a list of .dlls and a set of dependencies.
/// The loader searches the plugin path, as well as any additionally specified paths, for binaries
/// which satisfy the plugin's requirements.
/// </para>
/// </summary>
public class PluginLoader : IDisposable
{
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <param name="isUnloadable">Enable unloading the plugin from memory.</param>
/// <param name="sharedTypes">
/// <para>
/// A list of types which should be shared between the host and the plugin.
/// </para>
/// <para>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
/// </para>
/// </param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes)
=> CreateFromAssemblyFile(assemblyFile, isUnloadable, sharedTypes, _ => { });
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <param name="isUnloadable">Enable unloading the plugin from memory.</param>
/// <param name="sharedTypes">
/// <para>
/// A list of types which should be shared between the host and the plugin.
/// </para>
/// <para>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
/// </para>
/// </param>
/// <param name="configure">A function which can be used to configure advanced options for the plugin loader.</param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes, Action<PluginConfig> configure)
{
return CreateFromAssemblyFile(assemblyFile,
sharedTypes,
config =>
{
config.IsUnloadable = isUnloadable;
configure(config);
});
}
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <param name="sharedTypes">
/// <para>
/// A list of types which should be shared between the host and the plugin.
/// </para>
/// <para>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
/// </para>
/// </param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes)
=> CreateFromAssemblyFile(assemblyFile, sharedTypes, _ => { });
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <param name="sharedTypes">
/// <para>
/// A list of types which should be shared between the host and the plugin.
/// </para>
/// <para>
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md">
/// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
/// </seealso>
/// </para>
/// </param>
/// <param name="configure">A function which can be used to configure advanced options for the plugin loader.</param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes, Action<PluginConfig> configure)
{
return CreateFromAssemblyFile(assemblyFile,
config =>
{
if (sharedTypes != null)
{
var uniqueAssemblies = new HashSet<Assembly>();
foreach (var type in sharedTypes)
{
uniqueAssemblies.Add(type.Assembly);
}
foreach (var assembly in uniqueAssemblies)
{
config.SharedAssemblies.Add(assembly.GetName());
}
}
configure(config);
});
}
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile)
=> CreateFromAssemblyFile(assemblyFile, _ => { });
/// <summary>
/// Create a plugin loader for an assembly file.
/// </summary>
/// <param name="assemblyFile">The file path to the main assembly for the plugin.</param>
/// <param name="configure">A function which can be used to configure advanced options for the plugin loader.</param>
/// <returns>A loader.</returns>
public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Action<PluginConfig> configure)
{
if (configure == null)
{
throw new ArgumentNullException(nameof(configure));
}
var config = new PluginConfig(assemblyFile);
configure(config);
return new PluginLoader(config);
}
private readonly PluginConfig _config;
private ManagedLoadContext _context;
private readonly AssemblyLoadContextBuilder _contextBuilder;
private volatile bool _disposed;
private FileSystemWatcher? _fileWatcher;
private Debouncer? _debouncer;
/// <summary>
/// Initialize an instance of <see cref="PluginLoader" />
/// </summary>
/// <param name="config">The configuration for the plugin.</param>
public PluginLoader(PluginConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_contextBuilder = CreateLoadContextBuilder(config);
_context = (ManagedLoadContext)_contextBuilder.Build();
if (config.EnableHotReload)
{
StartFileWatcher();
}
}
/// <summary>
/// True when this plugin is capable of being unloaded.
/// </summary>
public bool IsUnloadable => _context.IsCollectible;
/// <summary>
/// This event is raised when the plugin has been reloaded.
/// If <see cref="PluginConfig.EnableHotReload" /> was set to <c>true</c>,
/// the plugin will be reloaded when files on disk are changed.
/// </summary>
public event PluginReloadedEventHandler? Reloaded;
/// <summary>
/// The unloads and reloads the plugin assemblies.
/// This method throws if <see cref="IsUnloadable" /> is <c>false</c>.
/// </summary>
public void Reload()
{
EnsureNotDisposed();
if (!IsUnloadable)
{
throw new InvalidOperationException("Reload cannot be used because IsUnloadable is false");
}
_context.Unload();
_context = (ManagedLoadContext)_contextBuilder.Build();
GC.Collect();
GC.WaitForPendingFinalizers();
Reloaded?.Invoke(this, new PluginReloadedEventArgs(this));
}
private void StartFileWatcher()
{
/*
This is a very simple implementation.
Some improvements that could be made in the future:
* Watch all directories which contain assemblies that could be loaded
* Support a polling file watcher.
* Handle delete/recreate better.
If you're interested in making improvements, feel free to send a pull request.
*/
_debouncer = new Debouncer(_config.ReloadDelay);
var watchedDir = Path.GetDirectoryName(_config.MainAssemblyPath);
if (watchedDir == null)
{
throw new InvalidOperationException("Could not determine which directory to watch. "
+ "Please set MainAssemblyPath to an absolute path so its parent directory can be discovered.");
}
_fileWatcher = new FileSystemWatcher
{
Path = watchedDir
};
_fileWatcher.Changed += OnFileChanged;
_fileWatcher.Filter = "*.dll";
_fileWatcher.NotifyFilter = NotifyFilters.LastWrite;
_fileWatcher.EnableRaisingEvents = true;
}
private void OnFileChanged(object source, FileSystemEventArgs e)
{
if (!_disposed)
{
_debouncer?.Execute(Reload);
}
}
internal AssemblyLoadContext LoadContext => _context;
/// <summary>
/// Load the main assembly for the plugin.
/// </summary>
public Assembly LoadDefaultAssembly()
{
EnsureNotDisposed();
return _context.LoadAssemblyFromFilePath(_config.MainAssemblyPath);
}
/// <summary>
/// Load an assembly by name.
/// </summary>
/// <param name="assemblyName">The assembly name.</param>
/// <returns>The assembly.</returns>
public Assembly LoadAssembly(AssemblyName assemblyName)
{
EnsureNotDisposed();
return _context.LoadFromAssemblyName(assemblyName);
}
/// <summary>
/// Load an assembly from path.
/// </summary>
/// <param name="assemblyPath">The assembly path.</param>
/// <returns>The assembly.</returns>
public Assembly LoadAssemblyFromPath(string assemblyPath)
=> _context.LoadAssemblyFromFilePath(assemblyPath);
/// <summary>
/// Load an assembly by name.
/// </summary>
/// <param name="assemblyName">The assembly name.</param>
/// <returns>The assembly.</returns>
public Assembly LoadAssembly(string assemblyName)
{
EnsureNotDisposed();
return LoadAssembly(new AssemblyName(assemblyName));
}
/// <summary>
/// Sets the scope used by some System.Reflection APIs which might trigger assembly loading.
/// <para>
/// See https://github.com/dotnet/coreclr/blob/v3.0.0/Documentation/design-docs/AssemblyLoadContext.ContextualReflection.md for more details.
/// </para>
/// </summary>
/// <returns></returns>
public AssemblyLoadContext.ContextualReflectionScope EnterContextualReflection()
=> _context.EnterContextualReflection();
/// <summary>
/// Disposes the plugin loader. This only does something if <see cref="IsUnloadable" /> is true.
/// When true, this will unload assemblies which which were loaded during the lifetime
/// of the plugin.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_fileWatcher != null)
{
_fileWatcher.EnableRaisingEvents = false;
_fileWatcher.Changed -= OnFileChanged;
_fileWatcher.Dispose();
}
_debouncer?.Dispose();
if (_context.IsCollectible)
{
_context.Unload();
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(PluginLoader));
}
}
private static AssemblyLoadContextBuilder CreateLoadContextBuilder(PluginConfig config)
{
var builder = new AssemblyLoadContextBuilder();
builder.SetMainAssemblyPath(config.MainAssemblyPath);
builder.SetDefaultContext(config.DefaultContext);
foreach (var ext in config.PrivateAssemblies)
{
builder.PreferLoadContextAssembly(ext);
}
if (config.PreferSharedTypes)
{
builder.PreferDefaultLoadContext(true);
}
if (config.IsUnloadable || config.EnableHotReload)
{
builder.EnableUnloading();
}
if (config.LoadInMemory)
{
builder.PreloadAssembliesIntoMemory();
builder.ShadowCopyNativeLibraries();
}
builder.IsLazyLoaded(config.IsLazyLoaded);
foreach (var assemblyName in config.SharedAssemblies)
{
builder.PreferDefaultLoadContextAssembly(assemblyName);
}
var baseDir = Path.GetDirectoryName(config.MainAssemblyPath);
var assemblyFileName = Path.GetFileNameWithoutExtension(config.MainAssemblyPath);
if (baseDir == null)
{
throw new InvalidOperationException("Could not determine which directory to watch. "
+ "Please set MainAssemblyPath to an absolute path so its parent directory can be discovered.");
}
var pluginRuntimeConfigFile = Path.Combine(baseDir, assemblyFileName + ".runtimeconfig.json");
builder.TryAddAdditionalProbingPathFromRuntimeConfig(pluginRuntimeConfigFile, includeDevConfig: true, out _);
// Always include runtimeconfig.json from the host app.
// in some cases, like `dotnet test`, the entry assembly does not actually match with the
// runtime config file which is why we search for all files matching this extensions.
foreach (var runtimeconfig in Directory.GetFiles(AppContext.BaseDirectory, "*.runtimeconfig.json"))
{
builder.TryAddAdditionalProbingPathFromRuntimeConfig(runtimeconfig, includeDevConfig: true, out _);
}
return builder;
}
public void AddAssemblyLoadContexts(IEnumerable<PluginLoader> pluginLoaders) => _context.AddAssemblyLoadContexts(pluginLoaders.Select(p => p.LoadContext));
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace BTCPayServer.Plugins.Dotnet
{
/// <summary>
/// Represents the method that will handle the <see cref="PluginLoader.Reloaded" /> event.
/// </summary>
/// <param name="sender">The object sending the event</param>
/// <param name="eventArgs">Data about the event.</param>
public delegate void PluginReloadedEventHandler(object sender, PluginReloadedEventArgs eventArgs);
/// <summary>
/// Provides data for the <see cref="PluginLoader.Reloaded" /> event.
/// </summary>
public class PluginReloadedEventArgs : EventArgs
{
/// <summary>
/// Initializes <see cref="PluginReloadedEventArgs" />.
/// </summary>
/// <param name="loader"></param>
public PluginReloadedEventArgs(PluginLoader loader)
{
Loader = loader;
}
/// <summary>
/// The plugin loader
/// </summary>
public PluginLoader Loader { get; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace BTCPayServer;
public static class TopologicalSortExtensions
{
public static List<T> TopologicalSort<T>(this IReadOnlyCollection<T> nodes, Func<T, IEnumerable<T>> dependsOn)
{
return nodes.TopologicalSort(dependsOn, k => k, k => k);
}
public static List<T> TopologicalSort<T, TDepend>(this IReadOnlyCollection<T> nodes, Func<T, IEnumerable<TDepend>> dependsOn, Func<T, TDepend> getKey)
{
return nodes.TopologicalSort(dependsOn, getKey, o => o);
}
public static List<TValue> TopologicalSort<T, TDepend, TValue>(this IReadOnlyCollection<T> nodes,
Func<T, IEnumerable<TDepend>> dependsOn,
Func<T, TDepend> getKey,
Func<T, TValue> getValue,
IComparer<T> solveTies = null)
{
if (nodes.Count == 0)
return new List<TValue>();
if (getKey == null)
throw new ArgumentNullException(nameof(getKey));
if (getValue == null)
throw new ArgumentNullException(nameof(getValue));
solveTies = solveTies ?? Comparer<T>.Default;
List<TValue> result = new List<TValue>(nodes.Count);
HashSet<TDepend> allKeys = new HashSet<TDepend>(nodes.Count);
var noDependencies = new SortedDictionary<T, HashSet<TDepend>>(solveTies);
foreach (var node in nodes)
allKeys.Add(getKey(node));
var dependenciesByValues = nodes.ToDictionary(node => node,
node => new HashSet<TDepend>(dependsOn(node).Where(n => allKeys.Contains(n))));
foreach (var e in dependenciesByValues.Where(x => x.Value.Count == 0))
{
noDependencies.Add(e.Key, e.Value);
}
if (noDependencies.Count == 0)
{
throw new InvalidOperationException("Impossible to topologically sort a cyclic graph");
}
while (noDependencies.Count > 0)
{
var nodep = noDependencies.First();
noDependencies.Remove(nodep.Key);
dependenciesByValues.Remove(nodep.Key);
var elemKey = getKey(nodep.Key);
result.Add(getValue(nodep.Key));
foreach (var selem in dependenciesByValues)
{
if (selem.Value.Remove(elemKey) && selem.Value.Count == 0)
noDependencies.Add(selem.Key, selem.Value);
}
}
if (dependenciesByValues.Count != 0)
{
throw new InvalidOperationException("Impossible to topologically sort a cyclic graph");
}
return result;
}
}