diff --git a/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj b/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj index 9f385a5d8..bf355f389 100644 --- a/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj +++ b/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj @@ -27,7 +27,6 @@ - diff --git a/BTCPayServer.PluginPacker/Program.cs b/BTCPayServer.PluginPacker/Program.cs index 5b67c533d..1ab9c08b8 100644 --- a/BTCPayServer.PluginPacker/Program.cs +++ b/BTCPayServer.PluginPacker/Program.cs @@ -9,7 +9,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; -using McMaster.NETCore.Plugins; +using BTCPayServer.Plugins.Dotnet; using NBitcoin.Crypto; using NBitcoin.DataEncoders; using NBitcoin.Secp256k1; diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 9e73eb74a..a61254988 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -62,7 +62,6 @@ - diff --git a/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs b/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs new file mode 100644 index 000000000..b8c31f4b4 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs @@ -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(); + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs b/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs new file mode 100644 index 000000000..609f7671c --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs @@ -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(); + NativeLibraryExtensions = Array.Empty(); + } + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs new file mode 100644 index 000000000..472856365 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs @@ -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; } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs new file mode 100644 index 000000000..3f7a5943c --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs @@ -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; } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs b/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs new file mode 100644 index 000000000..2e65e7bd1 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs @@ -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 +{ + /// + /// Represents a managed, .NET assembly. + /// + [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)); + } + + /// + /// Name of the managed library + /// + public AssemblyName Name { get; private set; } + + /// + /// 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. + /// + /// For example, microsoft.data.sqlite/1.0.0/lib/netstandard1.3/Microsoft.Data.Sqlite.dll + /// + /// + public string AdditionalProbingPath { get; private set; } + + /// + /// Contains path to file within a deployed, framework-dependent application. + /// + /// For most managed libraries, this will be the file name. + /// For example, MyPlugin1.dll. + /// + /// + /// For runtime-specific managed implementations, this may include a sub folder path. + /// For example, runtimes/win/lib/netcoreapp2.0/System.Diagnostics.EventLog.dll + /// + /// + public string AppLocalPath { get; private set; } + + /// + /// Create an instance of from a NuGet package. + /// + /// The name of the package. + /// The version of the package. + /// The path within the NuGet package. + /// + 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 + ); + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs b/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs new file mode 100644 index 000000000..c33297454 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs @@ -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 +{ + /// + /// Represents an unmanaged library, such as `libsqlite3`, which may need to be loaded + /// for P/Invoke to work. + /// + [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)); + } + + /// + /// Name of the native library. This should match the name of the P/Invoke call. + /// + /// For example, if specifying `[DllImport("sqlite3")]`, should be sqlite3. + /// 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`. + /// + /// + public string Name { get; private set; } + + /// + /// Contains path to file within a deployed, framework-dependent application + /// + /// For example, runtimes/linux-x64/native/libsqlite.so + /// + /// + public string AppLocalPath { get; private set; } + + /// + /// 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. + /// + /// For example, sqlite/3.13.3/runtimes/linux-x64/native/libsqlite.so + /// + /// + public string AdditionalProbingPath { get; private set; } + + /// + /// Create an instance of from a NuGet package. + /// + /// The name of the package. + /// The version of the package. + /// The path within the NuGet package. + /// + public static NativeLibrary CreateFromPackage(string packageId, string packageVersion, string assetPath) + { + return new NativeLibrary( + Path.GetFileNameWithoutExtension(assetPath), + assetPath, + Path.Combine(packageId.ToLowerInvariant(), packageVersion, assetPath) + ); + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs b/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs new file mode 100644 index 000000000..64e28ce90 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs @@ -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 +{ + /// + /// 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)); + } + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs b/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs new file mode 100644 index 000000000..f176cced4 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs @@ -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 +{ + /// + /// An implementation of which attempts to load managed and native + /// binaries at runtime immitating some of the behaviors of corehost. + /// + [DebuggerDisplay("'{Name}' ({_mainAssemblyPath})")] + internal class ManagedLoadContext : AssemblyLoadContext + { + private readonly string _basePath; + private readonly string _mainAssemblyPath; + private readonly IReadOnlyDictionary _managedAssemblies; + private readonly IReadOnlyDictionary _nativeLibraries; + private readonly IReadOnlyCollection _privateAssemblies; + private readonly ICollection _defaultAssemblies; + private readonly IReadOnlyCollection _additionalProbingPaths; + private readonly bool _preferDefaultLoadContext; + private readonly string[] _resourceRoots; + private readonly bool _loadInMemory; + private readonly bool _lazyLoadReferences; + private readonly List _assemblyLoadContexts = new(); + private readonly AssemblyDependencyResolver _dependencyResolver; + private readonly bool _shadowCopyNativeLibraries; + private readonly string _unmanagedDllShadowCopyDirectoryPath; + + public ManagedLoadContext(string mainAssemblyPath, + IReadOnlyDictionary managedAssemblies, + IReadOnlyDictionary nativeLibraries, + IReadOnlyCollection privateAssemblies, + IReadOnlyCollection defaultAssemblies, + IReadOnlyCollection additionalProbingPaths, + IReadOnlyCollection 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 assemblyLoadContexts) => _assemblyLoadContexts.AddRange(assemblyLoadContexts); + + /// + /// Load an assembly. + /// + /// + /// + 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); + + } + + /// + /// Loads the unmanaged binary using configured list of native libraries. + /// + /// + /// + 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. + } + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs b/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs new file mode 100644 index 000000000..91b23c8cd --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs @@ -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 +{ + /// + /// Extensions for creating a load context using settings from a runtimeconfig.json file + /// + public static class RuntimeConfigExtensions + { + private const string JsonExt = ".json"; + private static readonly JsonSerializerOptions s_serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Adds additional probing paths to a managed load context using settings found in the runtimeconfig.json + /// and runtimeconfig.dev.json files. + /// + /// The context builder + /// The path to the runtimeconfig.json file + /// Also read runtimeconfig.dev.json file, if present. + /// The error, if one occurs while parsing runtimeconfig.json + /// The builder. + 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(file, s_serializerOptions); + } + catch + { + return null; + } + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs b/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs new file mode 100644 index 000000000..748d6cb9a --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs @@ -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 +{ + /// + /// Extends the MVC builder. + /// + public static class MvcPluginExtensions + { + /// + /// Loads controllers and razor pages from a plugin assembly. + /// + /// This creates a loader with set to true. + /// If you need more control over shared types, use instead. + /// + /// + /// The MVC builder + /// Full path the main .dll file for the plugin. + /// The builder + 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); + } + + /// + /// Loads controllers and razor pages from a plugin loader. + /// + /// In order for this to work, the PluginLoader instance must be configured to share the types + /// and + /// (comes from Microsoft.AspNetCore.Mvc.Core.dll). The easiest way to ensure that is done correctly + /// is to set to true. + /// + /// + /// The MVC builder + /// An instance of PluginLoader. + /// The builder + 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(); + 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; + } + } +} diff --git a/BTCPayServer/Plugins/Dotnet/PluginConfig.cs b/BTCPayServer/Plugins/Dotnet/PluginConfig.cs new file mode 100644 index 000000000..5124d9cb3 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/PluginConfig.cs @@ -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 +{ + /// + /// Represents the configuration for a .NET Core plugin. + /// + public class PluginConfig + { + /// + /// Initializes a new instance of + /// + /// The full file path to the main assembly for the plugin. + 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; + } + + /// + /// The file path to the main assembly. + /// + public string MainAssemblyPath { get; } + + /// + /// A list of assemblies which should be treated as private. + /// + public ICollection PrivateAssemblies { get; protected set; } = new List(); + + /// + /// A list of assemblies which should be unified between the host and the plugin. + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + public ICollection SharedAssemblies { get; protected set; } = new List(); + + /// + /// Attempt to unify all types from a plugin with the host. + /// + /// This does not guarantee types will unify. + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + /// + public bool PreferSharedTypes { get; set; } + + /// + /// 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: + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873 + /// + /// + public bool IsLazyLoaded { get; set; } = false; + + /// + /// If set, 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) != + /// + public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; + + private bool _isUnloadable; + + /// + /// The plugin can be unloaded from memory. + /// + public bool IsUnloadable + { + get => _isUnloadable || EnableHotReload; + set => _isUnloadable = value; + } + + private bool _loadInMemory; + + /// + /// 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 + /// + public bool LoadInMemory + { + get => _loadInMemory || EnableHotReload; + set => _loadInMemory = value; + } + + /// + /// When any of the loaded files changes on disk, the plugin will be reloaded. + /// Use the event to be notified of changes. + /// + /// + /// It will load assemblies into memory in order to not lock files + /// + /// + public bool EnableHotReload { get; set; } + + /// + /// Specifies the delay to reload a plugin, after file changes have been detected. + /// Default value is 200 milliseconds. + /// + public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200); + } +} diff --git a/BTCPayServer/Plugins/Dotnet/PluginLoader.cs b/BTCPayServer/Plugins/Dotnet/PluginLoader.cs new file mode 100644 index 000000000..68fdf2ea7 --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/PluginLoader.cs @@ -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 +{ + /// + /// 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. + /// + /// 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. + /// + /// + public class PluginLoader : IDisposable + { + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// Enable unloading the plugin from memory. + /// + /// + /// A list of types which should be shared between the host and the plugin. + /// + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + /// + /// + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes) + => CreateFromAssemblyFile(assemblyFile, isUnloadable, sharedTypes, _ => { }); + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// Enable unloading the plugin from memory. + /// + /// + /// A list of types which should be shared between the host and the plugin. + /// + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + /// + /// + /// A function which can be used to configure advanced options for the plugin loader. + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes, Action configure) + { + return CreateFromAssemblyFile(assemblyFile, + sharedTypes, + config => + { + config.IsUnloadable = isUnloadable; + configure(config); + }); + } + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// + /// + /// A list of types which should be shared between the host and the plugin. + /// + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + /// + /// + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes) + => CreateFromAssemblyFile(assemblyFile, sharedTypes, _ => { }); + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// + /// + /// A list of types which should be shared between the host and the plugin. + /// + /// + /// + /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md + /// + /// + /// + /// A function which can be used to configure advanced options for the plugin loader. + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes, Action configure) + { + return CreateFromAssemblyFile(assemblyFile, + config => + { + if (sharedTypes != null) + { + var uniqueAssemblies = new HashSet(); + foreach (var type in sharedTypes) + { + uniqueAssemblies.Add(type.Assembly); + } + + foreach (var assembly in uniqueAssemblies) + { + config.SharedAssemblies.Add(assembly.GetName()); + } + } + configure(config); + }); + } + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile) + => CreateFromAssemblyFile(assemblyFile, _ => { }); + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// A function which can be used to configure advanced options for the plugin loader. + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Action 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; + + /// + /// Initialize an instance of + /// + /// The configuration for the plugin. + public PluginLoader(PluginConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _contextBuilder = CreateLoadContextBuilder(config); + _context = (ManagedLoadContext)_contextBuilder.Build(); + if (config.EnableHotReload) + { + StartFileWatcher(); + } + } + + /// + /// True when this plugin is capable of being unloaded. + /// + public bool IsUnloadable => _context.IsCollectible; + + + /// + /// This event is raised when the plugin has been reloaded. + /// If was set to true, + /// the plugin will be reloaded when files on disk are changed. + /// + public event PluginReloadedEventHandler? Reloaded; + + /// + /// The unloads and reloads the plugin assemblies. + /// This method throws if is false. + /// + 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; + + /// + /// Load the main assembly for the plugin. + /// + public Assembly LoadDefaultAssembly() + { + EnsureNotDisposed(); + return _context.LoadAssemblyFromFilePath(_config.MainAssemblyPath); + } + + /// + /// Load an assembly by name. + /// + /// The assembly name. + /// The assembly. + public Assembly LoadAssembly(AssemblyName assemblyName) + { + EnsureNotDisposed(); + return _context.LoadFromAssemblyName(assemblyName); + } + + /// + /// Load an assembly from path. + /// + /// The assembly path. + /// The assembly. + public Assembly LoadAssemblyFromPath(string assemblyPath) + => _context.LoadAssemblyFromFilePath(assemblyPath); + + /// + /// Load an assembly by name. + /// + /// The assembly name. + /// The assembly. + public Assembly LoadAssembly(string assemblyName) + { + EnsureNotDisposed(); + return LoadAssembly(new AssemblyName(assemblyName)); + } + + /// + /// Sets the scope used by some System.Reflection APIs which might trigger assembly loading. + /// + /// See https://github.com/dotnet/coreclr/blob/v3.0.0/Documentation/design-docs/AssemblyLoadContext.ContextualReflection.md for more details. + /// + /// + /// + public AssemblyLoadContext.ContextualReflectionScope EnterContextualReflection() + => _context.EnterContextualReflection(); + + /// + /// Disposes the plugin loader. This only does something if is true. + /// When true, this will unload assemblies which which were loaded during the lifetime + /// of the plugin. + /// + 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 pluginLoaders) => _context.AddAssemblyLoadContexts(pluginLoaders.Select(p => p.LoadContext)); + } +} diff --git a/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs b/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs new file mode 100644 index 000000000..39775745a --- /dev/null +++ b/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs @@ -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 +{ + /// + /// Represents the method that will handle the event. + /// + /// The object sending the event + /// Data about the event. + public delegate void PluginReloadedEventHandler(object sender, PluginReloadedEventArgs eventArgs); + + /// + /// Provides data for the event. + /// + public class PluginReloadedEventArgs : EventArgs + { + /// + /// Initializes . + /// + /// + public PluginReloadedEventArgs(PluginLoader loader) + { + Loader = loader; + } + + /// + /// The plugin loader + /// + public PluginLoader Loader { get; } + } +} diff --git a/BTCPayServer/Plugins/PluginManager.cs b/BTCPayServer/Plugins/PluginManager.cs index bd5d270c9..0c00fb404 100644 --- a/BTCPayServer/Plugins/PluginManager.cs +++ b/BTCPayServer/Plugins/PluginManager.cs @@ -1,4 +1,6 @@ +#nullable enable using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -9,7 +11,7 @@ using System.Reflection; using System.Text.RegularExpressions; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Configuration; -using McMaster.NETCore.Plugins; +using BTCPayServer.Plugins.Dotnet; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -61,11 +63,57 @@ namespace BTCPayServer.Plugins return false; } + record PreloadedPlugin(IBTCPayServerPlugin Instance, PluginLoader? Loader, Assembly Assembly); + + class PreloadedPlugins : IEnumerable + { + List _plugins = new(); + readonly Dictionary _preloadedPluginsByIdentifier = new(StringComparer.OrdinalIgnoreCase); + + public bool Contains(string identifier) => _preloadedPluginsByIdentifier.ContainsKey(identifier); + public void Add(PreloadedPlugin plugin) + { + if (!_preloadedPluginsByIdentifier.TryAdd(plugin.Instance.Identifier, plugin)) + return; + _plugins.Add(plugin); + } + + public IEnumerator GetEnumerator() => _plugins.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _plugins.GetEnumerator(); + + public void Clear() + { + _plugins.Clear(); + _preloadedPluginsByIdentifier.Clear(); + } + + public void TopologicalSort() + { + // We want to run all the system plugins first. + // Then the rest topologically sorted. + var ordered = new List(_plugins.Count); + var topological = _plugins.TopologicalSort( + p => p.Instance.Dependencies.Select(d => d.Identifier), + p => p.Instance.Identifier, + p=> p, Comparer.Create((a, b) => string.Compare(a.Instance.Identifier, b.Instance.Identifier, StringComparison.Ordinal))).ToList(); + foreach (var p in topological.Where(t => t.Instance.SystemPlugin)) + ordered.Add(p); + foreach (var p in topological.Where(t => !t.Instance.SystemPlugin)) + ordered.Add(p); + _plugins = ordered; + } + + public PreloadedPlugin? TryGet(string identifier) + { + _preloadedPluginsByIdentifier.TryGetValue(identifier, out var p); + return p; + } + } + public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection, IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider) { - void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet exclude, HashSet loadedPluginIdentifiers1, - List btcPayServerPlugins) + void PreloadPluginsFromAssemblies(Assembly systemAssembly1, HashSet exclude, PreloadedPlugins preloadedPlugins) { var excludedAssemblies = "System.Private.CoreLib,testhost,System.Runtime,Microsoft.TestPlatform.CoreUtilities,System.Diagnostics.Tracing,System.Diagnostics.Debug,System.Runtime.Extensions,System.Diagnostics.Process,System.ComponentModel.Primitives,Microsoft.TestPlatform.PlatformAbstractions,System.Collections,Microsoft.TestPlatform.CrossPlatEngine,netstandard,Microsoft.TestPlatform.CommunicationUtilities,Microsoft.VisualStudio.TestPlatform.ObjectModel,Microsoft.VisualStudio.TestPlatform.Common,Newtonsoft.Json,System.Runtime.Serialization.Formatters,System.Collections.Concurrent,System.Diagnostics.TraceSource,System.Threading,System.IO.FileSystem,System.Runtime.InteropServices,System.Memory,Microsoft.Win32.Primitives,System.Threading.ThreadPool,System.Net.Primitives,System.Collections.NonGeneric,System.Net.Sockets,System.Private.Uri,System.Threading.Overlapped,System.Runtime.Intrinsics,System.Linq.Expressions,System.Runtime.Numerics,System.Linq,System.ComponentModel.TypeConverter,System.ObjectModel,System.Runtime.Serialization.Primitives,System.Data.Common,System.Xml.ReaderWriter,System.Private.Xml,System.ComponentModel,System.Reflection.Emit.ILGeneration,System.Reflection.Emit.Lightweight,System.Reflection.Primitives,Anonymously Hosted DynamicMethods Assembly,System.Runtime.Loader,System.Reflection.Metadata,System.IO.MemoryMappedFiles,System.Collections.Immutable,System.Text.Encoding.Extensions,xunit.runner.visualstudio.testadapter,System.Runtime.Serialization.Json,System.Private.DataContractSerialization,System.Runtime.Serialization.Xml,System.Resources.ResourceManager,xunit.runner.utility.netcoreapp10,xunit.abstractions,System.Text.RegularExpressions,System.Runtime.InteropServices.RuntimeInformation,NuGet.Frameworks,System.Xml.XDocument,System.Private.Xml.Linq,System.Threading.Thread,System.Globalization,System.IO,System.Reflection,System.Reflection.TypeExtensions,xunit.runner.reporters.netcoreapp10,System.Threading.Tasks,System.Net.Http,System.Reflection.Extensions,BTCPayServer.Tests,Microsoft.AspNetCore.Mvc.ViewFeatures,Microsoft.AspNetCore.Mvc.Core,Microsoft.AspNetCore.Mvc.Abstractions,xunit.core,BTCPayServer.Rating,Microsoft.Extensions.Logging.Abstractions,NBitpayClient,NBitcoin,BTCPayServer.Client,BTCPayServer.Data,Microsoft.AspNetCore.Http.Abstractions,BTCPayServer.Abstractions,BTCPayServer.Lightning.Common,Microsoft.EntityFrameworkCore,NBXplorer.Client,BTCPayServer.BIP78.Sender,Microsoft.Extensions.Identity.Stores,LNURL,Renci.SshNet,Microsoft.Extensions.Identity.Core,Newtonsoft.Json.Schema,Microsoft.AspNetCore.Razor.Language,Microsoft.CodeAnalysis.CSharp,Microsoft.CodeAnalysis,xunit.execution.dotnet,Microsoft.Extensions.Configuration.UserSecrets,System.Text.Encoding,BTCPayServer.Common,WebDriver,System.Drawing.Primitives,xunit.assert,Microsoft.Extensions.Configuration.Abstractions,Microsoft.Extensions.DependencyInjection,Microsoft.Extensions.DependencyInjection.Abstractions,Microsoft.Extensions.Configuration,Microsoft.Extensions.Primitives,StandardConfiguration,CommandLine,System.Security.Cryptography.Algorithms,System.Security.Cryptography,System.Security.Cryptography.Primitives,NBitcoin.Altcoins,ExchangeSharp,Microsoft.Extensions.Http,Microsoft.Extensions.Hosting.Abstractions,Microsoft.Extensions.Logging,Microsoft.Extensions.Options,Microsoft.Extensions.Diagnostics,System.Diagnostics.DiagnosticSource,System.Net.Security,System.Net.NameResolution,Microsoft.Extensions.Configuration.FileExtensions,Microsoft.Extensions.Configuration.Json,Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Hosting.Abstractions,Microsoft.AspNetCore.Server.Kestrel,Microsoft.Extensions.Features,Microsoft.AspNetCore.Hosting.Server.Abstractions,Microsoft.Extensions.Configuration.EnvironmentVariables,CommandLine.Configuration,Microsoft.Extensions.Configuration.Ini,Microsoft.Extensions.FileProviders.Abstractions,Microsoft.Extensions.FileProviders.Physical,Microsoft.AspNetCore.Server.Kestrel.Transport.Quic,Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes,System.Net.Quic,Microsoft.Win32.Registry,Microsoft.AspNetCore.Http,Microsoft.AspNetCore.Razor.Runtime,Microsoft.AspNetCore.Connections.Abstractions,Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets,Microsoft.AspNetCore.Server.Kestrel.Core,Microsoft.Extensions.ObjectPool,NicolasDorier.RateLimits,Microsoft.Extensions.Caching.Memory,Microsoft.AspNetCore.DataProtection,Microsoft.AspNetCore.Identity,Microsoft.AspNetCore.Identity.EntityFrameworkCore,Microsoft.AspNetCore.Authentication.Abstractions,Microsoft.AspNetCore.Authentication.Cookies,Microsoft.AspNetCore.Authentication,Microsoft.AspNetCore.Session,Microsoft.AspNetCore.SignalR,Microsoft.AspNetCore.SignalR.Core,Microsoft.AspNetCore.SignalR.Common,Fido2.Models,Fido2.AspNet,Fido2,Microsoft.AspNetCore.Mvc,Microsoft.AspNetCore.Mvc.Razor,Microsoft.AspNetCore.Mvc.NewtonsoftJson,Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation,Microsoft.AspNetCore.Mvc.DataAnnotations,Microsoft.AspNetCore.Components.Server,Microsoft.AspNetCore.Components.Endpoints,Microsoft.AspNetCore.Routing.Abstractions,Microsoft.Extensions.Caching.Abstractions,Microsoft.AspNetCore.DataProtection.Abstractions,Microsoft.AspNetCore.Cryptography.Internal,Microsoft.AspNetCore.Authentication.Core,System.Security.Claims,Microsoft.Extensions.WebEncoders,System.Text.Encodings.Web,Microsoft.Extensions.Localization.Abstractions,Microsoft.AspNetCore.Mvc.Localization,Npgsql.EntityFrameworkCore.PostgreSQL,Microsoft.AspNetCore.Diagnostics,HtmlSanitizer,Microsoft.AspNetCore.Authorization,Microsoft.AspNetCore.Authorization.Policy,Microsoft.AspNetCore.Cors,System.ComponentModel.Annotations,Serilog,Serilog.Sinks.File,Serilog.Extensions.Logging,Microsoft.Extensions.Configuration.Binder,Microsoft.AspNetCore.Http.Features,TwentyTwenty.Storage,TwentyTwenty.Storage.Azure,Microsoft.AspNetCore.WebSockets,Microsoft.AspNetCore.Http.Connections,Microsoft.AspNetCore.Routing,Microsoft.AspNetCore.SignalR.Protocols.Json,System.Text.Json,McMaster.NETCore.Plugins.Mvc,Microsoft.AspNetCore.Mvc.ApiExplorer,Microsoft.AspNetCore.Mvc.Cors,Microsoft.AspNetCore.Antiforgery,Microsoft.AspNetCore.Components,Microsoft.AspNetCore.Components.Web,Microsoft.JSInterop,Microsoft.AspNetCore.Mvc.TagHelpers,Microsoft.AspNetCore.Razor,Microsoft.AspNetCore.Mvc.RazorPages,Microsoft.AspNetCore.Html.Abstractions,McMaster.NETCore.Plugins,Selenium,Selenium.Support,Selenium.WebDriver,Selenium.WebDriver.ChromeDriver,WebDriver.Support" .Split(',').ToHashSet(); @@ -75,18 +123,16 @@ namespace BTCPayServer.Plugins foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { var assemblyName = assembly.GetName().Name; - if (excludedAssemblies.Contains(assemblyName)) + if (assemblyName is null || excludedAssemblies.Contains(assemblyName)) continue; - bool isSystemPlugin = assembly == systemAssembly1; + var isSystemPlugin = assembly == systemAssembly1; if (!isSystemPlugin && exclude.Contains(assemblyName)) continue; foreach (var plugin in GetPluginInstancesFromAssembly(assembly, true)) { if (!isSystemPlugin && plugin.Identifier != assemblyName) continue; - if (!loadedPluginIdentifiers1.Add(plugin.Identifier)) - continue; - btcPayServerPlugins.Add(plugin); + preloadedPlugins.Add(new PreloadedPlugin(plugin, null, assembly)); plugin.SystemPlugin = isSystemPlugin; } } @@ -94,8 +140,7 @@ namespace BTCPayServer.Plugins var logger = loggerFactory.CreateLogger(typeof(PluginManager)); var pluginsFolder = new DataDirectories().Configure(config).PluginDir; - var plugins = new List(); - var loadedPluginIdentifiers = new HashSet(); + var preloadedPlugins = new PreloadedPlugins(); serviceCollection.Configure(options => { @@ -107,16 +152,15 @@ namespace BTCPayServer.Plugins var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder); var systemAssembly = typeof(Program).Assembly; - LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins); + PreloadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, preloadedPlugins); - if (ExecuteCommands(pluginsFolder, plugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version))) + if (ExecuteCommands(pluginsFolder, preloadedPlugins.ToDictionary(p => p.Instance.Identifier, p => p.Instance.Version))) { - plugins.Clear(); - loadedPluginIdentifiers.Clear(); - LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins); + preloadedPlugins.Clear(); + PreloadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, preloadedPlugins); } - var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>(); + var pluginsToPreload = new List<(string PluginIdentifier, string PluginFilePath)>(); #if DEBUG // Load from DEBUG_PLUGINS, in an optional appsettings.dev.json @@ -124,19 +168,19 @@ namespace BTCPayServer.Plugins foreach (var plugin in debugPlugins.Split(';', StringSplitOptions.RemoveEmptyEntries)) { // Formatted either as "::" or "" - var idx = plugin.IndexOf("::"); + var idx = plugin.IndexOf("::", StringComparison.Ordinal); var filePath = plugin; if (idx != -1) { filePath = plugin[(idx + 1)..]; filePath = Path.GetFullPath(filePath); - pluginsToLoad.Add((plugin[0..idx], filePath)); + pluginsToPreload.Add((plugin[0..idx], filePath)); } else { filePath = Path.GetFullPath(filePath); - pluginsToLoad.Add((Path.GetFileNameWithoutExtension(plugin), filePath)); + pluginsToPreload.Add((Path.GetFileNameWithoutExtension(plugin), filePath)); } } #endif @@ -150,100 +194,120 @@ namespace BTCPayServer.Plugins continue; if (disabledPluginIdentifiers.Contains(pluginIdentifier)) continue; - pluginsToLoad.Add((pluginIdentifier, pluginFilePath)); + pluginsToPreload.Add((pluginIdentifier, pluginFilePath)); } - ReorderPlugins(pluginsFolder, pluginsToLoad); - var crashedPlugins = new List(); - foreach (var toLoad in pluginsToLoad) + var toDisable = new List(); + + foreach (var toLoad in pluginsToPreload) { - if (!loadedPluginIdentifiers.Add(toLoad.PluginIdentifier)) + if (preloadedPlugins.Contains(toLoad.PluginIdentifier)) continue; try { - - var plugin = PluginLoader.CreateFromAssemblyFile( + var loader = PluginLoader.CreateFromAssemblyFile( toLoad.PluginFilePath, // create a plugin from for the .dll file - config => + c => { // this ensures that the version of MVC is shared between this app and the plugin - config.PreferSharedTypes = true; - config.IsUnloadable = false; + c.PreferSharedTypes = true; + c.IsUnloadable = false; }); - var pluginAssembly = plugin.LoadDefaultAssembly(); + var pluginAssembly = loader.LoadDefaultAssembly(); - var p = GetPluginInstanceFromAssembly(toLoad.PluginIdentifier, pluginAssembly); + var p = GetPluginInstanceFromAssembly(toLoad.PluginIdentifier, pluginAssembly, silentlyFails: true); if (p == null) { logger.LogError($"The plugin assembly doesn't contain the plugin {toLoad.PluginIdentifier}"); - crashedPlugins.Add(toLoad.PluginIdentifier); + toDisable.Add(toLoad.PluginIdentifier); } else { - mvcBuilder.AddPluginLoader(plugin); - _pluginAssemblies.Add(pluginAssembly); p.SystemPlugin = false; - plugins.Add(p); + preloadedPlugins.Add(new(p, loader, pluginAssembly)); } } catch (Exception e) { logger.LogError(e, $"Error when loading plugin {toLoad.PluginIdentifier}."); - crashedPlugins.Add(toLoad.PluginIdentifier); + toDisable.Add(toLoad.PluginIdentifier); } } - foreach (var plugin in plugins) + preloadedPlugins.TopologicalSort(); + var loadedPlugins = new List(); + foreach (var preloadedPlugin in preloadedPlugins) { + var plugin = preloadedPlugin.Instance; try { + AssertDependencies(plugin, loadedPlugins); + if (preloadedPlugin.Loader is { } loader) + loader.AddAssemblyLoadContexts( + plugin.Dependencies + .Select(d => preloadedPlugins.TryGet(d.Identifier)?.Loader) + .Where(d => d is not null) + .ToArray()!); + + // silentlyFails is false, because we want this to throw if there is any missing assembly. + GetPluginInstanceFromAssembly(plugin.Identifier, preloadedPlugin.Assembly, silentlyFails: false); + if (preloadedPlugin.Loader is not null) + mvcBuilder.AddPluginLoader(preloadedPlugin.Loader); + _pluginAssemblies.Add(preloadedPlugin.Assembly); + logger.LogInformation( $"Adding and executing plugin {plugin.Identifier} - {plugin.Version}"); var pluginServiceCollection = new PluginServiceCollection(serviceCollection, bootstrapServiceProvider); plugin.Execute(pluginServiceCollection); serviceCollection.AddSingleton(plugin); + loadedPlugins.Add(preloadedPlugin); + } + catch (MissingDependenciesException e) + { + // The difference is that we don't print the stacktrace and we do not disable it + logger.LogError($"Error when executing plugin {plugin.Identifier} - {plugin.Version}: {e.Message}"); } catch (Exception e) { logger.LogError(e, $"Error when executing plugin {plugin.Identifier} - {plugin.Version}."); - crashedPlugins.Add(plugin.Identifier); - } + if (!plugin.SystemPlugin) + toDisable.Add(plugin.Identifier); + } } - if (crashedPlugins.Count > 0) + if (toDisable.Count > 0) { - foreach (var plugin in crashedPlugins) + foreach (var plugin in toDisable) DisablePlugin(pluginsFolder, plugin); - var crashedPluginsStr = String.Join(", ", crashedPlugins); + var crashedPluginsStr = string.Join(", ", toDisable); throw new ConfigException($"The following plugin(s) crashed at startup, they will be disabled and the server will restart: {crashedPluginsStr}"); } return mvcBuilder; } - private static void ReorderPlugins(string pluginsFolder, List<(string PluginIdentifier, string PluginFilePath)> pluginsToLoad) + class MissingDependenciesException(string message) : Exception(message); + + private static void AssertDependencies(IBTCPayServerPlugin plugin, List loaded) { - Dictionary ordersByPlugin = new Dictionary(); - var orderFilePath = Path.Combine(pluginsFolder, "order"); - int order = 0; - if (File.Exists(orderFilePath)) + var missing = new List(); + var installed = loaded.ToDictionary(l => l.Instance.Identifier, l => l.Instance.Version); + foreach (var d in plugin.Dependencies) { - foreach (var o in File.ReadLines(orderFilePath)) + if (!DependencyMet(d, installed)) { - if (ordersByPlugin.TryAdd(o, order)) - order++; + missing.Add(d); } } - foreach (var p in pluginsToLoad) + if (missing.Any()) { - if (ordersByPlugin.TryAdd(p.PluginIdentifier, order)) - order++; + throw new MissingDependenciesException( + $"Plugin {plugin.Identifier} is missing dependencies: {string.Join(", ", missing.Select(d => d.ToString()))}"); } - pluginsToLoad.Sort((a, b) => ordersByPlugin[a.PluginIdentifier] - ordersByPlugin[b.PluginIdentifier]); } public static void UsePlugins(this IApplicationBuilder applicationBuilder) { - HashSet assemblies = new HashSet(); + var assemblies = new HashSet(); foreach (var extension in applicationBuilder.ApplicationServices .GetServices()) { @@ -252,8 +316,8 @@ namespace BTCPayServer.Plugins assemblies.Add(extension.GetType().Assembly); } - var webHostEnvironment = applicationBuilder.ApplicationServices.GetService(); - List providers = new List() { webHostEnvironment.WebRootFileProvider }; + var webHostEnvironment = applicationBuilder.ApplicationServices.GetRequiredService(); + var providers = new List() { webHostEnvironment.WebRootFileProvider }; providers.AddRange(assemblies.Select(a => new EmbeddedFileProvider(a))); webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers); } @@ -263,7 +327,8 @@ namespace BTCPayServer.Plugins return GetTypes(assembly, silentlyFails).Where(type => typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) && !type.IsAbstract). - Select(type => (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty())); + Select(type => Activator.CreateInstance(type, Array.Empty()) as IBTCPayServerPlugin) + .Where(t => t is not null)!; } private static IEnumerable GetTypes(Assembly assembly, bool silentlyFails) @@ -274,16 +339,16 @@ namespace BTCPayServer.Plugins } catch (ReflectionTypeLoadException ex) when (silentlyFails) { - return ex.Types; + return ex.Types.Where(t => t is not null)!; } } - private static IBTCPayServerPlugin GetPluginInstanceFromAssembly(string pluginIdentifier, Assembly assembly) + private static IBTCPayServerPlugin? GetPluginInstanceFromAssembly(string pluginIdentifier, Assembly assembly, bool silentlyFails) { - return GetPluginInstancesFromAssembly(assembly, false).FirstOrDefault(plugin => plugin.Identifier == pluginIdentifier); + return GetPluginInstancesFromAssembly(assembly, silentlyFails).FirstOrDefault(plugin => plugin.Identifier == pluginIdentifier); } - private static bool ExecuteCommands(string pluginsFolder, Dictionary installed = null) + private static bool ExecuteCommands(string pluginsFolder, Dictionary? installed = null) { var pendingCommands = GetPendingCommands(pluginsFolder); if (!pendingCommands.Any()) @@ -291,7 +356,7 @@ namespace BTCPayServer.Plugins return false; } - var remainingCommands = (from command in pendingCommands where !ExecuteCommand(command, pluginsFolder, false, installed) select $"{command.command}:{command.plugin}").ToList(); + var remainingCommands = (from command in pendingCommands where !ExecuteCommand(command, pluginsFolder, installed) select $"{command.command}:{command.plugin}").ToList(); if (remainingCommands.Any()) { File.WriteAllLines(Path.Combine(pluginsFolder, "commands"), remainingCommands); @@ -303,12 +368,12 @@ namespace BTCPayServer.Plugins return remainingCommands.Count != pendingCommands.Length; } - private static Dictionary TryGetInstalledInfo( + private static Dictionary TryGetInstalledInfo( string pluginsFolder) { var disabled = GetDisabledPluginIdentifiers(pluginsFolder); - var installed = new Dictionary(); - foreach (string pluginDir in Directory.EnumerateDirectories(pluginsFolder)) + var installed = new Dictionary(); + foreach (var pluginDir in Directory.EnumerateDirectories(pluginsFolder)) { var plugin = Path.GetFileName(pluginDir); var dirName = Path.Combine(pluginsFolder, plugin); @@ -317,7 +382,8 @@ namespace BTCPayServer.Plugins if (File.Exists(manifestFileName)) { var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject(); - installed.TryAdd(pluginManifest.Identifier, (pluginManifest.Version, pluginManifest.Dependencies, isDisabled)); + if (pluginManifest is not null) + installed.TryAdd(pluginManifest.Identifier, (pluginManifest.Version, pluginManifest.Dependencies, isDisabled)); } else if (isDisabled) { @@ -335,11 +401,12 @@ namespace BTCPayServer.Plugins var manifestFileName = dirName + ".json"; if (!File.Exists(manifestFileName)) return true; var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject(); - return DependenciesMet(pluginManifest.Dependencies, installed); + if (pluginManifest is not null) + return DependenciesMet(pluginManifest.Dependencies, installed); + return true; } - private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder, - bool ignoreOrder, Dictionary installed) + private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder, Dictionary installed) { var dirName = Path.Combine(pluginsFolder, command.extension); switch (command.command) @@ -347,12 +414,12 @@ namespace BTCPayServer.Plugins case "update": if (!DependenciesMet(pluginsFolder, command.extension, installed)) return false; - ExecuteCommand(("delete", command.extension), pluginsFolder, true, installed); - ExecuteCommand(("install", command.extension), pluginsFolder, true, installed); + ExecuteCommand(("delete", command.extension), pluginsFolder, installed); + ExecuteCommand(("install", command.extension), pluginsFolder, installed); break; case "delete": - ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed); + ExecuteCommand(("enable", command.extension), pluginsFolder, installed); if (File.Exists(dirName)) { File.Delete(dirName); @@ -360,12 +427,6 @@ namespace BTCPayServer.Plugins if (Directory.Exists(dirName)) { Directory.Delete(dirName, true); - if (!ignoreOrder && File.Exists(Path.Combine(pluginsFolder, "order"))) - { - var orders = File.ReadAllLines(Path.Combine(pluginsFolder, "order")); - File.WriteAllLines(Path.Combine(pluginsFolder, "order"), - orders.Where(s => s != command.extension)); - } } break; @@ -375,14 +436,10 @@ namespace BTCPayServer.Plugins if (!DependenciesMet(pluginsFolder, command.extension, installed)) return false; - ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed); + ExecuteCommand(("enable", command.extension), pluginsFolder, installed); if (File.Exists(fileName)) { ZipFile.ExtractToDirectory(fileName, dirName, true); - if (!ignoreOrder) - { - File.AppendAllLines(Path.Combine(pluginsFolder, "order"), new[] { command.extension }); - } File.Delete(fileName); if (File.Exists(manifestFileName)) { @@ -452,7 +509,7 @@ namespace BTCPayServer.Plugins if (File.Exists(Path.Combine(pluginDir, plugin, BTCPayPluginSuffix))) { File.Delete(Path.Combine(pluginDir, plugin, BTCPayPluginSuffix)); - } + } if (File.Exists(Path.Combine(pluginDir, plugin, ".json"))) { File.Delete(Path.Combine(pluginDir, plugin, ".json")); @@ -474,14 +531,14 @@ namespace BTCPayServer.Plugins } // List of disabled plugins with additional info, like the disabled version and its dependencies - public static Dictionary GetDisabledPlugins(string pluginsFolder) + public static Dictionary GetDisabledPlugins(string pluginsFolder) { return TryGetInstalledInfo(pluginsFolder).Where(pair => pair.Value.Disabled) .ToDictionary(pair => pair.Key, pair => pair.Value.Item1); } public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency, - Dictionary installed = null) + Dictionary? installed = null) { var plugin = dependency.Identifier.ToLowerInvariant(); var versionReq = dependency.Condition; @@ -530,7 +587,7 @@ namespace BTCPayServer.Plugins } public static bool DependenciesMet(IEnumerable dependencies, - Dictionary installed = null) + Dictionary? installed) { return dependencies.All(dependency => DependencyMet(dependency, installed)); } diff --git a/BTCPayServer/TopologicalSortExtensions.cs b/BTCPayServer/TopologicalSortExtensions.cs new file mode 100644 index 000000000..42f495e09 --- /dev/null +++ b/BTCPayServer/TopologicalSortExtensions.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BTCPayServer; + +public static class TopologicalSortExtensions +{ + public static List TopologicalSort(this IReadOnlyCollection nodes, Func> dependsOn) + { + return nodes.TopologicalSort(dependsOn, k => k, k => k); + } + + public static List TopologicalSort(this IReadOnlyCollection nodes, Func> dependsOn, Func getKey) + { + return nodes.TopologicalSort(dependsOn, getKey, o => o); + } + + public static List TopologicalSort(this IReadOnlyCollection nodes, + Func> dependsOn, + Func getKey, + Func getValue, + IComparer solveTies = null) + { + if (nodes.Count == 0) + return new List(); + if (getKey == null) + throw new ArgumentNullException(nameof(getKey)); + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + solveTies = solveTies ?? Comparer.Default; + List result = new List(nodes.Count); + HashSet allKeys = new HashSet(nodes.Count); + var noDependencies = new SortedDictionary>(solveTies); + + foreach (var node in nodes) + allKeys.Add(getKey(node)); + var dependenciesByValues = nodes.ToDictionary(node => node, + node => new HashSet(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; + } +}