Fix plugin deps loading and fix trying to load stupid Abstractions before the plugin dll. Fix plugin watcher a single redploy can take some time for a plugin so watcher gets confused

This commit is contained in:
Kayori
2026-08-12 23:21:37 +02:00
parent 2dfa182852
commit 7d2cc63899
4 changed files with 111 additions and 35 deletions
+16 -1
View File
@@ -11,10 +11,25 @@ public sealed class PluginLoadContext(string pluginAssemblyPath)
public string AssemblyPath => pluginAssemblyPath;
// Contract assemblies that every plugin compiles against and the host also has loaded in
// its own default AssemblyLoadContext - these must always bind to the host's copy, never a
// plugin-local one, or types like IMedusaPlugin end up with two distinct identities (the
// host's and the plugin's own), and every `t.GetInterfaces().Contains(typeof(IMedusaPlugin))`
// style check silently fails even though the plugin dll loaded fine. A plugin may still ship
// its own copy of "Abstractions.dll" locally (e.g. it needs one for `dotnet ef migrations
// add` to run standalone) - that copy is intentionally ignored here.
private static readonly HashSet<string> SharedAssemblyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Abstractions"
};
protected override Assembly? Load(AssemblyName assemblyName)
{
if (assemblyName.Name is not null && SharedAssemblyNames.Contains(assemblyName.Name))
return null; // fall through to the Default AssemblyLoadContext
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is not null ? LoadFromAssemblyPath(path) : null;
}
}
+20 -3
View File
@@ -38,13 +38,30 @@ public sealed class PluginWatcher(IPluginService pluginService, ILogger<PluginWa
// Only .dll writes matter for hot-reload; ignore other files and directory touches.
if (!e.FullPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return;
Debounce(e.FullPath, () =>
// Debounce (and reload) by the plugin's own root directory rather than the exact file
// that changed - a package-referenced plugin ships dozens of dependency dlls (and
// culture subfolders of satellite resource dlls), so a single redeploy touches many
// paths at once. Keying per-file would fire one reload per dll instead of one overall.
var pluginDir = GetPluginRootDir(e.FullPath);
if (pluginDir is null) return;
Debounce(pluginDir, () =>
{
logger.LogInformation("Plugin file changed: {path}", e.FullPath);
_ = pluginService.ReloadAsync(e.FullPath);
logger.LogInformation("Plugin directory changed: {dir}", pluginDir);
_ = pluginService.ReloadAsync(pluginDir);
});
}
// Resolves any path under plugins/<PluginName>/... (including nested culture subfolders
// like plugins/<PluginName>/cs/X.resources.dll) back to plugins/<PluginName>. Null if the
// path isn't inside a plugin folder at all (e.g. a stray file dropped directly in plugins/).
private string? GetPluginRootDir(string fullPath)
{
var relative = Path.GetRelativePath(_pluginPath, fullPath);
var separatorIndex = relative.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]);
return separatorIndex < 0 ? null : Path.Combine(_pluginPath, relative[..separatorIndex]);
}
private void OnDeleted(object sender, FileSystemEventArgs e)
{
// Don't filter by extension here - a deleted path might be the plugin's own
+2 -2
View File
@@ -11,8 +11,8 @@ public interface IPluginService
/// <summary>Post-build: add slots to the registry, call OnAppInitialize on each.</summary>
Task ActivatePluginsAsync(WebApplication app);
/// <summary>Hot-swap a single plugin DLL without restarting.</summary>
Task ReloadAsync(string pluginDllPath);
/// <summary>Hot-swap the plugin loaded from the given plugin directory without restarting.</summary>
Task ReloadAsync(string pluginDir);
/// <summary>Unload any plugin loaded from the given DLL or from underneath the given directory, once it's been deleted from disk.</summary>
Task UnloadAsync(string deletedPath);
+73 -29
View File
@@ -39,17 +39,10 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
}
var loaded = 0;
for (var i = 0; i < dirs.Length; i++)
foreach (var dir in dirs)
{
var dll = FindPluginDll(dirs[i]);
if (dll is null)
{
logger.LogWarning("Skipping directory '{dir}': no DLL found", Path.GetFileName(dirs[i]));
continue;
}
var slot = TryLoadSlot(dll);
if (slot is null) continue; // TryLoadSlot already logged the reason
var slot = TryLoadSlotFromDirectory(dir);
if (slot is null) continue;
logger.LogInformation("Registering plugin {} (gameCode={gameCode}, {verRange})",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer));
@@ -83,13 +76,15 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
// ── Hot-reload ───────────────────────────────────────────────────────────
public async Task ReloadAsync(string pluginDllPath)
public async Task ReloadAsync(string pluginDir)
{
var name = Path.GetFileNameWithoutExtension(pluginDllPath);
logger.LogInformation("Hot-reloading plugin from '{dll}'", name);
logger.LogInformation("Hot-reloading plugin in '{dir}'", Path.GetFileName(pluginDir));
var newSlot = TryLoadSlot(pluginDllPath);
if (newSlot is null) return; // TryLoadSlot already logged the reason
// Re-scan the whole directory rather than assuming any particular dll is the plugin -
// package-referenced plugins ship every dependency dll (AssetsTools.NET, EFCore, ...)
// alongside their own, so which file actually declares IMedusaPlugin isn't known up front.
var newSlot = TryLoadSlotFromDirectory(pluginDir);
if (newSlot is null) return; // TryLoadSlotFromDirectory already logged the reason
var old = pluginRegistry.Remove(newSlot.Key);
@@ -194,6 +189,40 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
// ── 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.
private PluginSlot? TryLoadSlotFromDirectory(string dir)
{
var dlls = Directory.EnumerateFiles(dir, "*.dll").ToArray();
if (dlls.Length == 0)
{
logger.LogWarning("Skipping directory '{dir}': no DLL found", Path.GetFileName(dir));
return null;
}
foreach (var dllFile in dlls)
{
if (dllFile.Contains("Abstractions.dll"))
continue;
var slot = TryLoadSlot(dllFile);
if (slot is not null) return slot;
}
logger.LogWarning("No medusa plugin found among {count} dll(s) in '{dir}'", dlls.Length, Path.GetFileName(dir));
return null;
}
// A non-atomic deploy (plain multi-file cp, editor autosave, etc.) can leave a dll
// partially written when FileSystemWatcher's debounce fires. That surfaces as
// BadImageFormatException ("Invalid token", "Invalid assembly public key", ...) from
// reading a truncated PE - a transient condition, not a real failure, so retry through
// it briefly before giving up. Any other exception (genuinely bad/incompatible dll)
// fails immediately as before.
private const int LoadRetryAttempts = 5;
private static readonly TimeSpan LoadRetryDelay = TimeSpan.FromMilliseconds(150);
private PluginSlot? TryLoadSlot(string dllPath)
{
var context = new PluginLoadContext(dllPath);
@@ -201,7 +230,7 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
Assembly assembly;
try
{
assembly = context.LoadFromAssemblyPath(dllPath);
assembly = LoadWithRetry(context, dllPath);
}
catch (Exception ex)
{
@@ -214,9 +243,19 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
IReadOnlyList<Type> handlerTypes;
try
{
pluginType = assembly.GetTypes()
var pluginTypes = assembly.GetTypes();
; pluginType = pluginTypes
.FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin)));
if (pluginType is null)
{
// Not the plugin dll itself - just one of its dependencies (AssetsTools.NET,
// EFCore, ...) picked up while scanning the whole directory. Not an error on
// its own, so no log here; TryLoadSlotFromDirectory logs if *none* of them match.
context.Unload();
return null;
}
handlerTypes = assembly.GetTypes()
.Where(t => typeof(BaseHandler).IsAssignableFrom(t)
&& t is { IsAbstract: false, IsInterface: false })
@@ -231,13 +270,6 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
return null;
}
if (pluginType is null)
{
context.Unload();
logger.LogError("Could not find medusa plugin in dll {}", assembly.GetName().Name);
return null;
}
logger.LogInformation("Trying to register plugin {}", assembly.GetName().Name);
if (Activator.CreateInstance(pluginType) is not IMedusaPlugin instance)
@@ -254,6 +286,23 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
return new PluginSlot(context, instance, handlerTypes, pluginSp);
}
private Assembly LoadWithRetry(PluginLoadContext context, string dllPath)
{
for (var attempt = 1; ; attempt++)
{
try
{
return context.LoadFromAssemblyPath(dllPath);
}
catch (BadImageFormatException) when (attempt < LoadRetryAttempts)
{
logger.LogDebug("'{}' looks like a partial write (attempt {}/{}), retrying...",
Path.GetFileName(dllPath), attempt, LoadRetryAttempts);
Thread.Sleep(LoadRetryDelay);
}
}
}
// Datecodes are YYYYMMDDXX, e.g. 2025070800 → "2025-07-08 r00"
private static string FormatVer(int ver)
{
@@ -269,9 +318,4 @@ public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPlu
(null, not null) => $"up to {FormatVer(maxVer.Value)}",
_ => $"{FormatVer(minVer.Value)} {FormatVer(maxVer.Value)}"
};
private static string? FindPluginDll(string dir) =>
Directory.EnumerateFiles(dir, "*.dll")
.FirstOrDefault(f => !Path.GetFileNameWithoutExtension(f)
.Equals("Abstractions", StringComparison.OrdinalIgnoreCase));
}