Skip to content

Make plugin and module loading fault-tolerant, safe and isolated - #1020

Merged
KrzysztofPajak merged 31 commits into
developfrom
fix/plugin-loading
Nov 5, 2026
Merged

KrzysztofPajak merged 31 commits into
developfrom
fix/plugin-loading

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: bugfix

Issue

How plugins and modules are loaded and handled had several defects, all reproduced on a Release publish of develop (5e8cf26):

  1. With Extensions:PluginShadowCopy=false (what the Aspire AppHost sets) the store did not start with the bundled plugins: Payments.BrainTree ships Newtonsoft.Json 13.0.1 and Payments.StripeCheckout ships 13.0.3, and both were loaded into the default context ("Assembly with same name is already loaded"). With shadow copy on, the first copy won silently.
  2. One plugin folder could stop the store, even for a plugin that was not installed. This happened with a missing dependency, an abstract class implementing IPlugin, or a non-.NET *.dll.
  3. Plugin upload in the admin rejected every standard plugin, because it compared the raw PluginInfoAttribute.SupportedVersion, which is null by convention. Archives without directory entries were rejected too. An archive named after an existing plugin could delete that plugin.
  4. An uninstalled plugin still ran code: a second assembly of the plugin was treated as core, so its IStartupApplication ran.
  5. The duplicate SystemName check never fired.
  6. Plugin list reload stopped the app on a GET. Remove deleted the folder of an installed plugin. Install and uninstall silently did nothing when Extensions:InstalledPlugins was set.
  7. InstalledPlugins.cfg was written non-atomically and removed names case-sensitively. Plugin activation errors lost their cause.
  8. Start-up loaded every plugin dll, installed or not, and rescanned all types about 12 times.
  9. ModuleLoader added every module dll as an MVC part and loaded Grand.Module.Scim's own copies of host assemblies.

Solution

  • Manifests are read from metadata (PluginManifestReader) and the load is planned before anything is loaded (PluginFolderScanner).
  • Each plugin loads in isolation (PluginLoader). A faulty plugin gets a LoadError that is logged with its folder and reason and shown on the admin plugin list. The store still starts. The new Extensions:FailOnPluginLoadError setting (default false) makes it fail fast instead.
  • Each plugin has its own non-collectible AssemblyLoadContext (IsolatedLoadContext). It resolves dependencies from the plugin's deps.json and takes host assemblies from the default context. Plugins with different versions of a library run side by side. Shadow copy now works per folder (Plugins/bin/<Folder>). Modules use the same model.
  • Uninstalled and incompatible plugins are listed from their manifest and not loaded. A plugin's code loads on Install.
  • Upload is rebuilt (PluginArchiveInstaller):
    • the manifest is validated without loading code;
    • extraction is bounded by the real size and checked for paths outside the folder;
    • the folder swap is atomic and rolls back on failure;
    • an upload cannot replace a different plugin's folder.
  • Install, uninstall, remove and upload no longer restart the application. A "restart required" notice on every Admin page lists the pending changes and restarts on request. The restart is a POST, audited, under the Plugins permission. Install and uninstall are refused for a plugin whose files changed until the restart.
  • InstalledPlugins.cfg is written atomically under a lock. Invalid JSON fails with the file path. Activation errors keep their cause.
  • Type scans are cached (TypeSearcher). In the test host, warm scans dropped from 3–8 ms to under 0.3 ms; this was not measured on a published store.
  • Shipping.ByWeight no longer throws when it quotes a customer without checkout attributes (found while testing checkout).

Breaking changes

For third-party plugins:

  • A plugin that references another plugin's assembly now fails to load with a LoadError. Before, it worked by accident, because all plugins shared one context. The same applies to a module that references another module.
  • A plugin's copy of an assembly the host provides is ignored, and the host's copy is used.
  • A plugin's deps.json is now used to resolve its dependencies, so it must be consistent: a listed file that is missing fails the plugin.
  • Only the plugin's own code is type-checked at load. Third-party libraries in the folder are not.
  • PluginInfo.ReferencedAssembly and PluginType are null for a plugin that is not installed. Instance() loads it on demand and creates it with ActivatorUtilities, so an IPlugin constructor may depend only on host services.
  • Only the plugin's concrete IPlugin class is registered in DI.
  • ITypeSearcher.GetAssemblies() returns a shared, read-only list.

For admins:

  • Plugin actions no longer restart the application. A restart is required for changes to take effect.
  • New setting Extensions:FailOnPluginLoadError, default false. With the default, existing behaviour is kept except that a faulty plugin no longer stops the store.
  • New resource keys are delivered through en_250.xml. Existing keys keep their values.

Testing

  1. dotnet test GrandNode.slnx: all green.
  2. Publish Grand.Web Release with all plugins and modules. Remove App_Data/Settings.cfg from the publish. Set Database__UseLiteDb=true and install a store with sample data.
  3. Start the store with shadow copy on, then with Extensions__PluginShadowCopy=false. In each run, check:
    • the home page and a product page render (theme and slider widget);
    • the admin login works and every plugin configuration page opens;
    • shipping by weight quotes;
    • checkout reaches order placement with BrainTree and with Stripe. Stripe fails at placement without an API key, which is expected.
    • The host log contains no FileNotFound, TypeLoad, MissingMethod or InvalidCast exceptions.
  4. Put a plugin folder with a missing dependency, and another with a random-bytes native.dll, into Plugins. The store starts and the admin list shows the errors. With Extensions__FailOnPluginLoadError=true and the broken plugin installed, startup stops with the plugin named.
  5. In Admin → Plugins:
    • install a plugin: no restart happens, the "restart required" notice appears, and "Restart now" restarts the store;
    • uninstall and remove work the same way;
    • upload a zip of a built plugin folder (made with Compress-Archive): it installs. An archive whose root folder is an existing plugin's folder but holds another plugin is rejected.
  6. Enable Grand.Module.Api and Grand.Module.Scim:
    • /scim/v2/Users returns 401 without a token and 200 with one;
    • the OpenAPI and Scalar pages answer.

Not verified: real BrainTree and Stripe payments, Google and Facebook sign-in, MongoDB, the Theme.Modern render, and a two-replica Aspire start.

support added 25 commits November 2, 2026 08:45
…p incompatible plugins out of dependency dedupe
…raries

A library may hold types built on an optional assembly it never ships; GetTypes on it failed the whole plugin although it works at runtime. The check now covers the main assembly and the assemblies the reference check already treats as plugin code.
The target is the existing folder matched case-insensitively, under its exact name, so a case-variant archive root replaces it in place on every platform. An existing folder holding a plugin is only replaced by an archive with the same system name. The comment on a kept temp folder now says it stays until an operator removes it.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 16:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

context.Unload();
for (var i = 0; i < 10; i++)
{
GC.Collect();
Comment on lines +199 to +209
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(root, file);
var separator = relative.IndexOf(Path.DirectorySeparatorChar);
if (separator < 0)
throw new GrandException("The archive should not contain files outside of the plugin folder.");

var name = relative[..separator];
if (!names.Contains(name, StringComparer.OrdinalIgnoreCase))
names.Add(name);
}
Comment on lines +45 to +65
foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes())
{
var attribute = reader.GetCustomAttribute(handle);
if (!IsPluginInfoAttribute(reader, attribute.Constructor))
continue;

var named = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var argument in attribute.DecodeValue(provider).NamedArguments)
if (!named.TryAdd(argument.Name!, argument.Value as string))
throw new BadImageFormatException(
$"The PluginInfo attribute of '{Path.GetFileName(assemblyPath)}' sets {argument.Name} more than once.");

return new PluginManifest(
named.GetValueOrDefault(nameof(PluginInfoAttribute.SystemName)),
named.GetValueOrDefault(nameof(PluginInfoAttribute.FriendlyName)),
named.GetValueOrDefault(nameof(PluginInfoAttribute.Group)),
named.GetValueOrDefault(nameof(PluginInfoAttribute.Author)),
named.GetValueOrDefault(nameof(PluginInfoAttribute.Version)),
PluginVersionResolver.ResolveSupportedVersion(GetCoreReference(reader),
named.GetValueOrDefault(nameof(PluginInfoAttribute.SupportedVersion))));
}
Comment on lines +87 to +92
foreach (var handle in reader.AssemblyReferences)
{
var reference = reader.GetAssemblyReference(handle);
if (string.Equals(reader.GetString(reference.Name), CoreAssemblyName, StringComparison.OrdinalIgnoreCase))
return reference.Version;
}
Comment on lines +365 to +372
catch (Exception rollback)
{
logger.LogError(rollback,
"The plugin could not be installed and the previous version could not be restored; it is kept in {PreviousPath}",
previous);
throw new RollbackFailedException(
$"The plugin could not be installed and the previous version could not be restored. The previous version is preserved at '{previous}'.");
}
Comment on lines +82 to +87
catch (Exception ex)
{
//FileLoadException, FileNotFoundException, BadImageFormatException, an unresolvable deps.json and
//whatever else a plugin's files can cause; the plugin is reported, the application goes on
return Failed(plugin, ex.Message);
}
support added 2 commits November 4, 2026 21:20
…s not identified

An entry for a folder that could not be loaded has no main assembly, so Remove
failed on it, and two folders declaring one system name were told apart by
neither: Remove deleted the first, the working one. The list now carries the
folder name, and Remove finds the entry by system name and folder.
The installed plugin of that name is the folder that loaded; the second one
was listed installed, so FailOnPluginLoadError stopped the application although
the plugin was working, and the list asked to uninstall it before removal.
support added 4 commits November 4, 2026 21:21
Only the top-level files of Plugins/bin were cleared at startup, so each removed
or renamed plugin folder left its whole copy there for good.
Assemblies were de-duplicated by full name, but every plugin now loads its
files into a context of its own: only the first plugin's copy of a shared
library was scanned, so the second plugin's types from it were never
registered. One assembly is now kept per load context.
…lder, as pending

A loaded plugin was compared by its main assembly only, and a folder that
existed at load time was never read again, so neither change showed the
restart notice nor stopped Install and Uninstall before the restart. All
top-level dlls are compared now, and a folder that had no plugin is read
again once its dlls change.
Comment on lines +118 to +129
foreach (var folder in folders.Where(x => !_plugins.ContainsKey(x)).Order(StringComparer.Ordinal))
{
if (_otherFolders.TryGetValue(folder, out var stamp) &&
stamp == StampOfAssemblies(Path.Combine(_pluginsFolder, folder)))
continue;

var manifest = ReadManifest(Path.Combine(_pluginsFolder, folder));
if (manifest != null)
changes.Add(new PluginPendingChange(
string.IsNullOrWhiteSpace(manifest.SystemName) ? folder : manifest.SystemName, folder,
PluginPendingChangeKind.Added));
}
Comment on lines +112 to +126
foreach (var copy in _copyFolder.GetDirectories())
{
if (plugin.SupportedVersion != GrandVersion.SupportedPluginVersion)
{
_logger.LogInformation("Incompatible plugin {PluginSystemName}", plugin.SystemName);
//set as not installed
referencedPlugins.Add(plugin);
if (Directory.Exists(Path.Combine(_pluginFolder.FullName, copy.Name)))
continue;
}

//some validation
if (string.IsNullOrWhiteSpace(plugin.SystemName))
throw new Exception($"The plugin '{plugin.SystemName}' has no system name.");
if (referencedPlugins.Contains(plugin))
throw new Exception($"The plugin with '{plugin.SystemName}' system name is already defined");

//set 'Installed' property
plugin.Installed = installedPluginSystemNames
.FirstOrDefault(x => x.Equals(plugin.SystemName, StringComparison.OrdinalIgnoreCase)) != null;

_logger.LogInformation("Deleting the shadow copy of the removed plugin folder {FolderName}", copy.Name);
try
{
if (!_config.PluginShadowCopy)
{
//remove deps.json files
var depsFiles =
plugin.OriginalAssemblyFile.Directory!.GetFiles("*.deps.json",
SearchOption.TopDirectoryOnly);
foreach (var f in depsFiles)
try
{
File.Delete(f.FullName);
}
catch (Exception exc)
{
_logger.LogError(exc, "PluginManager");
}
}

//controllers and views only for an installed plugin - an uninstalled one must not be routable
if (plugin.Installed)
AddApplicationPart(mvcCoreBuilder, plugin.ReferencedAssembly, plugin.SystemName,
plugin.PluginFileName);

//register interface for IPlugin - also when not installed, Install resolves it from DI
RegisterPluginInterface(mvcCoreBuilder, plugin.ReferencedAssembly);

//init plugin type
foreach (var t in plugin.ReferencedAssembly.GetTypes())
if (typeof(IPlugin).IsAssignableFrom(t))
if (!t.GetTypeInfo().IsInterface)
if (t.GetTypeInfo().IsClass && !t.GetTypeInfo().IsAbstract)
{
plugin.PluginType = t;
break;
}

referencedPlugins.Add(plugin);
copy.Delete(true);
}
catch (ReflectionTypeLoadException ex)
catch (Exception exc) when (exc is IOException or UnauthorizedAccessException)
{
var msg = $"Plugin '{plugin.FriendlyName}'. ";
msg = ex.LoaderExceptions.Aggregate(msg,
(current, e) => current + e!.Message + Environment.NewLine);

var fail = new Exception(msg, ex);
throw fail;
_logger.LogWarning(exc, "Shadow copy {FullName} could not be deleted", copy.FullName);
}
catch (Exception ex)
}
@KrzysztofPajak
KrzysztofPajak merged commit a5a12f3 into develop Nov 5, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/plugin-loading branch November 5, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants