diff --git a/src/Plugins/Loader/AssemblyLoadContextBuilder.cs b/src/Plugins/Loader/AssemblyLoadContextBuilder.cs index 03329bd..c7dd8e0 100644 --- a/src/Plugins/Loader/AssemblyLoadContextBuilder.cs +++ b/src/Plugins/Loader/AssemblyLoadContextBuilder.cs @@ -26,6 +26,7 @@ public class AssemblyLoadContextBuilder private AssemblyLoadContext _defaultLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; private string? _mainAssemblyPath; private bool _preferDefaultLoadContext; + private bool _lazyLoadReferences; #if FEATURE_UNLOAD private bool _isCollectible; @@ -63,6 +64,7 @@ public AssemblyLoadContext Build() resourceProbingPaths, _defaultLoadContext, _preferDefaultLoadContext, + _lazyLoadReferences, #if FEATURE_UNLOAD _isCollectible, _loadInMemory, @@ -148,21 +150,46 @@ public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assembl /// The builder. public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName) { - if (assemblyName.Name == null || _defaultAssemblies.Contains(assemblyName.Name)) + // Lazy loaded references have dependencies resolved as they are loaded inside the actual Load Context. + if (_lazyLoadReferences) { - // base cases + 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; } - _defaultAssemblies.Add(assemblyName.Name); - - // Recursively 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(assemblyName); - foreach (var reference in assembly.GetReferencedAssemblies()) + var names = new Queue(); + names.Enqueue(assemblyName); + while (names.TryDequeue(out var name)) { - PreferDefaultLoadContextAssembly(reference); + 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; @@ -187,6 +214,24 @@ public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoa 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. /// diff --git a/src/Plugins/Loader/ManagedLoadContext.cs b/src/Plugins/Loader/ManagedLoadContext.cs index 1c5185f..d4acd46 100644 --- a/src/Plugins/Loader/ManagedLoadContext.cs +++ b/src/Plugins/Loader/ManagedLoadContext.cs @@ -24,11 +24,12 @@ internal class ManagedLoadContext : AssemblyLoadContext private readonly IReadOnlyDictionary _managedAssemblies; private readonly IReadOnlyDictionary _nativeLibraries; private readonly IReadOnlyCollection _privateAssemblies; - private readonly IReadOnlyCollection _defaultAssemblies; + 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 AssemblyLoadContext _defaultLoadContext; #if FEATURE_NATIVE_RESOLVER private readonly AssemblyDependencyResolver _dependencyResolver; @@ -45,6 +46,7 @@ internal class ManagedLoadContext : AssemblyLoadContext IReadOnlyCollection resourceProbingPaths, AssemblyLoadContext defaultLoadContext, bool preferDefaultLoadContext, + bool lazyLoadReferences, bool isCollectible, bool loadInMemory, bool shadowCopyNativeLibraries) @@ -64,12 +66,13 @@ internal class ManagedLoadContext : AssemblyLoadContext _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 ?? throw new ArgumentNullException(nameof(defaultAssemblies)); + _defaultAssemblies = defaultAssemblies != null ? defaultAssemblies.ToList() : throw new ArgumentNullException(nameof(defaultAssemblies)); _nativeLibraries = nativeLibraries ?? throw new ArgumentNullException(nameof(nativeLibraries)); _additionalProbingPaths = additionalProbingPaths ?? throw new ArgumentNullException(nameof(additionalProbingPaths)); _defaultLoadContext = defaultLoadContext; _preferDefaultLoadContext = preferDefaultLoadContext; _loadInMemory = loadInMemory; + _lazyLoadReferences = lazyLoadReferences; _resourceRoots = new[] { _basePath } .Concat(resourceProbingPaths) @@ -105,6 +108,19 @@ internal class ManagedLoadContext : AssemblyLoadContext var defaultAssembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName); 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. diff --git a/src/Plugins/PluginConfig.cs b/src/Plugins/PluginConfig.cs index 8fc0737..a7cf617 100644 --- a/src/Plugins/PluginConfig.cs +++ b/src/Plugins/PluginConfig.cs @@ -62,10 +62,22 @@ public PluginConfig(string mainAssemblyPath) /// 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) != + /// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != /// public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; @@ -104,9 +116,9 @@ public bool LoadInMemory /// public bool EnableHotReload { get; set; } - /// + /// /// Specifies the delay to reload a plugin, after file changes have been detected. - /// Default value is 200 milliseconds. + /// Default value is 200 milliseconds. /// public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200); #endif diff --git a/src/Plugins/PluginLoader.cs b/src/Plugins/PluginLoader.cs index 7b67508..ee2d8a4 100644 --- a/src/Plugins/PluginLoader.cs +++ b/src/Plugins/PluginLoader.cs @@ -2,6 +2,7 @@ // 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; @@ -112,9 +113,15 @@ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sh { if (sharedTypes != null) { + var uniqueAssemblies = new HashSet(); foreach (var type in sharedTypes) { - config.SharedAssemblies.Add(type.Assembly.GetName()); + uniqueAssemblies.Add(type.Assembly); + } + + foreach (var assembly in uniqueAssemblies) + { + config.SharedAssemblies.Add(assembly.GetName()); } } configure(config); @@ -374,6 +381,7 @@ private static AssemblyLoadContextBuilder CreateLoadContextBuilder(PluginConfig } #endif + builder.IsLazyLoaded(config.IsLazyLoaded); foreach (var assemblyName in config.SharedAssemblies) { builder.PreferDefaultLoadContextAssembly(assemblyName); diff --git a/src/Plugins/PublicAPI.Unshipped.txt b/src/Plugins/PublicAPI.Unshipped.txt index e69de29..0707ad6 100644 --- a/src/Plugins/PublicAPI.Unshipped.txt +++ b/src/Plugins/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +McMaster.NETCore.Plugins.PluginConfig.IsLazyLoaded.get -> bool +McMaster.NETCore.Plugins.PluginConfig.IsLazyLoaded.set -> void +McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder.IsLazyLoaded(bool isLazyLoaded) -> McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder! diff --git a/src/Plugins/releasenotes.props b/src/Plugins/releasenotes.props index c3b7385..e772487 100644 --- a/src/Plugins/releasenotes.props +++ b/src/Plugins/releasenotes.props @@ -2,7 +2,8 @@ Changes: -* @Sewer56 - search in additional probing paths (PR #172) +* @Sewer56 - feature: add option to support lazy loading of transitive dependencies to increase performance (PR #164) +* @Sewer56 - bugfix: search in additional probing paths (PR #172) Changes: diff --git a/test/Plugins.Tests/SharedTypesTests.cs b/test/Plugins.Tests/SharedTypesTests.cs index 0e717ae..f03f42c 100644 --- a/test/Plugins.Tests/SharedTypesTests.cs +++ b/test/Plugins.Tests/SharedTypesTests.cs @@ -42,13 +42,12 @@ public void PluginsCanForceSharedTypes() /// accounted for. Without this, the order in which code loads /// could cause different assembly versions to be loaded. /// - [Fact] - public void TransitiveAssembliesOfSharedTypesAreResolved() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TransitiveAssembliesOfSharedTypesAreResolved(bool isLazyLoaded) { - using var loader = PluginLoader.CreateFromAssemblyFile( - TestResources.GetTestProjectAssembly("TransitivePlugin"), - sharedTypes: new[] { typeof(SharedType) }); - + using var loader = PluginLoader.CreateFromAssemblyFile(TestResources.GetTestProjectAssembly("TransitivePlugin"), sharedTypes: new[] { typeof(SharedType) }, config => config.IsLazyLoaded = isLazyLoaded); var assembly = loader.LoadDefaultAssembly(); var configType = assembly.GetType("TransitivePlugin.PluginConfig", throwOnError: true)!; var config = Activator.CreateInstance(configType);