Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4cf554c
Read plugin manifests from metadata and plan which plugin assemblies …
Nov 2, 2026
40cb2b7
Load each plugin in isolation so a faulty plugin folder cannot stop t…
Nov 2, 2026
c7466e0
List an installed plugin that fails at scan time as installed and kee…
Nov 3, 2026
1f42024
Validate uploaded plugins from their manifest and install them atomic…
Nov 3, 2026
b60e0d9
Bound plugin archive extraction by real size and keep the previous pl…
Nov 3, 2026
d4132a4
Let the upload controller delete only its own archive and prove the z…
Nov 3, 2026
ad8dd7f
Write the installed-plugins file atomically and keep plugin activatio…
Nov 3, 2026
e1aa75e
Make the plugin list reload a POST action and guard plugin removal
Nov 3, 2026
516f41e
Let a broken plugin be uninstalled and deliver the restart texts to e…
Nov 3, 2026
cd8d54c
Ask for a restart before installing or uninstalling a plugin whose fi…
Nov 3, 2026
9870cbf
Cache assembly types between type scans
Nov 4, 2026
f18133e
Load a plugin's assemblies only once it is installed
Nov 4, 2026
def6af5
State the LiteDB mapper race accepted when a plugin is installed with…
Nov 4, 2026
969aa00
Load each plugin into its own context with its own dependencies
Nov 4, 2026
d47aff8
Load modules like plugins and stop shipping host assembly copies
Nov 4, 2026
4bce3b5
Keep the plugin and module loader logs on every configured logging pr…
Nov 4, 2026
9e02ecd
Keep the plugin manager's log on every configured logging provider
Nov 4, 2026
9c5e55f
Fail a plugin's load when its code references an assembly its context…
Nov 4, 2026
50f19f1
Let shipping by weight quote a customer without checkout attributes
Nov 4, 2026
faa9ebc
Resolve types only in a plugin's own code, not in its third-party lib…
Nov 4, 2026
2398191
Keep a plugin upload from replacing another plugin's folder
Nov 4, 2026
98dd958
Say which version an incompatible plugin was built for when it is cre…
Nov 4, 2026
6f49918
Report a skip-pattern timeout as a problem of the plugin folder
Nov 4, 2026
c26fc57
Use the admin note markup for the plugin restart notice
Nov 4, 2026
92fedcb
Merge develop into fix/plugin-loading
Nov 4, 2026
fe0ab9b
Remove the plugin folder chosen on the list, also one whose plugin wa…
Nov 4, 2026
3b759ef
List a second folder of an installed system name as not installed
Nov 4, 2026
9d77582
Delete the shadow copies of plugin folders that are gone
Nov 4, 2026
f5e865e
Scan each plugin's own copy of a library two plugins ship
Nov 4, 2026
59ee5c2
Report a replaced dependency, and a plugin copied into an existing fo…
Nov 4, 2026
c6ea6d8
Say that a module not deployed with the application is not a load fai…
Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Build/Grand.Plugin.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
<!--
The host assemblies every plugin compiles against.

Private=false keeps them out of the plugin's output folder: the plugin is loaded
into the host's AssemblyLoadContext, which already has them, and a second copy of
Private=false keeps them out of the plugin's output folder: the plugin's own load
context takes them from the host's, which already has them, and a second copy of
a host assembly beside a plugin is a hazard - if that copy is ever the one loaded,
its types are not the host's types and DI resolution fails in ways that are hard
to trace.
Expand Down
11 changes: 10 additions & 1 deletion src/Core/Grand.Infrastructure/Configuration/ExtensionsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public class ExtensionsConfig
public bool DisableUploadExtensions { get; set; }

/// <summary>
/// Gets or sets a value indicating whether copy dll plugin files to /Plugins/bin on application startup
/// Gets or sets a value indicating whether each installed plugin's folder is copied to /Plugins/bin/{folder}
/// on application startup and loaded from there
/// </summary>
public bool PluginShadowCopy { get; set; }

Expand All @@ -29,4 +30,12 @@ public class ExtensionsConfig
/// List of installed plugins (comma separated)
/// </summary>
public string InstalledPlugins { get; set; }

/// <summary>
/// Stop the application when an installed plugin, or a module enabled under FeatureManagement, fails to
/// load. Off: the plugin is logged, listed with its error and left inactive, the module is logged as
/// critical, and the application starts without it. A module whose folder is not deployed with this
/// application is not a load failure: it is logged as a warning either way.
/// </summary>
public bool FailOnPluginLoadError { get; set; }
}
5 changes: 5 additions & 0 deletions src/Core/Grand.Infrastructure/Grand.Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@
<ProjectReference Include="..\Grand.Mapping\Grand.Mapping.csproj" />
<ProjectReference Include="..\Grand.Mediator\Grand.Mediator.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Grand.Infrastructure.Tests" />
<InternalsVisibleTo Include="Grand.Web.Admin.Tests" />
<InternalsVisibleTo Include="Grand.Web.Common.Tests" />
</ItemGroup>
</Project>
170 changes: 116 additions & 54 deletions src/Core/Grand.Infrastructure/Modules/ModuleLoader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using Grand.Infrastructure.Configuration;
using Grand.Infrastructure.Plugins;
using Grand.SharedKernel;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -9,12 +12,19 @@

namespace Grand.Infrastructure.Modules;

/// <summary>
/// Loads the modules enabled under FeatureManagement, each into a load context of its own, as plugins are
/// loaded: the module shares the host's assemblies and resolves its private libraries from its folder.
/// </summary>
public static class ModuleLoader
{
private const string MODULE_SECTION_PATH = "FeatureManagement";
private const string MODULES_DIRECTORY = "Modules";
private const string LOGGER_NAME = "ModuleManager";

//the contexts of modules that failed to load; their assemblies stay loaded, so scans must skip them
private static readonly ConditionalWeakTable<AssemblyLoadContext, object> FailedContexts = new();

private static List<(string FeatureName, bool IsEnabled)> GetFeatureList(IConfiguration configuration)
{
var featureSection = configuration.GetSection(MODULE_SECTION_PATH);
Expand All @@ -30,29 +40,73 @@ public static class ModuleLoader
))
.ToList();
}


/// <summary>
/// Loads the enabled modules. A module that cannot be loaded is logged and left out, or, with
/// Extensions:FailOnPluginLoadError, stops the application.
/// </summary>
/// <exception cref="GrandException">A module failed to load and Extensions:FailOnPluginLoadError is set</exception>
[MethodImpl(MethodImplOptions.NoInlining)]
public static void LoadModules(IMvcCoreBuilder mvcCoreBuilder, IConfiguration configuration, IWebHostEnvironment hostingEnvironment)
{
LoadModules(mvcCoreBuilder, configuration, hostingEnvironment,
(name, mainAssemblyPath, hostAssemblyNames) =>
new IsolatedLoadContext(name, mainAssemblyPath, hostAssemblyNames),
() => GetLoggerFactory(mvcCoreBuilder.Services));
}

/// <param name="createContext">
/// Creates a module's own context from its name, main assembly path and the host assembly names; tests
/// create collectible ones
/// </param>
/// <param name="getLoggerFactory">The logger factory, asked for only when there are modules to load</param>
internal static void LoadModules(IMvcCoreBuilder mvcCoreBuilder, IConfiguration configuration,
IWebHostEnvironment hostingEnvironment, Func<string, string, ISet<string>, AssemblyLoadContext> createContext,
Func<ILoggerFactory> getLoggerFactory)
{
var modules = GetFeatureList(configuration);
if (modules.Count == 0)
{
return;
}
var logger = CreateLogger(mvcCoreBuilder.Services);

var config = new ExtensionsConfig();
configuration.GetSection("Extensions").Bind(config);
var logger = getLoggerFactory().CreateLogger(LOGGER_NAME);
var hostAssemblyNames = PluginManager.HostAssemblyNames();
foreach (var module in modules)
{
if (!module.IsEnabled)
{
logger.LogInformation("Module '{ModuleName}' is disabled.", module.FeatureName);
continue;
}
LoadModule(module.FeatureName, hostingEnvironment, mvcCoreBuilder, logger);

var error = LoadModule(module.FeatureName, hostingEnvironment, mvcCoreBuilder, logger,
mainAssemblyPath => createContext(module.FeatureName, mainAssemblyPath, hostAssemblyNames));
if (error == null)
continue;

logger.LogCritical("Module '{ModuleName}' could not be loaded: {LoadError}", module.FeatureName, error);
if (config.FailOnPluginLoadError)
throw new GrandException($"The module '{module.FeatureName}' could not be loaded: {error}");
}
}

private static void LoadModule(string moduleName, IWebHostEnvironment hostingEnvironment,
IMvcCoreBuilder mvcCoreBuilder, ILogger logger)
/// <summary>
/// Whether the assembly belongs to a module that failed to load; its types must not be scanned
/// </summary>
internal static bool IsFailedModule(Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);

var context = AssemblyLoadContext.GetLoadContext(assembly);
return context != null && FailedContexts.TryGetValue(context, out _);
}

/// <returns>The reason the module could not be loaded; null when it is loaded, or has no files to load</returns>
private static string LoadModule(string moduleName, IWebHostEnvironment hostingEnvironment,
IMvcCoreBuilder mvcCoreBuilder, ILogger logger, Func<string, AssemblyLoadContext> createContext)
{
var modulePath = GetModulePath(hostingEnvironment, moduleName);
var moduleFile = Path.Combine(modulePath, $"{moduleName}.dll");
Expand All @@ -61,73 +115,81 @@ private static void LoadModule(string moduleName, IWebHostEnvironment hostingEnv
{
logger.LogWarning("Module '{ModuleName}' does not exist at path: {ModulePath}",
moduleName, moduleFile);
return;
return null;
}

AssemblyLoadContext context = null;
try
{
var assemblyLoadContext = new ModuleLoadContext();
LoadModuleDependencies(modulePath, assemblyLoadContext, mvcCoreBuilder, logger);
context = createContext(moduleFile);
var assembly = context.LoadFromAssemblyPath(moduleFile);

//resolved now, so a dependency the context cannot provide fails the module here instead of a type scan
//or a request
_ = assembly.GetTypes();
var error = ResolveReferences(assembly, context);
if (error != null)
{
FailedContexts.AddOrUpdate(context, null);
return error;
}

AddApplicationPart(mvcCoreBuilder, assembly);
logger.LogInformation("Module '{ModuleName}' has been successfully loaded.", moduleName);
return null;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load module '{ModuleName}'. Error: {Error}",
moduleName, ex.Message);
if (context != null)
FailedContexts.AddOrUpdate(context, null);

return ex is ReflectionTypeLoadException typeLoad
? typeLoad.LoaderExceptions.FirstOrDefault(x => x != null)?.Message ?? ex.Message
: ex.Message;
}
}
private static void LoadModuleDependencies(string modulePath, ModuleLoadContext assemblyLoadContext,
IMvcCoreBuilder mvcCoreBuilder, ILogger logger)
{
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Select(a => a.Location)
.ToHashSet();

foreach (var dependencyDll in Directory.GetFiles(modulePath, "*.dll"))
/// <summary>
/// Resolves every assembly the module references. The application's copy of an assembly wins over the
/// module's, and the runtime binds it whatever its version, so one older than the module was built against
/// is reported here: it would fail only once a request reached the member it lacks.
/// </summary>
/// <returns>The reason the module cannot use its references; null when it can</returns>
private static string ResolveReferences(Assembly assembly, AssemblyLoadContext context)
{
foreach (var reference in assembly.GetReferencedAssemblies())
{
if (loadedAssemblies.Contains(dependencyDll))
continue;

try
{
var assembly = assemblyLoadContext.LoadFromAssemblyPath(dependencyDll);
AddApplicationPart(mvcCoreBuilder, assembly);
logger.LogDebug("Successfully loaded dependency: {Dependency}", dependencyDll);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load dependency: {Dependency}", dependencyDll);
}
var resolved = context.LoadFromAssemblyName(reference).GetName();
if (reference.Version != null && resolved.Version != null && resolved.Version < reference.Version)
return $"it is built against {reference.Name} {reference.Version}, " +
$"but the application provides {resolved.Version}";
}

return null;
}

/// <summary>
/// The application's logging, with every provider configured so far (console, event log, OpenTelemetry)
/// </summary>
private static ILoggerFactory GetLoggerFactory(IServiceCollection services)
=> services.BuildServiceProvider().GetService<ILoggerFactory>()
?? throw new InvalidOperationException("Logger factory not found in services.");

private static string GetModulePath(IWebHostEnvironment hostingEnvironment, string moduleName)
=> Path.Combine(hostingEnvironment.ContentRootPath, MODULES_DIRECTORY, moduleName);

private static ILogger CreateLogger(IServiceCollection services)
=> services.BuildServiceProvider()
.GetService<ILoggerFactory>()
?.CreateLogger(LOGGER_NAME)
?? throw new InvalidOperationException("Logger factory not found in services.");

/// <summary>
/// Adds the module's own assembly and its related assemblies; its dependencies are not application parts
/// </summary>
private static void AddApplicationPart(IMvcCoreBuilder mvcCoreBuilder, Assembly assembly)
{
mvcCoreBuilder.AddApplicationPart(assembly);

var relatedAssemblies = RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, false);
foreach (var relatedAssembly in relatedAssemblies)
{
var applicationPartFactory = ApplicationPartFactory.GetApplicationPartFactory(relatedAssembly);
foreach (var part in applicationPartFactory.GetApplicationParts(relatedAssembly))
mvcCoreBuilder.PartManager.ApplicationParts.Add(part);
}
}

private class ModuleLoadContext : AssemblyLoadContext
{
public ModuleLoadContext() : base(isCollectible: true)
{
//related parts are read first, so a failure adds nothing
var relatedParts = RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, false)
.SelectMany(x => ApplicationPartFactory.GetApplicationPartFactory(x).GetApplicationParts(x))
.ToList();

}
mvcCoreBuilder.AddApplicationPart(assembly);
foreach (var part in relatedParts)
mvcCoreBuilder.PartManager.ApplicationParts.Add(part);
}
}
}
69 changes: 69 additions & 0 deletions src/Core/Grand.Infrastructure/Plugins/IsolatedLoadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Reflection;
using System.Runtime.Loader;

namespace Grand.Infrastructure.Plugins;

/// <summary>
/// The load context of one extension (a plugin or a module). It resolves the extension's own dependencies
/// from its deps.json, or from its folder, and takes every assembly the application provides from the
/// default context, so the extension shares the host's types while its private libraries, in whatever
/// version it ships them, stay its own.
/// </summary>
/// <remarks>Not collectible: an extension stays loaded until the application stops.</remarks>
public sealed class IsolatedLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly string _folder;
private readonly ISet<string> _hostAssemblyNames;

/// <param name="name">The context's name, for diagnostics</param>
/// <param name="mainAssemblyPath">The extension's main assembly; its deps.json, if any, sits beside it</param>
/// <param name="hostAssemblyNames">Simple names of the assemblies the application provides</param>
/// <exception cref="InvalidOperationException">The extension's deps.json cannot be resolved</exception>
public IsolatedLoadContext(string name, string mainAssemblyPath, ISet<string> hostAssemblyNames)
: this(name, mainAssemblyPath, hostAssemblyNames, false)
{
}

/// <param name="isCollectible">Tests only: lets a test unload what it loaded</param>
internal IsolatedLoadContext(string name, string mainAssemblyPath, ISet<string> hostAssemblyNames,
bool isCollectible) : base(name, isCollectible)
{
ArgumentException.ThrowIfNullOrEmpty(mainAssemblyPath);
ArgumentNullException.ThrowIfNull(hostAssemblyNames);

_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
_folder = Path.GetDirectoryName(Path.GetFullPath(mainAssemblyPath));
_hostAssemblyNames = hostAssemblyNames;
}

/// <summary>
/// Null hands the name to the default context: an assembly the application provides, or one it has
/// already loaded, is never loaded a second time.
/// </summary>
protected override Assembly Load(AssemblyName assemblyName)
{
var name = assemblyName.Name;
if (string.IsNullOrEmpty(name) || _hostAssemblyNames.Contains(name) || IsLoadedInDefault(name))
return null;

var path = _resolver.ResolveAssemblyToPath(assemblyName);
if (path != null)
return LoadFromAssemblyPath(path);

//a dll that sits in the folder without being listed in deps.json (or there is no deps.json)
var local = Path.Combine(_folder, name + ".dll");
return File.Exists(local) ? LoadFromAssemblyPath(local) : null;
}

protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}

private static bool IsLoadedInDefault(string name)
{
return Default.Assemblies.Any(x => string.Equals(x.GetName().Name, name, StringComparison.OrdinalIgnoreCase));
}
}
Loading
Loading