diff --git a/Core/Server/Plugins/PluginLoadContext.cs b/Core/Server/Plugins/PluginLoadContext.cs index 2fa6632..dfc46f5 100644 --- a/Core/Server/Plugins/PluginLoadContext.cs +++ b/Core/Server/Plugins/PluginLoadContext.cs @@ -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 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; } } \ No newline at end of file diff --git a/Core/Server/Plugins/PluginWatcher.cs b/Core/Server/Plugins/PluginWatcher.cs index 1144c94..4ad9f16 100644 --- a/Core/Server/Plugins/PluginWatcher.cs +++ b/Core/Server/Plugins/PluginWatcher.cs @@ -38,13 +38,30 @@ public sealed class PluginWatcher(IPluginService pluginService, ILogger + // 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//... (including nested culture subfolders + // like plugins//cs/X.resources.dll) back to plugins/. 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 diff --git a/Core/Server/Services/IPluginService.cs b/Core/Server/Services/IPluginService.cs index 3c65527..8a5f004 100644 --- a/Core/Server/Services/IPluginService.cs +++ b/Core/Server/Services/IPluginService.cs @@ -11,8 +11,8 @@ public interface IPluginService /// Post-build: add slots to the registry, call OnAppInitialize on each. Task ActivatePluginsAsync(WebApplication app); - /// Hot-swap a single plugin DLL without restarting. - Task ReloadAsync(string pluginDllPath); + /// Hot-swap the plugin loaded from the given plugin directory without restarting. + Task ReloadAsync(string pluginDir); /// Unload any plugin loaded from the given DLL or from underneath the given directory, once it's been deleted from disk. Task UnloadAsync(string deletedPath); diff --git a/Core/Server/Services/PluginService.cs b/Core/Server/Services/PluginService.cs index 5f60807..9d264d1 100644 --- a/Core/Server/Services/PluginService.cs +++ b/Core/Server/Services/PluginService.cs @@ -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 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)); }