mirror of
https://gitea.tendokyu.moe/Kayori/Medusa.net.git
synced 2026-09-22 22:38:00 +03:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user