Skip to content

Add plugin hot reload#4578

Open
Garulf wants to merge 5 commits into
devfrom
feature/plugin-hot-reload
Open

Add plugin hot reload#4578
Garulf wants to merge 5 commits into
devfrom
feature/plugin-hot-reload

Conversation

@Garulf

@Garulf Garulf commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

Reload plugins in place after install/update/uninstall — and on demand — instead of requiring a full app restart.

  • Collectible AssemblyLoadContext: PluginAssemblyLoader is now created with isCollectible: true and tracked per plugin, so .NET plugin assemblies can be unloaded. Unload is verified with a WeakReference + bounded GC loop and is best-effort: if something still pins the old context (cached result delegates, event handlers), it's logged and reclaimed on restart while the new version still loads from its own directory.
  • New reload primitives in PluginManager: ReloadPluginAsync(id) / ReloadAllPluginsAsync() (public), UnloadPluginAsync / LoadAndInitializePluginAsync (internal), with per-plugin locking. Any failure falls back to the existing ModifiedPlugins + restart-required flow.
  • Groundwork: capability registries converted from ConcurrentBag to removable ConcurrentDictionarys; single-plugin init extracted from InitializePluginsAsync; inverse-registration helpers; ResultsUpdated handlers are now tracked so they can be detached; DialogJump.RemoveDialogJumpPlugin.
  • Wiring: new HotReloadAfterChanging setting (default on, AutoRestartAfterChanging remains the fallback) in both the app settings and the PluginsManager plugin. Install/update/uninstall in PluginInstaller and the PluginsManager plugin now hot reload first. Uninstall deletes the plugin directory immediately when the unload is verified (marker-file fallback otherwise).
  • API surface: IPublicAPI.ReloadPluginAsync(string) and IPublicAPI.ReloadAllPluginsAsync() (additive). New Sys plugin command Reload All Plugins; newly added default Sys commands are now merged into persisted settings so they appear for existing users.
  • The taskkill PreBuild step now only runs on Windows, so the solution cross-compiles.

Tests

PluginHotReloadTest covers register/unregister symmetry of all plugin registries, load-context collectibility after unload, and single-directory plugin.json parsing. Verified dotnet build Flow.Launcher.sln clean; test execution needs the Windows CI (WindowsDesktop runtime).

Known limitations

  • Unload verification can fail for plugins whose delegates are still referenced (e.g. self-updating PluginsManager); this leaks the old context's memory until restart and is logged — the reload itself still works.
  • A query already past the initializing gate when a reload starts completes against the old instance; worst case it shows the existing "plugin failed to respond" result once.

Reload plugins in place after install/update/uninstall instead of
requiring an app restart, plus a manual reload API and Sys command.

- Make PluginAssemblyLoader a collectible AssemblyLoadContext and track
  it per plugin so dotnet plugin assemblies can be unloaded; verify
  collection via WeakReference with a bounded GC loop (best effort:
  a pinned context is logged and reclaimed on restart)
- Add PluginManager.ReloadPluginAsync/ReloadAllPluginsAsync/
  UnloadPluginAsync/LoadAndInitializePluginAsync with per-plugin
  locking; failures fall back to the existing modified+restart flow
- Convert capability registries to removable dictionaries, extract
  single-plugin init and inverse-registration helpers, track and
  detach ResultsUpdated handlers, support DialogJump removal
- Wire hot reload into PluginInstaller and the PluginsManager plugin
  behind a new HotReloadAfterChanging setting (default on), keeping
  AutoRestartAfterChanging as the fallback; uninstall now deletes the
  plugin directory immediately when the unload is verified
- Add IPublicAPI.ReloadPluginAsync/ReloadAllPluginsAsync and a Sys
  plugin "Reload All Plugins" command; merge newly added default Sys
  commands into persisted settings
- Run the taskkill PreBuild step only on Windows so the solution can
  be cross-compiled
- Add PluginHotReloadTest covering register/unregister symmetry, load
  context collectibility, and single-directory metadata parsing
@github-actions github-actions Bot added this to the 2.2.0 milestone Jul 16, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs
Comment thread Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs
Comment thread Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs
Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs Outdated
Comment thread Flow.Launcher.Core/Plugin/PluginInstaller.cs
Comment thread Flow.Launcher.Core/Plugin/PluginsLoader.cs Outdated
Comment thread Plugins/Flow.Launcher.Plugin.Sys/Main.cs Outdated
Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs
- Fix PluginHotReloadTest fake metadata: set ExecuteFileName before
  PluginDirectory so the setter's Path.Combine does not throw
- DialogJump: clear the cached active dialog window when its plugin is
  removed, and use TryGetValue for lookups that can race a hot reload
- ReloadPluginAsync: keep the plugin directory discoverable after a
  failed reload so a retry can still load it
- InstallPlugin: skip hot reload for packages whose plugin.json ID
  differs from the requested plugin ID
- LoadAndInitializePluginAsync: bail out before creating a load
  context when the plugin ID is already running
- Update-all flows: mark the batch as not fully hot reloaded on
  cancellation, failed update, or exception so the restart fallback
  still applies
- Sys reload command: only show the success message when the reload
  ran to completion

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs Outdated
Comment thread Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs Outdated
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 026d5d71-44ee-41e0-b472-dd8408ba7e99

📥 Commits

Reviewing files that changed from the base of the PR and between 3b4b730 and d10876c.

📒 Files selected for processing (3)
  • Flow.Launcher.Core/Plugin/PluginManager.cs
  • Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml
  • Plugins/Flow.Launcher.Plugin.Sys/Main.cs
🚧 Files skipped from review as they are similar to previous changes (3)
  • Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml
  • Plugins/Flow.Launcher.Plugin.Sys/Main.cs
  • Flow.Launcher.Core/Plugin/PluginManager.cs

📝 Walkthrough

Walkthrough

Plugin hot reload is added across plugin loading, unloading, public APIs, Plugin Store operations, settings, UI cleanup, system commands, and tests. Collectible assembly contexts and lifecycle cleanup support in-place plugin replacement without restarting.

Changes

Plugin hot reload

Layer / File(s) Summary
Reload contracts and collectible loading
Flow.Launcher.Core/Plugin/*, Flow.Launcher.Infrastructure/UserSettings/Settings.cs, Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
Adds reload APIs, hot-reload settings, metadata access, and collectible assembly-load-context unload helpers.
Plugin manager reload lifecycle
Flow.Launcher.Core/Plugin/PluginManager.cs
Tracks load contexts, reload paths, registries, modified state, initialization, unloading, and uninstall cleanup.
Plugin loading and runtime cleanup
Flow.Launcher.Core/Plugin/PluginsLoader.cs, Flow.Launcher/ViewModel/MainViewModel.cs, Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs, Flow.Launcher.Test/PluginHotReloadTest.cs
Centralizes plugin loading, removes stale result handlers and dialog state, hardens concurrent lookups, and tests unloading behavior.
Install, update, uninstall, and settings integration
Flow.Launcher.Core/Plugin/PluginInstaller.cs, Plugins/Flow.Launcher.Plugin.PluginsManager/*, Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml, Flow.Launcher/Languages/en.xaml
Attempts hot reload after plugin changes, tracks aggregate update success, adds settings controls, and adds localized messages.
Public reload commands and entry points
Flow.Launcher/PublicAPIInstance.cs, Plugins/Flow.Launcher.Plugin.Sys/*, Flow.Launcher/Flow.Launcher.csproj
Exposes reload operations through the public API and Sys command, persists the new command, adds reload status text, and limits the prebuild target to Windows hosts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant PluginsManager
  participant PublicAPIInstance
  participant PluginManager
  User->>PluginsManager: install or update plugin
  PluginsManager->>PublicAPIInstance: ReloadPluginAsync(id)
  PublicAPIInstance->>PluginManager: reload plugin
  PluginManager-->>PublicAPIInstance: reload success
  PublicAPIInstance-->>PluginsManager: return status
  PluginsManager-->>User: show hot-reload or restart message
Loading

Possibly related PRs

Suggested reviewers: jjw24, taooceros

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Clear and specific: it summarizes the addition of plugin hot reload support.
Description check ✅ Passed The description matches the changeset and explains the new hot-reload flow, settings, APIs, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/plugin-hot-reload

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs (1)

559-569: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Check TryGetValue return value to avoid accessing unloaded plugins.

If a plugin is removed concurrently via hot-reload, TryGetValue will return false and leave existingDialogWindow as null. The code then incorrectly falls into the else block and invokes dialog.Plugin.CheckDialogWindow(hwnd) on the unloaded/disposed plugin, which can throw an exception and disrupt the WINEVENTPROC hook.

  • Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs#L559-L569: Check the return value and continue if the plugin was removed.
  • Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs#L981-L991: Check the return value and continue if the plugin was removed here as well.
🐛 Proposed fix
-                    // The dialog can be removed concurrently by a plugin hot reload
-                    _dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow);
+                    // The dialog can be removed concurrently by a plugin hot reload
+                    if (!_dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow))
+                    {
+                        continue;
+                    }
+
                     if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd)
                     {
                         // If the dialog window is already in the list, no need to check again
                         dialogWindow = existingDialogWindow;
                     }
                     else
                     {
                         dialogWindow = dialog.Plugin.CheckDialogWindow(hwnd);
                     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs` around lines 559 -
569, The dialog lookup paths must handle concurrent plugin removal before
accessing the plugin. In both DialogJump.cs sites (anchor lines 559-569 and
sibling lines 981-991), capture the boolean result from
_dialogJumpDialogs.TryGetValue and continue the enclosing iteration when it
returns false; only use the existing-dialog or
dialog.Plugin.CheckDialogWindow(hwnd) logic after a successful lookup.
Flow.Launcher.Core/Plugin/PluginInstaller.cs (1)

349-391: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid data race by aggregating task results instead of mutating shared variables.

Mutating the shared local variables anyPluginSuccess and allPluginsHotReloaded concurrently from inside Task.WhenAll creates a data race. The C# memory model does not guarantee memory visibility across threads without synchronization, which could lead to missed updates or stale reads.

  • Flow.Launcher.Core/Plugin/PluginInstaller.cs#L349-L391: Return a tuple (bool success, bool hotReloaded) from the async plugin => lambda and aggregate the results with .Any() and .All() after the WhenAll completes.
  • Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs#L486-L533: Apply the same tuple-aggregation pattern to this concurrent update loop.
🛡️ Proposed fix for `PluginInstaller.cs`
-        var anyPluginSuccess = false;
-        var allPluginsHotReloaded = true;
-        await Task.WhenAll(resultsForUpdate.Select(async plugin =>
+        var updateTasks = resultsForUpdate.Select(async plugin =>
         {
+            bool success = false;
+            bool hotReloaded = true;
             var downloadToFilePath = Path.Combine(Path.GetTempPath(), $"{plugin.Name}-{plugin.NewVersion}.zip");
 
             try
             {
                 using var cts = new CancellationTokenSource();
 
                 await DownloadFileAsync(
                     $"{Localize.DownloadingPlugin()} {plugin.PluginNewUserPlugin.Name}",
                     plugin.PluginNewUserPlugin.UrlDownload, downloadToFilePath, cts);
 
                 // check if user cancelled download before installing plugin
                 if (cts.IsCancellationRequested)
                 {
-                    allPluginsHotReloaded = false;
-                    return;
+                    hotReloaded = false;
+                    return (success, hotReloaded);
                 }
 
                 if (!await PublicApi.Instance.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, downloadToFilePath))
                 {
-                    allPluginsHotReloaded = false;
-                    return;
+                    hotReloaded = false;
+                    return (success, hotReloaded);
                 }
 
-                anyPluginSuccess = true;
+                success = true;
 
                 if (!Settings.HotReloadAfterChanging || !await PluginManager.ReloadPluginAsync(plugin.ID))
                 {
-                    allPluginsHotReloaded = false;
+                    hotReloaded = false;
                 }
             }
             catch (Exception e)
             {
-                allPluginsHotReloaded = false;
+                hotReloaded = false;
                 PublicApi.Instance.LogException(ClassName, "Failed to update plugin", e);
                 PublicApi.Instance.ShowMsgError(Localize.ErrorUpdatingPlugin());
             }
-        }));
+            return (success, hotReloaded);
+        });
+
+        var results = await Task.WhenAll(updateTasks);
+        var anyPluginSuccess = results.Any(r => r.success);
+        var allPluginsHotReloaded = results.All(r => r.hotReloaded);
 
         if (!anyPluginSuccess) return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Flow.Launcher.Core/Plugin/PluginInstaller.cs` around lines 349 - 391, Replace
the shared anyPluginSuccess and allPluginsHotReloaded mutations in
PluginInstaller.cs lines 349-391 with tuple results returned by each async
plugin lambda, then aggregate the completed WhenAll results using Any() and
All(). Apply the same tuple-aggregation pattern to the concurrent update loop in
PluginsManager.cs lines 486-533, preserving each operation’s existing success
and hot-reload outcomes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Flow.Launcher.Core/Plugin/PluginManager.cs`:
- Around line 503-515: Extend the initialization transaction in InitAsync to
cover RegisterResultsUpdatedEvent, UpdatePluginMetadataTranslation,
InitializeDialogJumpPlugin, and AddPluginToLists, catching failures and
returning false instead of propagating exceptions. On any post-init failure,
roll back every registration or list insertion performed for the plugin so
reload can restart cleanly.
- Around line 143-146: Use a single per-plugin lifecycle gate from _reloadLocks
for ReloadPluginAsync, UninstallPluginAsync, and the update flow. Add
non-locking internal implementations for these operations, and have each public
entry point acquire the plugin-specific semaphore before invoking its internal
method, ensuring concurrent reload, update, and uninstall calls cannot mutate
shared plugin state or files simultaneously.
- Around line 195-203: Update Flow.Launcher.Core/Plugin/PluginManager.cs lines
195-203 in ReloadAllPluginsAsync to collect failed and skipped plugin IDs
instead of discarding ReloadPluginAsync results, then return an aggregate
success or structured reload summary. Update
Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs lines 618-622 to expose the
matching Task<bool> or structured summary contract so callers can report partial
failures.
- Around line 226-252: Update the plugin removal flow around
RemovePluginFromLists and DisposePluginAsync to quiesce the plugin before
disposal: use a per-plugin execution gate to cancel and await all active query
and event invocations holding pair, then proceed with DisposePluginAsync,
pair.Plugin = null, and unloading. Ensure new invocations are blocked during
shutdown and no active call can access the instance after disposal begins.
- Around line 1268-1273: Update the plugin installation flow around the
plugin.json deserialization to require exact, case-sensitive equality between
the manifest ID and the requested plugin ID, using the existing plugin ID
validation path. Reject and abort the installation before any files are copied
when they differ; do not merely skip the _pendingInstallPaths update or use
StringComparison.OrdinalIgnoreCase.
- Around line 468-500: In the initialization failure catch block of the plugin
registration flow, unregister the action keywords previously added by
RegisterPluginActionKeywords(pair) before returning false. Reuse the existing
keyword-unregistration mechanism and ensure cleanup occurs for every InitAsync
failure while preserving the disabled-plugin bookkeeping.

In `@Flow.Launcher.Core/Plugin/PluginsLoader.cs`:
- Around line 96-138: Declare the PluginAssemblyLoader used in the loading block
outside the try so it remains available to failure handling. Initialize it
before LoadAssemblyAndDependencies, and ensure every non-debug exception path
unloads it when loading or plugin creation fails, while preserving the existing
logging and successful PluginManager.TrackAssemblyLoader behavior.

---

Outside diff comments:
In `@Flow.Launcher.Core/Plugin/PluginInstaller.cs`:
- Around line 349-391: Replace the shared anyPluginSuccess and
allPluginsHotReloaded mutations in PluginInstaller.cs lines 349-391 with tuple
results returned by each async plugin lambda, then aggregate the completed
WhenAll results using Any() and All(). Apply the same tuple-aggregation pattern
to the concurrent update loop in PluginsManager.cs lines 486-533, preserving
each operation’s existing success and hot-reload outcomes.

In `@Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs`:
- Around line 559-569: The dialog lookup paths must handle concurrent plugin
removal before accessing the plugin. In both DialogJump.cs sites (anchor lines
559-569 and sibling lines 981-991), capture the boolean result from
_dialogJumpDialogs.TryGetValue and continue the enclosing iteration when it
returns false; only use the existing-dialog or
dialog.Plugin.CheckDialogWindow(hwnd) logic after a successful lookup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d24710f-2402-4291-bbef-f067426d3610

📥 Commits

Reviewing files that changed from the base of the PR and between 07a958d and 58d6101.

📒 Files selected for processing (23)
  • Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs
  • Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs
  • Flow.Launcher.Core/Plugin/PluginConfig.cs
  • Flow.Launcher.Core/Plugin/PluginInstaller.cs
  • Flow.Launcher.Core/Plugin/PluginManager.cs
  • Flow.Launcher.Core/Plugin/PluginsLoader.cs
  • Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs
  • Flow.Launcher.Infrastructure/UserSettings/Settings.cs
  • Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
  • Flow.Launcher.Test/PluginHotReloadTest.cs
  • Flow.Launcher/Flow.Launcher.csproj
  • Flow.Launcher/Languages/en.xaml
  • Flow.Launcher/PublicAPIInstance.cs
  • Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml
  • Flow.Launcher/ViewModel/MainViewModel.cs
  • Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml
  • Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs
  • Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs
  • Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs
  • Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml
  • Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml
  • Plugins/Flow.Launcher.Plugin.Sys/Main.cs
  • Plugins/Flow.Launcher.Plugin.Sys/Settings.cs

Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs
Comment on lines +195 to +203
public static async Task ReloadAllPluginsAsync()
{
foreach (var pair in GetAllLoadedPlugins())
{
var id = pair.Metadata.ID;
if (PluginModified(id)) continue;

await ReloadPluginAsync(id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Report partial failures from “Reload All.”

The implementation discards each reload result, while the public contract exposes no aggregate outcome.

  • Flow.Launcher.Core/Plugin/PluginManager.cs#L195-L203: aggregate failed and skipped plugin IDs instead of ignoring the returned boolean.
  • Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs#L618-L622: return Task<bool> or a structured reload summary so callers can report the correct status.
📍 Affects 2 files
  • Flow.Launcher.Core/Plugin/PluginManager.cs#L195-L203 (this comment)
  • Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs#L618-L622
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Flow.Launcher.Core/Plugin/PluginManager.cs` around lines 195 - 203, Update
Flow.Launcher.Core/Plugin/PluginManager.cs lines 195-203 in
ReloadAllPluginsAsync to collect failed and skipped plugin IDs instead of
discarding ReloadPluginAsync results, then return an aggregate success or
structured reload summary. Update Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
lines 618-622 to expose the matching Task<bool> or structured summary contract
so callers can report partial failures.

Comment on lines +226 to +252
// Removing the plugin from the initialized list first makes in-flight queries treat it as
// still initializing, so they show a placeholder result instead of touching a dead instance
RemovePluginFromLists(id);
UnregisterPluginActionKeywords(id);
_register?.UnregisterResultsUpdatedEvent(pair);
DialogJump.RemoveDialogJumpPlugin(pair);
_allLoadedPlugins.TryRemove(id, out _);

// For JsonRPC V2 plugins this kills the child process, releasing its file locks
await DisposePluginAsync(pair);

if (!_assemblyLoaders.TryRemove(id, out var loader)) return true;

// Persist and evict the Type-keyed storages that would otherwise pin the collectible load context
PublicApi.Instance.SavePluginSettings();
PublicApi.Instance.SavePluginCaches();
if (PublicApi.Instance is IRemovable removable)
{
removable.RemovePluginSettings(metadata.AssemblyName);
removable.RemovePluginCaches(metadata.PluginCacheDirectoryPath);
}

pair.Plugin = null;

var weakReference = PluginAssemblyLoader.UnloadAndGetWeakReference(loader);
loader = null;
var unloaded = await PluginAssemblyLoader.WaitForUnloadAsync(weakReference);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Quiesce active plugin calls before disposal.

Removing the pair from registries only prevents new lookups. Queries already holding pair can still execute against the disposed instance or race with pair.Plugin = null, causing failures during reload.

Cancel and await active query/event invocations through a per-plugin execution gate before disposal and unloading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Flow.Launcher.Core/Plugin/PluginManager.cs` around lines 226 - 252, Update
the plugin removal flow around RemovePluginFromLists and DisposePluginAsync to
quiesce the plugin before disposal: use a per-plugin execution gate to cancel
and await all active query and event invocations holding pair, then proceed with
DisposePluginAsync, pair.Plugin = null, and unloading. Ensure new invocations
are blocked during shutdown and no active call can access the instance after
disposal begins.

Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs
Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs Outdated
Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs Outdated
Comment on lines +96 to +138
try
{
var assemblyLoader = new PluginAssemblyLoader(metadata.ExecuteFilePath);
assembly = assemblyLoader.LoadAssemblyAndDependencies();

var type = assemblyLoader.FromAssemblyGetTypeOfInterface(assembly,
typeof(IAsyncPlugin));

plugin = Activator.CreateInstance(type) as IAsyncPlugin;

metadata.AssemblyName = assembly.GetName().Name;

// Keep the load context around so the assembly can be unloaded when the plugin is
// reloaded or uninstalled
PluginManager.TrackAssemblyLoader(metadata.ID, assemblyLoader);
}
#if DEBUG
catch (Exception)
{
throw;
}
#else
catch (Exception e) when (assembly == null)
{
PublicApi.Instance.LogException(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e);
}
catch (InvalidOperationException e)
{
PublicApi.Instance.LogException(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e);
}
catch (ReflectionTypeLoadException e)
{
PublicApi.Instance.LogException(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e);
}
#endif

if (plugin == null) return;

pair = new PluginPair { Plugin = plugin, Metadata = metadata };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline Flow.Launcher.Core/Plugin/PluginsLoader.cs \
  --match DotNetPlugin --view expanded

rg -n -C4 \
  'new PluginAssemblyLoader|TrackAssemblyLoader|UnloadAndGetWeakReference|\.Unload\(\)' \
  Flow.Launcher.Core Flow.Launcher.Test

Repository: Flow-Launcher/Flow.Launcher

Length of output: 2079


🌐 Web query:

.NET collectible AssemblyLoadContext unload occurs on GC file lock official docs

💡 Result:

In.NET, the unloading of a collectible AssemblyLoadContext (ALC) is a cooperative process, not an immediate or forced one [1]. When you call AssemblyLoadContext.Unload, it merely initiates the request to unload; the actual release of resources—including file locks—occurs only after the Garbage Collector (GC) successfully collects the ALC and all its associated objects [1][2]. Key points regarding the interaction between unloading, the GC, and file locks: 1. Cooperative Unloading: The ALC cannot be unloaded as long as there are any active references to it, or to any types, instances, or assemblies loaded within that context [1][3]. This includes objects on the execution stack, static variables, or strong references held elsewhere in the application [1][4]. 2. File Locks and GC: When an assembly is loaded from a file path (e.g., via LoadFromAssemblyPath), the runtime maps the file into memory, which often results in an OS-level file lock [5]. This lock is not released until the ALC is fully unloaded. Because the unload process depends on the GC to reclaim all references, the file lock will persist until the GC has successfully performed enough collection cycles to fully clean up the context [6][2][7]. 3. Forcing Collection: Because the process is cooperative, there is no API to "force" an immediate unload [4]. To facilitate the release of file locks, you must ensure that all references to objects within the ALC are cleared [1][4]. Developers often use a pattern of calling GC.Collect followed by GC.WaitForPendingFinalizers in a loop to encourage the runtime to complete the collection process [2][7]. 4. Recommended Workarounds: If file locking is a blocking issue (e.g., you need to delete or move the assembly file), a common practice is to load the assembly into memory first using a Stream (via LoadFromStream) rather than directly from a file path [7]. This avoids the OS-level file lock entirely, as the runtime does not maintain a handle to the original file on disk [7]. In summary, the file lock is not a bug but a result of the cooperative unload mechanism. To ensure the lock is released, you must eliminate all strong references to the ALC's contents and allow the GC to finalize the collection [1][3]. If immediate file access is required, loading from a stream is the preferred architectural solution [7].

Citations:


Unload the plugin loader on failure
Declare assemblyLoader outside the try so the failure path can call Unload(). Without that, a failed load can keep the collectible context and plugin file locked until GC runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Flow.Launcher.Core/Plugin/PluginsLoader.cs` around lines 96 - 138, Declare
the PluginAssemblyLoader used in the loading block outside the try so it remains
available to failure handling. Initialize it before LoadAssemblyAndDependencies,
and ensure every non-debug exception path unloads it when loading or plugin
creation fails, while preserving the existing logging and successful
PluginManager.TrackAssemblyLoader behavior.

- DialogJump: skip dialogs removed mid-enumeration instead of probing
  the unloaded plugin, and always clear the cached active dialog window
  when its plugin is removed (it may not be attributable by value)
- Route uninstall through the same per-plugin lifecycle lock as reload
  so concurrent reload/uninstall cannot interleave
- InitializePluginAsync: unregister action keywords when init fails and
  roll back partial registrations when post-init wiring throws
- InstallPlugin: reject packages whose plugin.json ID does not match
  the requested plugin before copying any files
- PluginsLoader: track the assembly load context only after the plugin
  instance is accepted
- ReloadAllPluginsAsync now returns whether every plugin reloaded; the
  Sys command reports failure instead of an unconditional success toast

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml (1)

55-55: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid promising that reload always completes without a restart.

Reload is best-effort; the Sys command can report incomplete reloads and recommend restarting. Update this description to reflect that limitation.

Proposed wording
-    <system:String x:Key="flowlauncher_plugin_sys_reload_all_plugins">Fully reloads all plugins without restarting Flow Launcher</system:String>
+    <system:String x:Key="flowlauncher_plugin_sys_reload_all_plugins">Attempts to reload all plugins without restarting Flow Launcher; a restart may be required</system:String>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml` at line 55, Update the
flowlauncher_plugin_sys_reload_all_plugins resource description to describe
plugin reloading as best-effort, without promising completion, and mention that
restarting Flow Launcher may be recommended when reloads are incomplete.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml`:
- Line 55: Update the flowlauncher_plugin_sys_reload_all_plugins resource
description to describe plugin reloading as best-effort, without promising
completion, and mention that restarting Flow Launcher may be recommended when
reloads are incomplete.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d74d4eb-96ad-49be-bd01-63d812388f81

📥 Commits

Reviewing files that changed from the base of the PR and between 58d6101 and e923518.

📒 Files selected for processing (8)
  • Flow.Launcher.Core/Plugin/PluginManager.cs
  • Flow.Launcher.Core/Plugin/PluginsLoader.cs
  • Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs
  • Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
  • Flow.Launcher/Languages/en.xaml
  • Flow.Launcher/PublicAPIInstance.cs
  • Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml
  • Plugins/Flow.Launcher.Plugin.Sys/Main.cs
🚧 Files skipped from review as they are similar to previous changes (5)
  • Flow.Launcher/PublicAPIInstance.cs
  • Flow.Launcher.Core/Plugin/PluginsLoader.cs
  • Plugins/Flow.Launcher.Plugin.Sys/Main.cs
  • Flow.Launcher/Languages/en.xaml
  • Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs">

<violation number="1" location="Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs:179">
P2: Hot-reloading a plugin can abandon its active dialog wrapper without disposal when the wrapper exists only in `_dialogWindow`. Capture and clear the cached instance under `_dialogWindowLock`, then dispose it (without double-disposing when it is the dictionary value).</violation>

<violation number="2" location="Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs:560">
P1: A reload can still invoke and recache a plugin after it has been removed because `TryGetValue` is only a point-in-time check. Coordinate this callback with removal/plugin lifetime (or revalidate under a shared lock) before calling `CheckDialogWindow`, otherwise old contexts can remain pinned or a disposed plugin can be called.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

IDialogJumpDialogWindow dialogWindow;
var existingDialogWindow = _dialogJumpDialogs[dialog];
// Skip dialogs removed concurrently by a plugin hot reload
if (!_dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A reload can still invoke and recache a plugin after it has been removed because TryGetValue is only a point-in-time check. Coordinate this callback with removal/plugin lifetime (or revalidate under a shared lock) before calling CheckDialogWindow, otherwise old contexts can remain pinned or a disposed plugin can be called.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs, line 560:

<comment>A reload can still invoke and recache a plugin after it has been removed because `TryGetValue` is only a point-in-time check. Coordinate this callback with removal/plugin lifetime (or revalidate under a shared lock) before calling `CheckDialogWindow`, otherwise old contexts can remain pinned or a disposed plugin can be called.</comment>

<file context>
@@ -556,8 +556,8 @@ uint dwmsEventTime
-                    // The dialog can be removed concurrently by a plugin hot reload
-                    _dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow);
+                    // Skip dialogs removed concurrently by a plugin hot reload
+                    if (!_dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow)) continue;
                     if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd)
                     {
</file context>

Comment thread Plugins/Flow.Launcher.Plugin.Sys/Main.cs
// foreground event repopulates it from the remaining dialogs.
lock (_dialogWindowLock)
{
_dialogWindow = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hot-reloading a plugin can abandon its active dialog wrapper without disposal when the wrapper exists only in _dialogWindow. Capture and clear the cached instance under _dialogWindowLock, then dispose it (without double-disposing when it is the dictionary value).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs, line 179:

<comment>Hot-reloading a plugin can abandon its active dialog wrapper without disposal when the wrapper exists only in `_dialogWindow`. Capture and clear the cached instance under `_dialogWindowLock`, then dispose it (without double-disposing when it is the dictionary value).</comment>

<file context>
@@ -169,14 +169,14 @@ public static void RemoveDialogJumpPlugin(PluginPair pair)
-                        {
-                            _dialogWindow = null;
-                        }
+                        _dialogWindow = null;
                     }
 
</file context>

- InstallPlugin: adopt the package's plugin.json ID when the requested
  UserPlugin has no ID (install-from-URL builds one with an empty ID),
  instead of rejecting the install as an ID mismatch
- ReloadPluginAsync: do not clear the modified flag when a concurrent
  install recorded a newer pending version during the reload
- Sys reload command: handle faulted reloads in the continuation (log
  the exception, show the failure message) instead of skipping it
- Skip the delete marker when the plugin directory no longer exists
- Keep the assembly-unload GC wait loop off the UI thread context
- Drop unreachable duplicate-load cleanup now guarded by the lifecycle
  lock, and the unused fromPendingInstall local

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread Flow.Launcher.Core/Plugin/PluginManager.cs
- Sys reload failure toast: pass a short title and the message as the
  subtitle, matching ShowMsgError's signature
- InstallPlugin: record the pending install path before the modified
  flag so a concurrent reload that observes the flag also sees the
  pending path and never clears it prematurely
- Soften the Reload All Plugins description: a restart may still be
  needed if a reload fails

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Flow.Launcher.Core/Plugin/PluginManager.cs">

<violation number="1" location="Flow.Launcher.Core/Plugin/PluginManager.cs:1311">
P2: A concurrent manual/reload-all request can reload this new directory between these two publications, then this install adds a stale modified flag after reload succeeds. Serialize installation publication with the per-plugin reload lock (or make reload recheck/clear after publication) so successful hot reloads do not incorrectly require restart.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// Remember where this version was installed so a hot reload can load it without a
// restart. Recorded before the modified flag so that a concurrent reload observing
// the flag always sees the pending path too and never clears the flag prematurely.
_pendingInstallPaths[plugin.ID] = newPluginPath;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A concurrent manual/reload-all request can reload this new directory between these two publications, then this install adds a stale modified flag after reload succeeds. Serialize installation publication with the per-plugin reload lock (or make reload recheck/clear after publication) so successful hot reloads do not incorrectly require restart.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Flow.Launcher.Core/Plugin/PluginManager.cs, line 1311:

<comment>A concurrent manual/reload-all request can reload this new directory between these two publications, then this install adds a stale modified flag after reload succeeds. Serialize installation publication with the per-plugin reload lock (or make reload recheck/clear after publication) so successful hot reloads do not incorrectly require restart.</comment>

<file context>
@@ -1305,14 +1305,16 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c
+                // Remember where this version was installed so a hot reload can load it without a
+                // restart. Recorded before the modified flag so that a concurrent reload observing
+                // the flag always sees the pending path too and never clears the flag prematurely.
+                _pendingInstallPaths[plugin.ID] = newPluginPath;
+
                 if (checkModified)
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant