From e99e8b932d9748c8799935b2fa4d37aa2887dfca Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 00:13:12 -0500 Subject: [PATCH 1/7] Add support for extension private deps --- src/Core/ExtensionsManager.cs | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index a2831daf6..ecd223e1f 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -4,9 +4,31 @@ using SwarmUI.Utils; using System.IO; using System.Reflection; +using System.Runtime.Loader; namespace SwarmUI.Core; +public class SwarmExtensionLoadContext : AssemblyLoadContext +{ + private readonly string ExtensionDir; + + public SwarmExtensionLoadContext(string name, string extensionDir) + : base(name, isCollectible: false) + { + ExtensionDir = extensionDir; + } + + protected override Assembly Load(AssemblyName name) + { + // If the host already has this assembly (SwarmUI itself, ASP.NET Core, NuGet deps SwarmUI loaded), return null so the runtime resolves it from the default ALC. This preserves type identity for shared types. + try { Default.LoadFromAssemblyName(name); return null; } + catch (FileNotFoundException) { } + // Otherwise probe the extension's own output folder. Extensions ship private deps as with a HintPath, which MSBuild copies next to the extension DLL. + string candidate = Path.Combine(ExtensionDir, name.Name + ".dll"); + return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; + } +} + public class ExtensionsManager { /// All extensions currently loaded. @@ -45,6 +67,16 @@ public static HtmlString HtmlTags(string[] tags) /// List of known online available extensions. public List KnownExtensions = []; + /// Per-extension load contexts, keyed by extension DLL name. Each extension gets its own so private dependencies stay isolated from other extensions. + public ConcurrentDictionary ExtensionLoadContexts = new(); + + private Assembly LoadInExtensionContext(string dllName, string targetPath) + { + SwarmExtensionLoadContext ctx = ExtensionLoadContexts.GetOrAdd(dllName, + n => new SwarmExtensionLoadContext(n, Path.GetDirectoryName(targetPath))); + return ctx.LoadFromAssemblyPath(targetPath); + } + public static string ReferenceCsproj = """ @@ -181,7 +213,7 @@ public async Task BuildExtension(string folder, string projFile) if (File.Exists(target) && !Program.IsDevMode) { Logs.Debug($"Don't need to rebuild extension {projFile}, already built."); - return Assembly.LoadFile(Path.GetFullPath(target)); + return LoadInExtensionContext(dllName, Path.GetFullPath(target)); } Logs.Debug($"Building extension project: {projFile}..."); string buildParam = $"-p:BaseIntermediateOutputPath={Path.GetFullPath($"./src/obj/extensions/{dllName}/")};TargetName={dllName}-{hash}"; @@ -195,7 +227,7 @@ public async Task BuildExtension(string folder, string projFile) { Logs.Debug($"Successful build output for extension project {projFile}:\n{output}"); } - return Assembly.LoadFile(Path.GetFullPath(target)); + return LoadInExtensionContext(dllName, Path.GetFullPath(target)); } public void PrepExtension(Type extType, bool isCore, string[] possible) From b50d97c8ddbf5dbfab128e2c2d0661c1f42028ef Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 07:41:48 -0500 Subject: [PATCH 2/7] Formatting --- src/Core/ExtensionsManager.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index ecd223e1f..72632549f 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -12,8 +12,7 @@ public class SwarmExtensionLoadContext : AssemblyLoadContext { private readonly string ExtensionDir; - public SwarmExtensionLoadContext(string name, string extensionDir) - : base(name, isCollectible: false) + public SwarmExtensionLoadContext(string name, string extensionDir) : base(name, isCollectible: false) { ExtensionDir = extensionDir; } @@ -21,8 +20,14 @@ public SwarmExtensionLoadContext(string name, string extensionDir) protected override Assembly Load(AssemblyName name) { // If the host already has this assembly (SwarmUI itself, ASP.NET Core, NuGet deps SwarmUI loaded), return null so the runtime resolves it from the default ALC. This preserves type identity for shared types. - try { Default.LoadFromAssemblyName(name); return null; } - catch (FileNotFoundException) { } + try { + Default.LoadFromAssemblyName(name); + return null; + } + catch (FileNotFoundException) + { + // Ignore the exception, we want to load the assembly from the extension's own output folder. + } // Otherwise probe the extension's own output folder. Extensions ship private deps as with a HintPath, which MSBuild copies next to the extension DLL. string candidate = Path.Combine(ExtensionDir, name.Name + ".dll"); return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; @@ -72,8 +77,7 @@ public static HtmlString HtmlTags(string[] tags) private Assembly LoadInExtensionContext(string dllName, string targetPath) { - SwarmExtensionLoadContext ctx = ExtensionLoadContexts.GetOrAdd(dllName, - n => new SwarmExtensionLoadContext(n, Path.GetDirectoryName(targetPath))); + SwarmExtensionLoadContext ctx = ExtensionLoadContexts.GetOrAdd(dllName, n => new SwarmExtensionLoadContext(n, Path.GetDirectoryName(targetPath))); return ctx.LoadFromAssemblyPath(targetPath); } From b05664c672042f1764e20dd306d65a25e64c8f8b Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 07:42:31 -0500 Subject: [PATCH 3/7] Formatting --- src/Core/ExtensionsManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index 72632549f..14f70bc3b 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -23,7 +23,7 @@ protected override Assembly Load(AssemblyName name) try { Default.LoadFromAssemblyName(name); return null; - } + } catch (FileNotFoundException) { // Ignore the exception, we want to load the assembly from the extension's own output folder. From 41fad6d9405969adc90049291b8365f4904c9824 Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 07:45:36 -0500 Subject: [PATCH 4/7] Formatting --- src/Core/ExtensionsManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index 14f70bc3b..ccdfa884f 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -20,7 +20,8 @@ public SwarmExtensionLoadContext(string name, string extensionDir) : base(name, protected override Assembly Load(AssemblyName name) { // If the host already has this assembly (SwarmUI itself, ASP.NET Core, NuGet deps SwarmUI loaded), return null so the runtime resolves it from the default ALC. This preserves type identity for shared types. - try { + try + { Default.LoadFromAssemblyName(name); return null; } From 20ceda175091c1a398d2c09ab45c7cc1602df61e Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 08:10:55 -0500 Subject: [PATCH 5/7] Add explicit logging --- src/Core/ExtensionsManager.cs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index ccdfa884f..8e054c174 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -8,30 +8,29 @@ namespace SwarmUI.Core; -public class SwarmExtensionLoadContext : AssemblyLoadContext +public class SwarmExtensionLoadContext(string name, string extensionDir) : AssemblyLoadContext(name, isCollectible: false) { - private readonly string ExtensionDir; - - public SwarmExtensionLoadContext(string name, string extensionDir) : base(name, isCollectible: false) - { - ExtensionDir = extensionDir; - } - + /// Host wins, then we probe the extension's folder for private deps. protected override Assembly Load(AssemblyName name) { - // If the host already has this assembly (SwarmUI itself, ASP.NET Core, NuGet deps SwarmUI loaded), return null so the runtime resolves it from the default ALC. This preserves type identity for shared types. + string candidate = Path.Combine(extensionDir, name.Name + ".dll"); + // Host wins to keep type identity intact. If the extension also shipped its own copy, that's almost always a csproj misconfig (missing Private=false), so warn. try { Default.LoadFromAssemblyName(name); + if (File.Exists(candidate)) + { + Logs.Warning($"Extension {Name} ships {name.Name}.dll but host already has it; using host copy. Set Private=false on that reference."); + } return null; } - catch (FileNotFoundException) + catch (FileNotFoundException) { } + if (!File.Exists(candidate)) { - // Ignore the exception, we want to load the assembly from the extension's own output folder. + return null; } - // Otherwise probe the extension's own output folder. Extensions ship private deps as with a HintPath, which MSBuild copies next to the extension DLL. - string candidate = Path.Combine(ExtensionDir, name.Name + ".dll"); - return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; + Logs.Debug($"Extension {Name} loading private dep {name.Name} from {candidate}"); + return LoadFromAssemblyPath(candidate); } } From 0e9889e9a1104b3c9d019188894680fb967febdf Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 14:10:26 -0500 Subject: [PATCH 6/7] Reducing diff --- src/Core/ExtensionsManager.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index 8e054c174..3769d6352 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -72,13 +72,9 @@ public static HtmlString HtmlTags(string[] tags) /// List of known online available extensions. public List KnownExtensions = []; - /// Per-extension load contexts, keyed by extension DLL name. Each extension gets its own so private dependencies stay isolated from other extensions. - public ConcurrentDictionary ExtensionLoadContexts = new(); - private Assembly LoadInExtensionContext(string dllName, string targetPath) { - SwarmExtensionLoadContext ctx = ExtensionLoadContexts.GetOrAdd(dllName, n => new SwarmExtensionLoadContext(n, Path.GetDirectoryName(targetPath))); - return ctx.LoadFromAssemblyPath(targetPath); + return new SwarmExtensionLoadContext(dllName, Path.GetDirectoryName(targetPath)).LoadFromAssemblyPath(targetPath); } public static string ReferenceCsproj = From 67f81b9a686ec4f5eb2be3bec8d52dc5c15c0a56 Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Tue, 5 May 2026 15:15:17 -0500 Subject: [PATCH 7/7] Log only when dependency is known to core; inline SwarmExtensionLoadContext class; simplify --- src/Core/ExtensionsManager.cs | 62 ++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/Core/ExtensionsManager.cs b/src/Core/ExtensionsManager.cs index 3769d6352..2553dfa44 100644 --- a/src/Core/ExtensionsManager.cs +++ b/src/Core/ExtensionsManager.cs @@ -8,32 +8,6 @@ namespace SwarmUI.Core; -public class SwarmExtensionLoadContext(string name, string extensionDir) : AssemblyLoadContext(name, isCollectible: false) -{ - /// Host wins, then we probe the extension's folder for private deps. - protected override Assembly Load(AssemblyName name) - { - string candidate = Path.Combine(extensionDir, name.Name + ".dll"); - // Host wins to keep type identity intact. If the extension also shipped its own copy, that's almost always a csproj misconfig (missing Private=false), so warn. - try - { - Default.LoadFromAssemblyName(name); - if (File.Exists(candidate)) - { - Logs.Warning($"Extension {Name} ships {name.Name}.dll but host already has it; using host copy. Set Private=false on that reference."); - } - return null; - } - catch (FileNotFoundException) { } - if (!File.Exists(candidate)) - { - return null; - } - Logs.Debug($"Extension {Name} loading private dep {name.Name} from {candidate}"); - return LoadFromAssemblyPath(candidate); - } -} - public class ExtensionsManager { /// All extensions currently loaded. @@ -42,11 +16,40 @@ public class ExtensionsManager /// Hashset of folder names of all extensions currently loaded. public HashSet LoadedExtensionFolders = []; + /// Hashset of dependency names that are considered "core" and should not be loaded from the extension's folder. + public HashSet CoreDependencyNames = []; + /// Simple holder of information about extensions available online. public record class ExtensionInfo(string Name, string Author, string License, string Description, string URL, string OldURL, string[] Tags, string[] FolderNames) { } + private class SwarmExtensionLoadContext(ExtensionsManager manager, string name, string extensionDir) : AssemblyLoadContext(name, isCollectible: false) + { + /// Host wins, then we probe the extension's folder for private deps. + protected override Assembly Load(AssemblyName name) + { + string dependency = Path.Combine(extensionDir, name.Name + ".dll"); + try + { + Default.LoadFromAssemblyName(name); + // We only get here if host successfully loads the assembly. + if (File.Exists(dependency) && !manager.CoreDependencyNames.Contains(name.Name)) + { + Logs.Warning($"Extension {Name} ships {name.Name}.dll but host already has it loaded; using host copy."); + } + return null; + } + catch (FileNotFoundException) { } + if (!File.Exists(dependency)) + { + return null; + } + Logs.Debug($"Extension {Name} loading private dep {name.Name} from {dependency}"); + return LoadFromAssemblyPath(dependency); + } + } + public static HtmlString HtmlTags(string[] tags) { return new(tags.Select(t => @@ -74,7 +77,7 @@ public static HtmlString HtmlTags(string[] tags) private Assembly LoadInExtensionContext(string dllName, string targetPath) { - return new SwarmExtensionLoadContext(dllName, Path.GetDirectoryName(targetPath)).LoadFromAssemblyPath(targetPath); + return new SwarmExtensionLoadContext(this, dllName, Path.GetDirectoryName(targetPath)).LoadFromAssemblyPath(targetPath); } public static string ReferenceCsproj = @@ -90,6 +93,11 @@ private Assembly LoadInExtensionContext(string dllName, string targetPath) /// Initial call that prepares the extensions list. public async Task PrepExtensions() { + CoreDependencyNames = + [ + .. AssemblyLoadContext.Default.Assemblies.Select(a => a.GetName().Name).Where(n => n is not null), + .. Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll").Select(Path.GetFileNameWithoutExtension) + ]; await BuildPublicExtensionList(); string[] builtins = [.. Directory.EnumerateDirectories("./src/BuiltinExtensions").Select(s => "src/" + s.Replace('\\', '/').AfterLast("/src/"))]; string[] extras = Directory.Exists("./src/Extensions") ? [.. Directory.EnumerateDirectories("./src/Extensions/").Select(s => "src/" + s.Replace('\\', '/').AfterLast("/src/"))] : [];