From 2def5750bc5e21c710ac9ae766bc78cf0e9e5e30 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 01:00:29 -0500 Subject: [PATCH 01/21] Detect source plugins with only a .csproj (no .cs files required) Plugins that consist of a .csproj with NuGet package references but no source files are a valid pattern (e.g. wrapper plugins). The previous check rejected these because it required at least one .cs file. --- src/Ivy/Core/Plugins/SourcePluginBuilder.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Ivy/Core/Plugins/SourcePluginBuilder.cs b/src/Ivy/Core/Plugins/SourcePluginBuilder.cs index 5afb42df7..7b028cbbf 100644 --- a/src/Ivy/Core/Plugins/SourcePluginBuilder.cs +++ b/src/Ivy/Core/Plugins/SourcePluginBuilder.cs @@ -26,8 +26,7 @@ public SourcePluginBuilder(ILogger logger) public static bool IsSourcePlugin(string directory) { return Directory.Exists(directory) && - Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly).Any() && - Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Any(); + Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly).Any(); } public static bool IsSourceFile(string filePath) From 6b7bfb08b2b23aa7bbd8d9275300ff07859b638c Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 02:40:28 -0500 Subject: [PATCH 02/21] Don't set reload cooldown when plugin load fails A failed load was blocking subsequent retry attempts when DLLs appeared shortly after the directory was created (e.g. during plugin installation). --- src/Ivy/Core/Plugins/PluginReloadScheduler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginReloadScheduler.cs b/src/Ivy/Core/Plugins/PluginReloadScheduler.cs index 0aeb3efbf..eea43dca3 100644 --- a/src/Ivy/Core/Plugins/PluginReloadScheduler.cs +++ b/src/Ivy/Core/Plugins/PluginReloadScheduler.cs @@ -44,8 +44,8 @@ public void ScheduleLoad(string pluginDirectory) _logger.LogInformation("Loading plugin from: {Directory}", pluginDirectory); try { - _pluginManager.LoadPlugin(pluginDirectory); - _reloadCooldowns[pluginDirectory] = DateTime.UtcNow + _cooldownPeriod; + if (_pluginManager.LoadPlugin(pluginDirectory)) + _reloadCooldowns[pluginDirectory] = DateTime.UtcNow + _cooldownPeriod; } catch (InvalidOperationException ex) { From 2ac78420daf60cc176f577707cc469326dab2635 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 02:40:46 -0500 Subject: [PATCH 03/21] Clean up failed plugins when their directory is deleted Previously, deleting a failed plugin's directory left a stale entry in _failedPlugins because GetPluginIdByDirectory only checks _knownPlugins. Now fires PluginUnloaded so the UI updates accordingly. --- src/Ivy/Core/Plugins/PluginLoader.cs | 14 ++++++++++++++ src/Ivy/Core/Plugins/PluginWatcher.cs | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index fe67e968a..0f1468ec3 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -880,6 +880,20 @@ public IReadOnlyList GetUnloadedPlugins() } } + internal void RemoveFailedPlugin(string directory) + { + _lock.EnterWriteLock(); + try + { + _failedPlugins.Remove(directory); + } + finally + { + _lock.ExitWriteLock(); + } + PluginUnloaded?.Invoke(Path.GetFileName(directory)); + } + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); private void InvokeShutdownHook(IIvyPlugin pluginInstance, string pluginId, PluginShutdownReason reason) diff --git a/src/Ivy/Core/Plugins/PluginWatcher.cs b/src/Ivy/Core/Plugins/PluginWatcher.cs index 9665350f5..ed810a997 100644 --- a/src/Ivy/Core/Plugins/PluginWatcher.cs +++ b/src/Ivy/Core/Plugins/PluginWatcher.cs @@ -114,6 +114,10 @@ private void OnDeleted(object sender, FileSystemEventArgs e) _logger.LogError(ex, "Failed to unload plugin {PluginId}", pluginId); } } + else + { + loader.RemoveFailedPlugin(e.FullPath); + } } } From 223a3741f1795f588622ccd478f82e298ba57f27 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 14:33:44 -0500 Subject: [PATCH 04/21] Fire PluginLoadFailed event so UI updates when a plugin fails to load Without this, the plugin settings view never re-rendered after a failed load because no event was fired to trigger UsePluginState(). --- src/Ivy.Plugin.Abstractions/IPluginManager.cs | 1 + src/Ivy/Core/Plugins/PluginLoader.cs | 2 ++ src/Ivy/Core/Plugins/PluginStateService.cs | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/Ivy.Plugin.Abstractions/IPluginManager.cs b/src/Ivy.Plugin.Abstractions/IPluginManager.cs index 8dd271cdc..4baee4717 100644 --- a/src/Ivy.Plugin.Abstractions/IPluginManager.cs +++ b/src/Ivy.Plugin.Abstractions/IPluginManager.cs @@ -20,6 +20,7 @@ public interface IPluginManager bool ReconfigurePlugin(string pluginId); event Action? PluginLoaded; + event Action? PluginLoadFailed; event Action? PluginUnloaded; event Action? PluginReloaded; event Action? PluginActivated; diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 0f1468ec3..66ad3cab8 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -26,6 +26,7 @@ public class PluginLoader : IPluginManager private Version? _hostVersion; public event Action? PluginLoaded; + public event Action? PluginLoadFailed; public event Action? PluginUnloaded; public event Action? PluginReloaded; public event Action? PluginActivated; @@ -1014,6 +1015,7 @@ private void RecordFailure(string directory, string reason) { _lock.ExitWriteLock(); } + PluginLoadFailed?.Invoke(Path.GetFileName(directory)); } private void TryLoadCandidate( diff --git a/src/Ivy/Core/Plugins/PluginStateService.cs b/src/Ivy/Core/Plugins/PluginStateService.cs index fccf6dae1..8253e6726 100644 --- a/src/Ivy/Core/Plugins/PluginStateService.cs +++ b/src/Ivy/Core/Plugins/PluginStateService.cs @@ -13,6 +13,7 @@ public PluginStateService(IPluginManager pluginManager) _pluginManager = pluginManager; _pluginManager.PluginLoaded += OnPluginChanged; + _pluginManager.PluginLoadFailed += OnPluginChanged; _pluginManager.PluginUnloaded += OnPluginChanged; _pluginManager.PluginReloaded += OnPluginChanged; _pluginManager.PluginActivated += OnPluginChanged; @@ -27,6 +28,7 @@ public IReadOnlyList GetActivePluginIds() => public void Dispose() { _pluginManager.PluginLoaded -= OnPluginChanged; + _pluginManager.PluginLoadFailed -= OnPluginChanged; _pluginManager.PluginUnloaded -= OnPluginChanged; _pluginManager.PluginReloaded -= OnPluginChanged; _pluginManager.PluginActivated -= OnPluginChanged; From 4e2756572c586d0cbd6b8fe73dd61169f9982e8d Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 15:40:41 -0500 Subject: [PATCH 05/21] Consolidate plugin failure recording into RecordFailure Ensures PluginLoadFailed fires on all genuine load failures so the UI updates. Removes redundant LogPluginLoadFailure, centralizes logging in RecordFailure, and avoids firing the event when the write lock is held. --- src/Ivy/Core/Plugins/PluginLoader.cs | 76 ++++++++++++++++------------ 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 66ad3cab8..8f3828181 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -82,8 +82,7 @@ public void DiscoverAndLoad(Version hostVersion, IServiceProvider serviceProvide { if (!Directory.Exists(directory)) { - _logger.LogWarning("Referenced plugin directory does not exist: {Directory}", directory); - RecordFailure(directory, $"Directory does not exist: {directory}"); + RecordFailure(directory, "Directory does not exist"); continue; } @@ -355,10 +354,14 @@ public bool LoadPlugin(string pluginPath) if (BuildSourcePlugins && SourcePluginBuilder.IsSourcePlugin(pluginPath)) { if (!SourcePluginBuilder.BuildSync(pluginPath, _logger)) + { + RecordFailure(pluginPath, "Build failed"); return false; + } } string? loadedPluginId = null; + string? loadFailureReason = null; PluginStatus loadedStatus = PluginStatus.Active; _lock.EnterWriteLock(); try @@ -366,8 +369,9 @@ public bool LoadPlugin(string pluginPath) var loaded = LoadPluginFromDirectory(pluginPath, _serviceProviderFactory(), out var loadFailReason); if (loaded is null) { - _logger.LogError("Failed to load plugin from {Path}: {Reason}", pluginPath, loadFailReason ?? "unknown"); - return false; + RecordFailure(pluginPath, loadFailReason ?? "unknown", fireEvent: false); + loadFailureReason = loadFailReason; + goto done; } var manifest = loaded.Value.Instance.Manifest; @@ -375,7 +379,7 @@ public bool LoadPlugin(string pluginPath) if (_plugins.Any(p => p.Instance.Manifest.Id == manifest.Id)) { _logger.LogError("Plugin '{Id}' is already loaded.", manifest.Id); - return false; + goto done; } if (manifest.MinimumHostVersion is { } minVersion && _hostVersion < minVersion) @@ -383,7 +387,9 @@ public bool LoadPlugin(string pluginPath) _logger.LogError( "Plugin '{Id}' requires host version {Required} but current is {Current}.", manifest.Id, minVersion, _hostVersion); - return false; + loadFailureReason = $"Requires host version {minVersion} but current is {_hostVersion}"; + RecordFailure(pluginPath, loadFailureReason, fireEvent: false); + goto done; } var plugin = new LoadedPlugin(loaded.Value.Instance, loaded.Value.Assembly, loaded.Value.Context, loaded.Value.Directory, loaded.Value.ShadowDirectory); @@ -453,10 +459,13 @@ public bool LoadPlugin(string pluginPath) _pluginContext!.RefreshApps(appIds); } + done: // Fire event outside the lock to avoid deadlocks — subscribers may call // GetActivePluginIds() which needs a read lock. if (loadedPluginId is not null) PluginLoaded?.Invoke(loadedPluginId); + else if (loadFailureReason is not null) + PluginLoadFailed?.Invoke(Path.GetFileName(pluginPath)); return loadedPluginId is not null; } @@ -987,35 +996,40 @@ public async Task ShutdownAllAsync() await Task.WhenAll(tasks); } - private void LogPluginLoadFailure(string directory, Exception ex) + private void RecordFailure(string directory, Exception ex, bool fireEvent = true) + => RecordFailure(directory, $"Exception during load: {ex.Message}", fireEvent); + + private void RecordFailure(string directory, string reason, bool fireEvent = true) { - _logger.LogError(ex, "Failed to load plugin from {Directory}. Skipping.", directory); + _logger.LogError("Failed to load plugin from {Directory}: {Reason}", directory, reason); - _lock.EnterWriteLock(); - try + var writeLockHeld = _lock.IsWriteLockHeld; + if (writeLockHeld) { - _failedPlugins[directory] = ( - $"Exception during load: {ex.Message}", - DateTime.UtcNow); + _failedPlugins[directory] = (reason, DateTime.UtcNow); } - finally + else { - _lock.ExitWriteLock(); + _lock.EnterWriteLock(); + try + { + _failedPlugins[directory] = (reason, DateTime.UtcNow); + } + finally + { + _lock.ExitWriteLock(); + } } - } - private void RecordFailure(string directory, string reason) - { - _lock.EnterWriteLock(); - try - { - _failedPlugins[directory] = (reason, DateTime.UtcNow); - } - finally + if (fireEvent) { - _lock.ExitWriteLock(); + if (writeLockHeld) + { + throw new InvalidOperationException("Cannot fire PluginLoadFailed event while holding write lock"); + } + + PluginLoadFailed?.Invoke(Path.GetFileName(directory)); } - PluginLoadFailed?.Invoke(Path.GetFileName(directory)); } private void TryLoadCandidate( @@ -1068,23 +1082,23 @@ private void TryLoadCandidate( } catch (IOException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (UnauthorizedAccessException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (BadImageFormatException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (ReflectionTypeLoadException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (InvalidOperationException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } } From 3baed5c123adfad8dc96ed38775b09dda7056e33 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 16:09:29 -0500 Subject: [PATCH 06/21] Forget plugin from _knownPlugins when its directory is deleted Previously, deleting a plugin directory would unload it but leave it in _knownPlugins, causing it to appear as "Unloaded" with no way to reload. --- src/Ivy/Core/Plugins/PluginLoader.cs | 13 +++++++++++++ src/Ivy/Core/Plugins/PluginWatcher.cs | 1 + 2 files changed, 14 insertions(+) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 8f3828181..b487b249b 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -890,6 +890,19 @@ public IReadOnlyList GetUnloadedPlugins() } } + internal void ForgetPlugin(string pluginId) + { + _lock.EnterWriteLock(); + try + { + _knownPlugins.Remove(pluginId); + } + finally + { + _lock.ExitWriteLock(); + } + } + internal void RemoveFailedPlugin(string directory) { _lock.EnterWriteLock(); diff --git a/src/Ivy/Core/Plugins/PluginWatcher.cs b/src/Ivy/Core/Plugins/PluginWatcher.cs index ed810a997..4320bd655 100644 --- a/src/Ivy/Core/Plugins/PluginWatcher.cs +++ b/src/Ivy/Core/Plugins/PluginWatcher.cs @@ -108,6 +108,7 @@ private void OnDeleted(object sender, FileSystemEventArgs e) try { _pluginManager.UnloadPlugin(pluginId); + loader.ForgetPlugin(pluginId); } catch (Exception ex) { From 09ee6fc9eef61d92adba3adbf10b4aad8ac3cc37 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 16:19:43 -0500 Subject: [PATCH 07/21] Add PluginRemoved event for when a plugin is fully forgotten from the system Fired when a plugin directory is deleted (whether it was loaded, unloaded, or failed). Ensures the UI removes it from all lists without needing a restart. --- src/Ivy.Plugin.Abstractions/IPluginManager.cs | 1 + src/Ivy/Core/Plugins/PluginLoader.cs | 4 +++- src/Ivy/Core/Plugins/PluginStateService.cs | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Ivy.Plugin.Abstractions/IPluginManager.cs b/src/Ivy.Plugin.Abstractions/IPluginManager.cs index 4baee4717..9fc08f901 100644 --- a/src/Ivy.Plugin.Abstractions/IPluginManager.cs +++ b/src/Ivy.Plugin.Abstractions/IPluginManager.cs @@ -22,6 +22,7 @@ public interface IPluginManager event Action? PluginLoaded; event Action? PluginLoadFailed; event Action? PluginUnloaded; + event Action? PluginRemoved; event Action? PluginReloaded; event Action? PluginActivated; event Action? PluginDeactivated; diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index b487b249b..317bd1299 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -28,6 +28,7 @@ public class PluginLoader : IPluginManager public event Action? PluginLoaded; public event Action? PluginLoadFailed; public event Action? PluginUnloaded; + public event Action? PluginRemoved; public event Action? PluginReloaded; public event Action? PluginActivated; public event Action? PluginDeactivated; @@ -901,6 +902,7 @@ internal void ForgetPlugin(string pluginId) { _lock.ExitWriteLock(); } + PluginRemoved?.Invoke(pluginId); } internal void RemoveFailedPlugin(string directory) @@ -914,7 +916,7 @@ internal void RemoveFailedPlugin(string directory) { _lock.ExitWriteLock(); } - PluginUnloaded?.Invoke(Path.GetFileName(directory)); + PluginRemoved?.Invoke(Path.GetFileName(directory)); } private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); diff --git a/src/Ivy/Core/Plugins/PluginStateService.cs b/src/Ivy/Core/Plugins/PluginStateService.cs index 8253e6726..5e3eb79ec 100644 --- a/src/Ivy/Core/Plugins/PluginStateService.cs +++ b/src/Ivy/Core/Plugins/PluginStateService.cs @@ -15,6 +15,7 @@ public PluginStateService(IPluginManager pluginManager) _pluginManager.PluginLoaded += OnPluginChanged; _pluginManager.PluginLoadFailed += OnPluginChanged; _pluginManager.PluginUnloaded += OnPluginChanged; + _pluginManager.PluginRemoved += OnPluginChanged; _pluginManager.PluginReloaded += OnPluginChanged; _pluginManager.PluginActivated += OnPluginChanged; _pluginManager.PluginDeactivated += OnPluginChanged; @@ -30,6 +31,7 @@ public void Dispose() _pluginManager.PluginLoaded -= OnPluginChanged; _pluginManager.PluginLoadFailed -= OnPluginChanged; _pluginManager.PluginUnloaded -= OnPluginChanged; + _pluginManager.PluginRemoved -= OnPluginChanged; _pluginManager.PluginReloaded -= OnPluginChanged; _pluginManager.PluginActivated -= OnPluginChanged; _pluginManager.PluginDeactivated -= OnPluginChanged; From 0c983f628644cef2e3c296db70e31c0c4eb9e905 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 16:47:59 -0500 Subject: [PATCH 08/21] Forget plugins when their reference is removed from plugin-references.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as directory deletion — unloading alone leaves the plugin in _knownPlugins, showing it as "Unloaded" with no way to reload. --- src/Ivy/Core/Plugins/PluginReferencesWatcher.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs b/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs index 62ac23fbb..ad446a012 100644 --- a/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs +++ b/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs @@ -123,12 +123,17 @@ private void ProcessReferencesFileChange() try { _pluginManager.UnloadPlugin(pluginId); + loader.ForgetPlugin(pluginId); } catch (InvalidOperationException ex) { _logger.LogError(ex, "Failed to unload plugin {PluginId}", pluginId); } } + else + { + loader.RemoveFailedPlugin(dir); + } } } From fb4df1309ba1fa5fa6d7ab80f387a5fcf97e6280 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 30 Jun 2026 16:56:20 -0500 Subject: [PATCH 09/21] Catch exceptions during plugin Configure() and record as load failure Previously, exceptions thrown during Configure (e.g. missing dependency DLLs) would propagate unhandled without recording the failure or firing PluginLoadFailed, leaving the plugin invisible in the UI. --- src/Ivy/Core/Plugins/PluginLoader.cs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 317bd1299..ae2817608 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -425,12 +425,25 @@ public bool LoadPlugin(string pluginPath) // Configure context — plugin has valid config if (_pluginContext is not null) { - _pluginContext.SetCurrentPlugin(manifest.Id, pluginPath); - _pluginContext.SetPluginConfig(CreatePluginConfig(plugin.Instance)); - plugin.Instance.Configure(_pluginContext); - _pluginContext.ClearCurrentPlugin(); - _pluginContext.ClearPluginConfig(); - _pluginContext.BuildPluginServiceProvider(manifest.Id, plugin.Services); + try + { + _pluginContext.SetCurrentPlugin(manifest.Id, pluginPath); + _pluginContext.SetPluginConfig(CreatePluginConfig(plugin.Instance)); + plugin.Instance.Configure(_pluginContext); + _pluginContext.ClearCurrentPlugin(); + _pluginContext.ClearPluginConfig(); + _pluginContext.BuildPluginServiceProvider(manifest.Id, plugin.Services); + } + catch (Exception ex) + { + _pluginContext.ClearCurrentPlugin(); + _pluginContext.ClearPluginConfig(); + plugin.LoadContext.Unload(); + DeleteShadowDirectory(plugin.ShadowDirectory); + RecordFailure(pluginPath, ex, fireEvent: false); + loadFailureReason = $"Exception during load: {ex.Message}"; + goto done; + } } plugin.Status = PluginStatus.Active; From 89a7055a121c8763633cc32547e63c6dac7026c1 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Wed, 1 Jul 2026 01:30:52 -0500 Subject: [PATCH 10/21] Add deferPluginLoads option to load plugins asynchronously after server startup When enabled, UsePlugins skips the synchronous DiscoverAndLoad during setup and instead loads all plugins in a background task after the server is ready. This significantly improves startup time when source plugins need to be built. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Ivy/Core/Plugins/PluginLoader.cs | 60 +++++++++++++++++++++++++++- src/Ivy/Server.cs | 19 ++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index ae2817608..cf7a6e026 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -34,12 +34,14 @@ public class PluginLoader : IPluginManager public event Action? PluginDeactivated; internal bool BuildSourcePlugins { get; } + internal bool DeferPluginLoads { get; } - internal PluginLoader(string pluginsDirectory, ILogger logger, IEnumerable? sharedAssemblyNames = null, bool buildSourcePlugins = false) + internal PluginLoader(string pluginsDirectory, ILogger logger, IEnumerable? sharedAssemblyNames = null, bool buildSourcePlugins = false, bool deferPluginLoads = false) { _pluginsDirectory = pluginsDirectory; _logger = logger; BuildSourcePlugins = buildSourcePlugins; + DeferPluginLoads = deferPluginLoads; _sharedAssemblyNames = new HashSet(sharedAssemblyNames ?? []) { "Ivy.Plugin.Abstractions", @@ -237,6 +239,11 @@ public void DiscoverAndLoad(Version hostVersion, IServiceProvider serviceProvide return (instance, pluginAssembly, loadContext, directory, shadowDir); } + internal void SetHostVersion(Version version) + { + _hostVersion = version; + } + internal void SetPluginConfigFactory(IIvyPluginConfigFactory factory) { _configFactory = factory; @@ -245,6 +252,57 @@ internal void SetPluginConfigFactory(IIvyPluginConfigFactory factory) internal void SetServiceProviderFactory(Func factory) { _serviceProviderFactory = factory; + if (DeferPluginLoads) + LoadDeferredPluginsAsync(); + } + + private void LoadDeferredPluginsAsync() + { + _ = Task.Run(() => + { + try + { + _logger.LogInformation("Loading plugins in background..."); + var directories = DiscoverPluginDirectories(); + foreach (var directory in directories) + { + try + { + LoadPlugin(directory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading deferred plugin from {Directory}", directory); + } + } + _logger.LogInformation("Background plugin loading complete. {Count} plugin(s) loaded.", Plugins.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during background plugin loading"); + } + }); + } + + private List DiscoverPluginDirectories() + { + var directories = new List(); + if (!Directory.Exists(_pluginsDirectory)) return directories; + + // Referenced plugins first (explicit references take priority) + var referencesFilePath = Path.Combine(_pluginsDirectory, PluginReferencesWatcher.FileName); + var referencedPaths = PluginReferencesWatcher.ParseReferencesFile(referencesFilePath, _pluginsDirectory, _logger); + foreach (var directory in referencedPaths) + { + if (Directory.Exists(directory)) + directories.Add(directory); + } + + // Then subdirectories of the plugins directory + foreach (var directory in Directory.GetDirectories(_pluginsDirectory)) + directories.Add(directory); + + return directories; } public void Configure(PluginContextBase context) diff --git a/src/Ivy/Server.cs b/src/Ivy/Server.cs index e92e6f198..ff8dd370f 100644 --- a/src/Ivy/Server.cs +++ b/src/Ivy/Server.cs @@ -556,16 +556,23 @@ public Server UsePlugins( Func? contextFactory = null, IEnumerable? sharedAssemblyNames = null, bool enableHotReload = true, - bool buildSourcePlugins = false) + bool buildSourcePlugins = false, + bool deferPluginLoads = false) { using var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); var logger = loggerFactory.CreateLogger(); - var loader = new PluginLoader(pluginsDirectory, logger, sharedAssemblyNames, buildSourcePlugins); + var loader = new PluginLoader(pluginsDirectory, logger, sharedAssemblyNames, buildSourcePlugins, deferPluginLoads); - using var bootstrapProvider = Services.BuildServiceProvider(); - loader.DiscoverAndLoad( - hostVersion ?? Assembly.GetEntryAssembly()!.GetName().Version!, - bootstrapProvider); + var effectiveHostVersion = hostVersion ?? Assembly.GetEntryAssembly()!.GetName().Version!; + if (!deferPluginLoads) + { + using var bootstrapProvider = Services.BuildServiceProvider(); + loader.DiscoverAndLoad(effectiveHostVersion, bootstrapProvider); + } + else + { + loader.SetHostVersion(effectiveHostVersion); + } loader.SetPluginConfigFactory(configFactory); configFactory.SetPluginManager(loader); From a959f356340670194139a27fc51e4fbb12d95484 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Wed, 1 Jul 2026 02:07:13 -0500 Subject: [PATCH 11/21] Add missing PluginLoadFailed and PluginRemoved events to FakePluginManager test helper --- src/Ivy.Test/Plugins/PluginStateServiceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Ivy.Test/Plugins/PluginStateServiceTests.cs b/src/Ivy.Test/Plugins/PluginStateServiceTests.cs index c724c4ac3..e3fd4e72d 100644 --- a/src/Ivy.Test/Plugins/PluginStateServiceTests.cs +++ b/src/Ivy.Test/Plugins/PluginStateServiceTests.cs @@ -95,7 +95,9 @@ private class FakePluginManager : IPluginManager public List ActivePluginIds { get; set; } = []; public event Action? PluginLoaded; + public event Action? PluginLoadFailed; public event Action? PluginUnloaded; + public event Action? PluginRemoved; public event Action? PluginReloaded; public event Action? PluginActivated; public event Action? PluginDeactivated; @@ -121,7 +123,9 @@ private class FakePluginManager : IPluginManager public object? BuildPluginConfigurationView(string pluginId, IIvyPluginConfig config) => null; public void RaisePluginLoaded(string pluginId) => PluginLoaded?.Invoke(pluginId); + public void RaisePluginLoadFailed(string pluginId) => PluginLoadFailed?.Invoke(pluginId); public void RaisePluginUnloaded(string pluginId) => PluginUnloaded?.Invoke(pluginId); + public void RaisePluginRemoved(string pluginId) => PluginRemoved?.Invoke(pluginId); public void RaisePluginReloaded(string pluginId) => PluginReloaded?.Invoke(pluginId); public void RaisePluginActivated(string pluginId) => PluginActivated?.Invoke(pluginId); public void RaisePluginDeactivated(string pluginId) => PluginDeactivated?.Invoke(pluginId); From 370effcf1a5b7c0d33d3f8ccec80812db2bab3eb Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Tue, 7 Jul 2026 03:30:01 -0500 Subject: [PATCH 12/21] Add UseEndpoints to plugin context for scoped, hot-reloadable HTTP routes Plugins can now register endpoints under /ivy/plugins/{slug}/ via a dedicated API instead of requiring raw UseWebApplication access. Endpoints are backed by a dynamic data source so they're added/removed with the plugin lifecycle. Also moves the plugin icon asset route to /ivy/plugin-icons/ to avoid path collisions with plugin endpoints. --- .../Abstractions/IIvyExtendedPluginContext.cs | 4 + src/Ivy/Core/Plugins/PluginContext.cs | 81 ++++++++++++++++++- src/Ivy/Core/Plugins/PluginState.cs | 1 + .../DynamicPluginEndpointDataSource.cs | 65 +++++++++++++++ .../Routing/PluginEndpointExtensions.cs | 51 ++++++++++++ .../Routing/PluginEndpointRouteBuilder.cs | 35 ++++++++ ...PluginEndpointRouteBuilderWithDirectory.cs | 21 +++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs create mode 100644 src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs create mode 100644 src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs create mode 100644 src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs diff --git a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs index 25713bfd7..c55eefb19 100644 --- a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs +++ b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs @@ -1,5 +1,6 @@ using System.Reflection; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; namespace Ivy.Plugins; @@ -9,6 +10,9 @@ public interface IIvyExtendedPluginContext : IIvyPluginContext void AddApp(AppDescriptor descriptor); void AddAppsFromAssembly(Assembly assembly); + // HTTP endpoints + void UseEndpoints(string slug, Action configure); + // ASP.NET pipeline void UseWebApplication(Action configure); void UseWebApplicationBuilder(Action configure); diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index 21b799e1f..35a0d8018 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -1,8 +1,11 @@ using System.Reflection; +using System.Text.RegularExpressions; using Ivy.Core.Apps; +using Ivy.Core.Plugins.Routing; using Ivy.Plugins; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Core.Plugins; @@ -35,6 +38,9 @@ protected PluginContextBase(AppRepository appRepository, IReadOnlySet re private readonly AggregatePluginServiceProvider _aggregateProvider = new(); private readonly Dictionary _pluginStates = new(); private readonly ReaderWriterLockSlim _lock = new(); + private readonly Dictionary _slugToPluginId = new(); + private DynamicPluginEndpointDataSource? _endpointDataSource; + private WebApplication? _app; private string? _currentPluginId; protected string? CurrentPluginId => _currentPluginId; @@ -115,6 +121,60 @@ public void UseWebApplicationBuilder(Action configure) configure(Builder); } + public void UseEndpoints(string slug, Action configure) + { + ValidateSlug(slug); + + var pluginId = _currentPluginId + ?? throw new InvalidOperationException("UseEndpoints can only be called during plugin configuration."); + + _lock.EnterWriteLock(); + try + { + if (_slugToPluginId.TryGetValue(slug, out var existingId) && existingId != pluginId) + throw new InvalidOperationException($"Endpoint slug '{slug}' is already claimed by plugin '{existingId}'."); + _slugToPluginId[slug] = pluginId; + + if (_pluginStates.TryGetValue(pluginId, out var state)) + state.EndpointSlug = slug; + } + finally + { + _lock.ExitWriteLock(); + } + + if (_endpointDataSource is null || _app is null) + throw new InvalidOperationException("UseEndpoints cannot be called before the application is built."); + + var pluginDir = _pluginStates.TryGetValue(pluginId, out var ps) ? ps.Directory : null; + var builder = new PluginEndpointRouteBuilder(_app.Services, _app); + + // Use ASP.NET's MapGroup for correct prefix handling across all endpoint types + var group = builder.MapGroup($"/ivy/plugins/{slug}"); + + // Wrap the group to provide plugin directory for MapStaticAssets + IEndpointRouteBuilder target = pluginDir is not null + ? new PluginEndpointRouteBuilderWithDirectory(group, pluginDir) + : group; + + configure(target); + + var endpoints = builder.CollectEndpoints(); + _endpointDataSource.AddEndpoints(slug, endpoints); + } + + private static readonly Regex SlugPattern = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled); + + private static void ValidateSlug(string slug) + { + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Endpoint slug cannot be empty.", nameof(slug)); + if (!SlugPattern.IsMatch(slug)) + throw new ArgumentException( + $"Endpoint slug '{slug}' is invalid. Must be lowercase alphanumeric with optional hyphens, not starting or ending with a hyphen.", + nameof(slug)); + } + public void BuildServiceProvider() { _lock.EnterReadLock(); @@ -174,6 +234,13 @@ internal virtual void RemovePluginContributions(string pluginId) // Collect app IDs before removing factories so we can reload affected sessions affectedAppIds = GetAppIdsFromFactories(state.AppFactories); + // Remove dynamic endpoints + if (state.EndpointSlug is not null) + { + _endpointDataSource?.RemoveEndpoints(state.EndpointSlug); + _slugToPluginId.Remove(state.EndpointSlug); + } + foreach (var a in state.AppActions) _appActions.Remove(a); @@ -242,8 +309,19 @@ private static HashSet GetAppIdsFromFactories(List internal IReadOnlyDictionary PluginStates => _pluginStates; + internal void SetEndpointDataSource(DynamicPluginEndpointDataSource dataSource) + { + _endpointDataSource = dataSource; + } + public void Apply(WebApplication app) { + _app = app; + + // Register the dynamic endpoint data source for plugin routes + _endpointDataSource ??= new DynamicPluginEndpointDataSource(); + ((IEndpointRouteBuilder)app).DataSources.Add(_endpointDataSource); + List> actions; _lock.EnterReadLock(); try { actions = _appActions.ToList(); } @@ -252,7 +330,8 @@ public void Apply(WebApplication app) foreach (var action in actions) action(app); - app.MapGet("/ivy/plugins/{pluginId}/assets/{**filePath}", (string pluginId, string filePath) => + // Plugin icon route — serves files referenced by PluginIcon.File() + app.MapGet("/ivy/plugin-icons/{pluginId}/{**filePath}", (string pluginId, string filePath) => { _lock.EnterReadLock(); try diff --git a/src/Ivy/Core/Plugins/PluginState.cs b/src/Ivy/Core/Plugins/PluginState.cs index c3a560024..8cdf7bbdc 100644 --- a/src/Ivy/Core/Plugins/PluginState.cs +++ b/src/Ivy/Core/Plugins/PluginState.cs @@ -8,6 +8,7 @@ internal class PluginState public string PluginId { get; } public string Directory { get; } public ServiceCollection PluginServices { get; } = new(); + public string? EndpointSlug { get; set; } public List> AppActions { get; } = []; public List> AppFactories { get; } = []; diff --git a/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs b/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs new file mode 100644 index 000000000..70de4580e --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Ivy.Core.Plugins.Routing; + +internal sealed class DynamicPluginEndpointDataSource : EndpointDataSource +{ + private readonly object _lock = new(); + private readonly Dictionary> _pluginEndpoints = new(); + private CancellationTokenSource _cts = new(); + private IReadOnlyList? _cachedEndpoints; + + public override IReadOnlyList Endpoints + { + get + { + lock (_lock) + { + _cachedEndpoints ??= _pluginEndpoints.Values.SelectMany(e => e).ToList(); + return _cachedEndpoints; + } + } + } + + public override IChangeToken GetChangeToken() + { + return new CancellationChangeToken(_cts.Token); + } + + public void AddEndpoints(string slug, IReadOnlyList endpoints) + { + lock (_lock) + { + _pluginEndpoints[slug] = endpoints; + InvalidateCache(); + } + } + + public void RemoveEndpoints(string slug) + { + lock (_lock) + { + if (_pluginEndpoints.Remove(slug)) + InvalidateCache(); + } + } + + public bool HasSlug(string slug) + { + lock (_lock) + { + return _pluginEndpoints.ContainsKey(slug); + } + } + + private void InvalidateCache() + { + _cachedEndpoints = null; + var oldCts = _cts; + _cts = new CancellationTokenSource(); + oldCts.Cancel(); + oldCts.Dispose(); + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs new file mode 100644 index 000000000..88d0a34da --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticFiles; + +namespace Ivy.Core.Plugins.Routing; + +public static class PluginEndpointExtensions +{ + private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); + + /// + /// Serves static files from the plugin directory at the given sub-path. + /// Must be called on an inside a UseEndpoints callback. + /// + public static IEndpointRouteBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string subPath) + { + var directory = endpoints is PluginEndpointRouteBuilderWithDirectory withDir + ? withDir.PluginDirectory + : throw new InvalidOperationException( + "MapStaticAssets can only be called on an endpoint builder provided by UseEndpoints."); + + return MapStaticAssets(endpoints, subPath, directory); + } + + public static IEndpointRouteBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string subPath, string directory) + { + var normalizedSubPath = subPath.TrimStart('/').TrimEnd('/'); + var baseDir = Path.GetFullPath(directory); + + endpoints.MapGet($"{normalizedSubPath}/{{**filePath}}", (string filePath) => + { + if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath)) + return Results.NotFound(); + + var fullPath = Path.GetFullPath(Path.Join(baseDir, filePath)); + if (!fullPath.StartsWith(baseDir + Path.DirectorySeparatorChar)) + return Results.NotFound(); + + if (!File.Exists(fullPath)) + return Results.NotFound(); + + if (!ContentTypeProvider.TryGetContentType(fullPath, out var contentType)) + contentType = "application/octet-stream"; + + return Results.File(fullPath, contentType); + }); + + return endpoints; + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs new file mode 100644 index 000000000..32de30cb6 --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Ivy.Core.Plugins.Routing; + +/// +/// Collects endpoint data sources registered by a plugin. +/// Prefixing is handled by calling MapGroup on this builder before +/// passing the group to the plugin's configure callback. +/// +internal sealed class PluginEndpointRouteBuilder : IEndpointRouteBuilder +{ + private readonly IApplicationBuilder _appBuilder; + + public PluginEndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilder appBuilder) + { + ServiceProvider = serviceProvider; + _appBuilder = appBuilder; + } + + public IServiceProvider ServiceProvider { get; } + public ICollection DataSources { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() => _appBuilder.New(); + + /// + /// Collects all endpoints from registered data sources. + /// Prefixing is already applied by the RouteGroupBuilder returned from MapGroup. + /// + internal IReadOnlyList CollectEndpoints() + { + return DataSources.SelectMany(ds => ds.Endpoints).ToList(); + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs new file mode 100644 index 000000000..cc93c8f7a --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Ivy.Core.Plugins.Routing; + +internal sealed class PluginEndpointRouteBuilderWithDirectory : IEndpointRouteBuilder +{ + private readonly IEndpointRouteBuilder _inner; + + public string PluginDirectory { get; } + + public PluginEndpointRouteBuilderWithDirectory(IEndpointRouteBuilder inner, string pluginDirectory) + { + _inner = inner; + PluginDirectory = pluginDirectory; + } + + public IServiceProvider ServiceProvider => _inner.ServiceProvider; + public ICollection DataSources => _inner.DataSources; + public IApplicationBuilder CreateApplicationBuilder() => _inner.CreateApplicationBuilder(); +} From 386f602a4b79d2a44a992bc54564ec260f05f0d4 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Thu, 9 Jul 2026 22:48:08 -0500 Subject: [PATCH 13/21] Remove UseWebApplication and UseWebApplicationBuilder from plugin interface These APIs expose too much of the ASP.NET pipeline to plugins. Plugins should use UseEndpoints for HTTP routes instead. The methods remain on Server for internal first-party use (DesktopWindow, DocsServer). --- .../Abstractions/IIvyExtendedPluginContext.cs | 5 --- src/Ivy/Core/Plugins/PluginContext.cs | 36 ++----------------- src/Ivy/Core/Plugins/PluginState.cs | 2 -- src/Ivy/Server.cs | 2 -- 4 files changed, 2 insertions(+), 43 deletions(-) diff --git a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs index c55eefb19..4aece35a4 100644 --- a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs +++ b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs @@ -1,5 +1,4 @@ using System.Reflection; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; namespace Ivy.Plugins; @@ -12,8 +11,4 @@ public interface IIvyExtendedPluginContext : IIvyPluginContext // HTTP endpoints void UseEndpoints(string slug, Action configure); - - // ASP.NET pipeline - void UseWebApplication(Action configure); - void UseWebApplicationBuilder(Action configure); } diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index 35a0d8018..ee27ee308 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -34,8 +34,7 @@ protected PluginContextBase(AppRepository appRepository, IReadOnlySet re ReservedPaths = reservedPaths; Builder = builder; } - private readonly List> _appActions = []; - private readonly AggregatePluginServiceProvider _aggregateProvider = new(); +private readonly AggregatePluginServiceProvider _aggregateProvider = new(); private readonly Dictionary _pluginStates = new(); private readonly ReaderWriterLockSlim _lock = new(); private readonly Dictionary _slugToPluginId = new(); @@ -101,27 +100,7 @@ public void AddAppsFromAssembly(Assembly assembly) - public void UseWebApplication(Action configure) - { - _lock.EnterWriteLock(); - try - { - _appActions.Add(configure); - if (_currentPluginId is not null && _pluginStates.TryGetValue(_currentPluginId, out var state)) - state.AppActions.Add(configure); - } - finally - { - _lock.ExitWriteLock(); - } - } - - public void UseWebApplicationBuilder(Action configure) - { - configure(Builder); - } - - public void UseEndpoints(string slug, Action configure) +public void UseEndpoints(string slug, Action configure) { ValidateSlug(slug); @@ -241,9 +220,6 @@ internal virtual void RemovePluginContributions(string pluginId) _slugToPluginId.Remove(state.EndpointSlug); } - foreach (var a in state.AppActions) - _appActions.Remove(a); - foreach (var f in state.AppFactories) AppRepository.RemoveFactory(f); @@ -322,14 +298,6 @@ public void Apply(WebApplication app) _endpointDataSource ??= new DynamicPluginEndpointDataSource(); ((IEndpointRouteBuilder)app).DataSources.Add(_endpointDataSource); - List> actions; - _lock.EnterReadLock(); - try { actions = _appActions.ToList(); } - finally { _lock.ExitReadLock(); } - - foreach (var action in actions) - action(app); - // Plugin icon route — serves files referenced by PluginIcon.File() app.MapGet("/ivy/plugin-icons/{pluginId}/{**filePath}", (string pluginId, string filePath) => { diff --git a/src/Ivy/Core/Plugins/PluginState.cs b/src/Ivy/Core/Plugins/PluginState.cs index 8cdf7bbdc..734eb5ced 100644 --- a/src/Ivy/Core/Plugins/PluginState.cs +++ b/src/Ivy/Core/Plugins/PluginState.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Core.Plugins; @@ -10,7 +9,6 @@ internal class PluginState public ServiceCollection PluginServices { get; } = new(); public string? EndpointSlug { get; set; } - public List> AppActions { get; } = []; public List> AppFactories { get; } = []; public PluginState(string pluginId, string directory) diff --git a/src/Ivy/Server.cs b/src/Ivy/Server.cs index ff8dd370f..268edfa15 100644 --- a/src/Ivy/Server.cs +++ b/src/Ivy/Server.cs @@ -771,8 +771,6 @@ public Server UsePlugins( }); } - // Plugin Configure runs before Build so UseWebApplicationBuilder actions - // apply directly to the builder. UseWebApplication actions are deferred to Apply. PluginContextBase? pluginContext = null; if (_pluginLoader != null) { From cc0f2b936ad07b4245e52515c438e5070158da38 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Thu, 9 Jul 2026 23:03:59 -0500 Subject: [PATCH 14/21] Fix formatting and add exception filters to generic catch clauses - Fix whitespace in PluginContext.cs (missing indentation on field and method) - Fix indentation on goto label in PluginLoader.cs - Add 'when' exception filters to exclude OutOfMemoryException and StackOverflowException from plugin isolation catches, satisfying CodeQL cs/generic-catch-clause rule Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Ivy/Core/Plugins/PluginContext.cs | 7 +++---- src/Ivy/Core/Plugins/PluginLoader.cs | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index ee27ee308..b1000cf88 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -34,7 +34,8 @@ protected PluginContextBase(AppRepository appRepository, IReadOnlySet re ReservedPaths = reservedPaths; Builder = builder; } -private readonly AggregatePluginServiceProvider _aggregateProvider = new(); + + private readonly AggregatePluginServiceProvider _aggregateProvider = new(); private readonly Dictionary _pluginStates = new(); private readonly ReaderWriterLockSlim _lock = new(); private readonly Dictionary _slugToPluginId = new(); @@ -98,9 +99,7 @@ public void AddAppsFromAssembly(Assembly assembly) state.AppFactories.Add(factory); } - - -public void UseEndpoints(string slug, Action configure) + public void UseEndpoints(string slug, Action configure) { ValidateSlug(slug); diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index cf7a6e026..0f033ebfd 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -270,14 +270,14 @@ private void LoadDeferredPluginsAsync() { LoadPlugin(directory); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { _logger.LogError(ex, "Error loading deferred plugin from {Directory}", directory); } } _logger.LogInformation("Background plugin loading complete. {Count} plugin(s) loaded.", Plugins.Count); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { _logger.LogError(ex, "Error during background plugin loading"); } @@ -492,7 +492,7 @@ public bool LoadPlugin(string pluginPath) _pluginContext.ClearPluginConfig(); _pluginContext.BuildPluginServiceProvider(manifest.Id, plugin.Services); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) { _pluginContext.ClearCurrentPlugin(); _pluginContext.ClearPluginConfig(); @@ -531,7 +531,7 @@ public bool LoadPlugin(string pluginPath) _pluginContext!.RefreshApps(appIds); } - done: + done: // Fire event outside the lock to avoid deadlocks — subscribers may call // GetActivePluginIds() which needs a read lock. if (loadedPluginId is not null) From a3b25189973595622fdbc9d10334f022f4963e98 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Fri, 10 Jul 2026 07:53:37 -0500 Subject: [PATCH 15/21] Add API compatibility suppressions for plugin context changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppress CP0006 (new interface member on IIvyExtendedPluginContext.UseEndpoints) and CP0002 (removed UseWebApplication/UseWebApplicationBuilder from PluginContextBase). Context interfaces are safe to extend per project conventions — plugins call into them, not implement them. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Ivy/CompatibilitySuppressions.xml | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Ivy/CompatibilitySuppressions.xml b/src/Ivy/CompatibilitySuppressions.xml index a1859cbc1..d17fa9402 100644 --- a/src/Ivy/CompatibilitySuppressions.xml +++ b/src/Ivy/CompatibilitySuppressions.xml @@ -120,6 +120,20 @@ lib/net10.0/Ivy.dll true + + CP0002 + M:Ivy.Core.Plugins.PluginContextBase.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + + + CP0002 + M:Ivy.Core.Plugins.PluginContextBase.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0002 M:Ivy.Core.Plugins.PluginLoader.SetConfiguration(Microsoft.Extensions.Configuration.IConfiguration) @@ -148,6 +162,20 @@ lib/net10.0/Ivy.dll true + + CP0002 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + + + CP0002 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0002 M:Ivy.Server.UsePlugins(System.String,System.Version,System.Func{Ivy.Server,Microsoft.AspNetCore.Builder.WebApplicationBuilder,Ivy.Core.Plugins.PluginContextBase},System.Collections.Generic.IEnumerable{System.String},System.Boolean,System.Boolean) @@ -155,6 +183,13 @@ lib/net10.0/Ivy.dll true + + CP0006 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseEndpoints(System.String,System.Action{Microsoft.AspNetCore.Routing.IEndpointRouteBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0011 F:Ivy.Icons.AArrowDown From 1eb500f260345d68ba3e8ad9222ea73f1d57e9a9 Mon Sep 17 00:00:00 2001 From: Zach Wolfe <72848430+zachwolfe@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:56:51 -0500 Subject: [PATCH 16/21] Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/Ivy/Core/Plugins/PluginLoader.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 0f033ebfd..906cc4e62 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Linq; using System.Reflection; using System.Runtime.Loader; using System.Text.Json; @@ -292,10 +293,9 @@ private List DiscoverPluginDirectories() // Referenced plugins first (explicit references take priority) var referencesFilePath = Path.Combine(_pluginsDirectory, PluginReferencesWatcher.FileName); var referencedPaths = PluginReferencesWatcher.ParseReferencesFile(referencesFilePath, _pluginsDirectory, _logger); - foreach (var directory in referencedPaths) + foreach (var directory in referencedPaths.Where(Directory.Exists)) { - if (Directory.Exists(directory)) - directories.Add(directory); + directories.Add(directory); } // Then subdirectories of the plugins directory From 86d44deebcb63cac9d42f3082f2e19497aec8b02 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Fri, 10 Jul 2026 08:16:03 -0500 Subject: [PATCH 17/21] Remove unnecessary using System.Linq directive System.Linq is already included via ImplicitUsings, so the explicit using directive triggers IDE0005 in CI's dotnet format check. Co-Authored-By: Claude Opus 4.8 --- src/Ivy/Core/Plugins/PluginLoader.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index 906cc4e62..c81779782 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Linq; using System.Reflection; using System.Runtime.Loader; using System.Text.Json; From 741bf13ada9552ff10b1ebcd578146d2182386f7 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Fri, 10 Jul 2026 08:26:37 -0500 Subject: [PATCH 18/21] Add API compatibility suppressions for new IPluginManager events PluginLoadFailed and PluginRemoved are new members on IPluginManager, which is a host-provided interface not implemented by plugins, so this is not a breaking change for deployed plugins. Co-Authored-By: Claude Opus 4.8 --- .../CompatibilitySuppressions.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml b/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml index 4f6016b18..ab63ed95f 100644 --- a/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml +++ b/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml @@ -85,6 +85,20 @@ lib/net10.0/Ivy.Plugin.Abstractions.dll true + + CP0006 + E:Ivy.Plugins.IPluginManager.PluginLoadFailed + lib/net10.0/Ivy.Plugin.Abstractions.dll + lib/net10.0/Ivy.Plugin.Abstractions.dll + true + + + CP0006 + E:Ivy.Plugins.IPluginManager.PluginRemoved + lib/net10.0/Ivy.Plugin.Abstractions.dll + lib/net10.0/Ivy.Plugin.Abstractions.dll + true + CP0006 M:Ivy.Plugins.IPluginManager.BuildPluginConfigurationView(System.String,Ivy.Plugins.IIvyPluginConfig) From 0996b26a7d727433b68ee8bd8556ba94523f0aff Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Sat, 18 Jul 2026 00:11:22 -0500 Subject: [PATCH 19/21] =?UTF-8?q?Remove=20PluginIconKind.File=20=E2=80=94?= =?UTF-8?q?=20unused=20and=20replaced=20by=20tendril.json=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The File icon kind added complexity (route handler, validation, path traversal protection) with zero adopters. Icons for available plugins will now flow through a tendril.json file in the .nupkg, supporting only Named and Url kinds. --- src/Ivy.Plugin.Abstractions/PluginIcon.cs | 6 ---- src/Ivy/Core/Plugins/PluginContext.cs | 38 ----------------------- src/Ivy/Core/Plugins/PluginLoader.cs | 23 -------------- 3 files changed, 67 deletions(-) diff --git a/src/Ivy.Plugin.Abstractions/PluginIcon.cs b/src/Ivy.Plugin.Abstractions/PluginIcon.cs index b853ef58f..b868cfcb7 100644 --- a/src/Ivy.Plugin.Abstractions/PluginIcon.cs +++ b/src/Ivy.Plugin.Abstractions/PluginIcon.cs @@ -14,16 +14,10 @@ public record PluginIcon /// Creates an icon from a URL (e.g. PNG, SVG, or any image URL). /// public static PluginIcon Url(string url) => new() { Kind = PluginIconKind.Url, Value = url }; - - /// - /// Creates an icon from a file bundled with the plugin (relative path within the plugin directory). - /// - public static PluginIcon File(string relativePath) => new() { Kind = PluginIconKind.File, Value = relativePath }; } public enum PluginIconKind { Named = 0, Url = 1, - File = 2, } diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index b1000cf88..96dc3925c 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -297,44 +297,6 @@ public void Apply(WebApplication app) _endpointDataSource ??= new DynamicPluginEndpointDataSource(); ((IEndpointRouteBuilder)app).DataSources.Add(_endpointDataSource); - // Plugin icon route — serves files referenced by PluginIcon.File() - app.MapGet("/ivy/plugin-icons/{pluginId}/{**filePath}", (string pluginId, string filePath) => - { - _lock.EnterReadLock(); - try - { - if (!_pluginStates.TryGetValue(pluginId, out var state)) - return Results.NotFound(); - - if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath)) - return Results.NotFound(); - - var pluginDir = Path.GetFullPath(state.Directory); - var fullPath = Path.GetFullPath(Path.Join(pluginDir, filePath)); - - if (!fullPath.StartsWith(pluginDir + Path.DirectorySeparatorChar)) - return Results.NotFound(); - - if (!File.Exists(fullPath)) - return Results.NotFound(); - - var contentType = Path.GetExtension(fullPath).ToLowerInvariant() switch - { - ".svg" => "image/svg+xml", - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".ico" => "image/x-icon", - _ => "application/octet-stream" - }; - return Results.File(fullPath, contentType); - } - finally - { - _lock.ExitReadLock(); - } - }); } } diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index c81779782..2a961e2e4 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -1148,8 +1148,6 @@ private void TryLoadCandidate( return; } - ValidatePluginIcon(manifest, directory); - // Reject duplicates — if a plugin with this ID was already discovered, skip it. var existingIndex = candidates.FindIndex(c => c.Instance.Manifest.Id == manifest.Id); if (existingIndex >= 0) @@ -1200,27 +1198,6 @@ internal void AddTestPlugin(IIvyPlugin instance, string directory) } } - private void ValidatePluginIcon(PluginManifest manifest, string directory) - { - if (manifest.Icon is { Kind: PluginIconKind.File } icon) - { - if (string.IsNullOrWhiteSpace(icon.Value) || Path.IsPathRooted(icon.Value)) - { - _logger.LogWarning( - "Plugin '{Id}' has an invalid icon path '{Path}' (must be relative).", - manifest.Id, icon.Value); - return; - } - - var fullPath = Path.GetFullPath(Path.Join(directory, icon.Value)); - if (!File.Exists(fullPath)) - { - _logger.LogWarning( - "Plugin '{Id}' references icon file '{Path}' which does not exist in '{Directory}'.", - manifest.Id, icon.Value, directory); - } - } - } internal List ValidatePluginConfiguration( PluginConfigurationSchema schema, From 65367898eaad982db96f662469217b3260276ae6 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Sat, 18 Jul 2026 00:26:08 -0500 Subject: [PATCH 20/21] Add NuGet metadata to sample plugins (HelloWorld, AppProvider) --- .../Ivy.Plugin.Example.AppProvider.csproj | 9 +++++++++ .../Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj b/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj index 972868c22..4d3af23bf 100644 --- a/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj +++ b/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj @@ -4,6 +4,15 @@ net10.0 enable enable + Ivy.Plugin.Example.AppProvider + Example App Provider + Ivy Interactive + Ivy Interactive + Sample plugin demonstrating custom app registration in Ivy + Apache-2.0 + https://github.com/Ivy-Interactive/Ivy-Framework + https://github.com/Ivy-Interactive/Ivy-Framework + git diff --git a/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj b/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj index 9b8d86193..be9b915f6 100644 --- a/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj +++ b/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj @@ -4,6 +4,15 @@ net10.0 enable enable + Ivy.Plugin.HelloWorld + Hello World + Ivy Interactive + Ivy Interactive + Sample plugin demonstrating the Ivy plugin system + Apache-2.0 + https://github.com/Ivy-Interactive/Ivy-Framework + https://github.com/Ivy-Interactive/Ivy-Framework + git From badd16cc6c3c5e66f299b7b00f35324cb9213ce4 Mon Sep 17 00:00:00 2001 From: Zach Wolfe Date: Sat, 18 Jul 2026 18:03:20 -0500 Subject: [PATCH 21/21] Remove unused using Microsoft.AspNetCore.Http directive Fixes IDE0005 format check failure in CI. Co-Authored-By: Claude Opus 4.8 --- src/Ivy/Core/Plugins/PluginContext.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index 96dc3925c..37aacb162 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -4,7 +4,6 @@ using Ivy.Core.Plugins.Routing; using Ivy.Plugins; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection;