diff --git a/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs b/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs
index 4227778..5eb5b0d 100644
--- a/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs
+++ b/Core/Server/Handlers/Common/Card/AuthPassCardManegementHandler.cs
@@ -20,7 +20,19 @@ public class AuthPassCardManegementHandler(ICardService cardService) : Handler
+
diff --git a/Core/Server/Services/PluginService.cs b/Core/Server/Services/PluginService.cs
index f1e38b5..b65c8fd 100644
--- a/Core/Server/Services/PluginService.cs
+++ b/Core/Server/Services/PluginService.cs
@@ -101,7 +101,7 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry, Plugin
logger.LogInformation("Plugin '{name}' v{version} hot-reloaded successfully (gameCode={gameCode}, {verRange})",
newSlot.Plugin.Name, newSlot.Plugin.Version,
newSlot.Plugin.GameCode, VerRange(newSlot.Plugin.MinVer, newSlot.Plugin.MaxVer));
-
+
if (newSlot.Plugin.UiManifest is not null)
uiEventBroadcaster.Broadcast(
new PluginUiEvent("reloaded", PluginsApi.DerivePluginUiId(newSlot.Plugin)));
@@ -134,7 +134,7 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry, Plugin
logger.LogInformation(
"Plugin '{name}' (gameCode={gameCode}, {verRange}) unloaded because '{path}' was deleted",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer), deletedPath);
-
+
if (slot.Plugin.UiManifest is not null)
uiEventBroadcaster.Broadcast(
new PluginUiEvent("unloaded", PluginsApi.DerivePluginUiId(slot.Plugin)));
@@ -196,9 +196,15 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry, Plugin
// ── Internal ─────────────────────────────────────────────────────────────
- // A plugin folder now ships every package dependency alongside the plugin's own dll
- // (AssetsTools.NET.dll, EFCore, etc.), so which file actually declares IMedusaPlugin
- // isn't known up front - try each dll in the directory until one of them does.
+ // Dependencies ship alongside the plugin DLL, so we don't know which file hosts
+ // IMedusaPlugin until we look. Loading each candidate for real would mean ~10
+ // wasted PluginLoadContext cycles per plugin, and AssemblyLoadContext.Unload()
+ // is asynchronous (needs GC) — a scanned dependency can still be resident when
+ // the real plugin later loads and needs that same assembly, causing type-identity
+ // collisions even on cold boot. MetadataLoadContext reads metadata without
+ // executing code; Dispose() is immediate and leaves no stale state. Note that
+ // types returned from it are not runtime types, so match IMedusaPlugin by
+ // full name rather than direct interface comparison.
private PluginSlot? TryLoadSlotFromDirectory(string dir)
{
var dlls = Directory.EnumerateFiles(dir, "*.dll").ToArray();
@@ -208,16 +214,55 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry, Plugin
return null;
}
+ var pluginDll = FindPluginDllByMetadata(dir, dlls);
+ if (pluginDll is null)
+ {
+ logger.LogWarning("No medusa plugin found among {count} dll(s) in '{dir}'", dlls.Length, Path.GetFileName(dir));
+ return null;
+ }
+
+ return TryLoadSlot(pluginDll);
+ }
+
+ private string? FindPluginDllByMetadata(string dir, string[] dlls)
+ {
+ var pluginInterfaceName = typeof(IMedusaPlugin).FullName;
+
+ // Precedence for name -> path when the same simple assembly name shows up more than
+ // once: the plugin's own bundled copy first, falling back to the host's base
+ // directory (Abstractions.dll normally isn't bundled at all - PluginLoadContext
+ // resolves it to the host by design), then the runtime's own reference assemblies.
+ var byName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var path in Directory.EnumerateFiles(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "*.dll"))
+ byName.TryAdd(Path.GetFileNameWithoutExtension(path), path);
+ foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
+ byName[Path.GetFileNameWithoutExtension(path)] = path;
+ foreach (var path in dlls)
+ byName[Path.GetFileNameWithoutExtension(path)] = path;
+
+ using var mlc = new MetadataLoadContext(new PathAssemblyResolver(byName.Values));
+
foreach (var dllFile in dlls)
{
if (dllFile.Contains("Abstractions.dll"))
continue;
- var slot = TryLoadSlot(dllFile);
- if (slot is not null) return slot;
+ try
+ {
+ var assembly = mlc.LoadFromAssemblyPath(dllFile);
+ var hasPlugin = assembly.GetTypes()
+ .Any(t => t.GetInterfaces().Any(i => i.FullName == pluginInterfaceName));
+
+ if (hasPlugin) return dllFile;
+ }
+ catch (Exception ex) when (ex is BadImageFormatException or ReflectionTypeLoadException or FileNotFoundException)
+ {
+ // Not a .NET assembly, or its own dependencies didn't resolve for metadata
+ // purposes - neither means it's our plugin, so just move on to the next dll.
+ logger.LogDebug(ex, "Skipping '{file}' while scanning for the plugin dll", Path.GetFileName(dllFile));
+ }
}
- logger.LogWarning("No medusa plugin found among {count} dll(s) in '{dir}'", dlls.Length, Path.GetFileName(dir));
return null;
}
@@ -348,4 +393,4 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry, Plugin
(null, not null) => $"up to {FormatVer(maxVer.Value)}",
_ => $"{FormatVer(minVer.Value)} – {FormatVer(maxVer.Value)}"
};
-}
\ No newline at end of file
+}