Is your feature request related to a problem? Please describe.
When developing plugins, DesktopMagic needs to be fully closed to overwrite a plugin. This results in a frustrating loop of Make Change -> Close DM -> Update plugin file -> Reopen DM -> Repeat
Currently DesktopMagic uses Assembly.LoadFrom to load plugin DLLs. This results in a file lock on the plugin's main.dll, which prevents hot updates.
Describe the solution you'd like
A possible solution seems to be shadow copying, which copies the target DLL to a temp directory and loads the copy, but still uses the original path for binding context. This would allow DesktopMagic to load plugin assemblies without locking the original file, allowing the plugin to be hot-updated by overwriting the file and hitting "Reload" in DM.
A caveat to this is that the article linked in the above SO thread only applies to .NET Framework, and I'm struggling to find resources on shadow copying in general assembly loading on .NET 8+
Describe alternatives you've considered
It seems shadow copying could be implemented manually, though that could have unforeseen issues and inflates the scope of the change. I've also seen mention of 3rd party frameworks/libraries that can accomplish generic dotnet assembly "plugin" loading with hot reload support, such as MEF (Managed Extensibility Framework).
MEF is specific to .NET Framework 4, but modern alternatives may be available.
Originally created by @dylanrenwick on GitHub (Oct 25, 2024).
Original GitHub issue: https://github.com/Stone-Red-Code/DesktopMagic/issues/5
**Is your feature request related to a problem? Please describe.**
When developing plugins, DesktopMagic needs to be fully closed to overwrite a plugin. This results in a frustrating loop of Make Change -> Close DM -> Update plugin file -> Reopen DM -> Repeat
Currently DesktopMagic uses `Assembly.LoadFrom` to load plugin DLLs. This results in a file lock on the plugin's `main.dll`, which prevents hot updates.
**Describe the solution you'd like**
A possible solution seems to be [shadow copying](https://stackoverflow.com/questions/5267106/load-an-assembly-without-locking-file-and-keep-the-right-binding-context), which copies the target DLL to a temp directory and loads the copy, but still uses the original path for binding context. This would allow DesktopMagic to load plugin assemblies without locking the original file, allowing the plugin to be hot-updated by overwriting the file and hitting "Reload" in DM.
A caveat to this is that the article linked in the above SO thread only applies to .NET Framework, and I'm struggling to find resources on shadow copying in general assembly loading on .NET 8+
**Describe alternatives you've considered**
It seems shadow copying could be implemented manually, though that could have unforeseen issues and inflates the scope of the change. I've also seen mention of 3rd party frameworks/libraries that can accomplish generic dotnet assembly "plugin" loading with hot reload support, such as MEF (Managed Extensibility Framework).
MEF is specific to .NET Framework 4, but modern alternatives may be available.
Stone_Red
added the bug label 2026-09-04 00:45:50 +02:00
@Stone-Red-Code commented on GitHub (Oct 25, 2024):
Hi there,
I'm actually aware of this, it's also a problem when you are trying to uninstall a loaded plugin because DM can't delete the files.
This is a problem since the last updated that fixed another issue causing plugin dependencies to not being loaded.
Before that update, the DLL files got loaded into memory, so the file wouldn't be locked. But since that prevented dependencies from loading correctly, I had to use another method that locks the files.
I also looked into shadow copying, but wasn't able to find a reasonable way to implement it.
As you mentioned, a third-party library is likely the best option. This probably would also simplify the whole plugin system, but would obviously require a rewrite of that system.
<!-- gh-comment-id:2438622274 -->
@Stone-Red-Code commented on GitHub (Oct 25, 2024):
Hi there,
I'm actually aware of this, it's also a problem when you are trying to uninstall a loaded plugin because DM can't delete the files.
This is a problem since the last updated that fixed another issue causing plugin dependencies to not being loaded.
Before that update, the DLL files got loaded into memory, so the file wouldn't be locked. But since that prevented dependencies from loading correctly, I had to use another method that locks the files.
I also looked into shadow copying, but wasn't able to find a reasonable way to implement it.
As you mentioned, a third-party library is likely the best option. This probably would also simplify the whole plugin system, but would obviously require a rewrite of that system.
<!-- gh-comment-id:2438630254 -->
@Stone-Red-Code commented on GitHub (Oct 25, 2024):
I will try to find a library that would work with the DM plugin system and try to implement it as soon as possible.
[DotNetCorePlugins](https://github.com/natemcmaster/DotNetCorePlugins) looks promising.
Loading the file into memory to avoid locking the file-on-disk was my first thought, so it's good to know that's been tried and ruled out. I hadn't considered plugin dependencies.
I'll do some research into and testing of shadow copy implementations and/or third-party libraries and see if I can find a good solution for DesktopMagic. DotNetCorePlugins does look very promising, hopefully it lives up to that promise!
I'm excited about the prospect of a C#/dotnet oriented replacement for Rainmeter (who thought object-oriented ini files were a good idea?) so I'm looking to contribute a bit 😄
<!-- gh-comment-id:2438646001 -->
@dylanrenwick commented on GitHub (Oct 25, 2024):
Loading the file into memory to avoid locking the file-on-disk was my first thought, so it's good to know that's been tried and ruled out. I hadn't considered plugin dependencies.
I'll do some research into and testing of shadow copy implementations and/or third-party libraries and see if I can find a good solution for DesktopMagic. DotNetCorePlugins does look very promising, hopefully it lives up to that promise!
I'm excited about the prospect of a C#/dotnet oriented replacement for Rainmeter (who thought object-oriented ini files were a good idea?) so I'm looking to contribute a bit 😄
@Stone-Red-Code commented on GitHub (Oct 29, 2024):
It seems like the maintainer of DotNetCorePlugins doesn't want to maintain the library anymore and is considering archiving the repository. So that doesn't seem like a viable option.
The biggest problem with loading assemblies in .NET is unloading them.
The AssemblyLoadContext class should theoretically solve this problem, but I haven't played around with it yet.
Another option would be to load the plugins into a separate process that communicates with IPC with the main process.
This would make it very easy to unload the assembly because we can just stop/kill the process if required. And it would also solve some performance issues that are caused by WPF because it requires all UI interactions to run on the main UI thread. (The main window and other plugins can stutter when plugins are updating the Bitmap)
Both of these ideas would require some form of shadow copying because of the Assembly.Load method would still lock the files.
<!-- gh-comment-id:2444569917 -->
@Stone-Red-Code commented on GitHub (Oct 29, 2024):
It seems like the maintainer of DotNetCorePlugins doesn't want to maintain the library anymore and is considering archiving the repository. So that doesn't seem like a viable option.
The biggest problem with loading assemblies in .NET is unloading them.
The `AssemblyLoadContext` class should theoretically solve this problem, but I haven't played around with it yet.
Another option would be to load the plugins into a separate process that communicates with IPC with the main process.
This would make it very easy to unload the assembly because we can just stop/kill the process if required. And it would also solve some performance issues that are caused by WPF because it requires all UI interactions to run on the main UI thread. (The main window and other plugins can stutter when plugins are updating the `Bitmap`)
Both of these ideas would require some form of shadow copying because of the `Assembly.Load` method would still lock the files.
It's looking to me like we may need to implement shadow copying ourselves. I'm not finding many good options that are still maintained.
I'll take a stab over the weekend at implementing a standalone lib that can load an Assembly from a file without locking it, whilst still maintaining proper Load Context and dependencies
<!-- gh-comment-id:2451852297 -->
@dylanrenwick commented on GitHub (Nov 1, 2024):
It's looking to me like we may need to implement shadow copying ourselves. I'm not finding many good options that are still maintained.
I'll take a stab over the weekend at implementing a standalone lib that can load an `Assembly` from a file without locking it, whilst still maintaining proper Load Context and dependencies
After some brief testing, it seems the solution may actually be as simple as copying the target file before loading it.
I created a simple proof-of-concept here to demonstrate this.
All it does is copy the .dll we want to load to a local _shadow directory before loading it. To my surprise this doesn't seem to break dependency resolution at all, and even appears to avoid locking loaded dependencies (Dependency.dll was not locked, even before the test project itself depended on it)
If you can think of a more complex real world use case/edge case that this PoC doesn't cover let me know and I'll add it to the test suite.
<!-- gh-comment-id:2452013520 -->
@dylanrenwick commented on GitHub (Nov 1, 2024):
After some brief testing, it seems the solution may actually be as simple as copying the target file before loading it.
I created a simple proof-of-concept [here](https://github.com/dylanrenwick/PluginLoader) to demonstrate this.
All it does is copy the `.dll` we want to load to a local `_shadow` directory before loading it. To my surprise this doesn't seem to break dependency resolution at all, and even appears to avoid locking loaded dependencies (`Dependency.dll` was not locked, even before the test project itself depended on it)
If you can think of a more complex real world use case/edge case that this PoC doesn't cover let me know and I'll add it to the test suite.
Additionally, it looks like if we load each plugin into its own AssemblyLoadContext and ensure they're set to collectible when created, we can unload plugins at runtime with AssemblyLoadContext.Unload().
Only whole LoadContexts can be unloaded though, not individual plugins, so each plugin would need its own LoadContext. I don't think this would cause problems, but it may warrant further investigation.
<!-- gh-comment-id:2452058944 -->
@dylanrenwick commented on GitHub (Nov 1, 2024):
Additionally, it looks like if we load each plugin into its own `AssemblyLoadContext` and ensure they're set to collectible when created, we can unload plugins at runtime with `AssemblyLoadContext.Unload()`.
Only whole LoadContexts can be unloaded though, not individual plugins, so each plugin would need its own LoadContext. I don't _think_ this would cause problems, but it may warrant further investigation.
@Stone-Red-Code commented on GitHub (Nov 1, 2024):
Your PoC looks good, but are the dependencies not copied into the _shadow directory?
When removing the Dependency project from the project references of the PluginDotNet.Test project, the DependentPlugin.dll fails to load because it can't find Dependency.dll
Additionally, it looks like if we load each plugin into its own AssemblyLoadContext and ensure they're set to collectible when created, we can unload plugins at runtime with AssemblyLoadContext.Unload(). Only whole LoadContexts can be unloaded though, not individual plugins, so each plugin would need its own LoadContext. I don't think this would cause problems, but it may warrant further investigation.
Yeah, that's how the AssemblyLoadContext is supposed to be used. Loading each plugin into its own AssemblyLoadContext shouldn't be a problem.
<!-- gh-comment-id:2452609133 -->
@Stone-Red-Code commented on GitHub (Nov 1, 2024):
Your PoC looks good, but are the dependencies not copied into the `_shadow` directory?
When removing the `Dependency` project from the project references of the `PluginDotNet.Test` project, the `DependentPlugin.dll` fails to load because it can't find `Dependency.dll`
> Additionally, it looks like if we load each plugin into its own `AssemblyLoadContext` and ensure they're set to collectible when created, we can unload plugins at runtime with `AssemblyLoadContext.Unload()`. Only whole LoadContexts can be unloaded though, not individual plugins, so each plugin would need its own LoadContext. I don't _think_ this would cause problems, but it may warrant further investigation.
Yeah, that's how the `AssemblyLoadContext` is supposed to be used. Loading each plugin into its own `AssemblyLoadContext ` shouldn't be a problem.
That's odd. I had run the DependencyPlugin test before adding the project reference and it had worked fine. I added the project reference solater that I could add the interface check.
I admittedly didn't check if the dependencies were also copied into _shadow, though I don't see that being an issue as we would probably need the dependencies shadow copied as well anyway, or at the very least not locked.
For that reason it may also make sense to give each loaded plugin its own subdirectory, to avoid file conflicts.
My one concern with this approach 8s shared dependencies, ie if multiple plugins reference the same dependency (is each plugin going to need its own PluginAPI assembly?)
I'll do some more testing and double check my results.
<!-- gh-comment-id:2452639209 -->
@dylanrenwick commented on GitHub (Nov 1, 2024):
That's odd. I had run the `DependencyPlugin` test before adding the project reference and it had worked fine. I added the project reference solater that I could add the interface check.
I admittedly didn't check if the dependencies were also copied into `_shadow`, though I don't see that being an issue as we would probably need the dependencies shadow copied as well anyway, or at the very least not locked.
For that reason it may also make sense to give each loaded plugin its own subdirectory, to avoid file conflicts.
My one concern with this approach 8s shared dependencies, ie if multiple plugins reference the same dependency (is each plugin going to need its own PluginAPI assembly?)
I'll do some more testing and double check my results.
Running some further tests I was able to confirm your results; that the DependencyPlugin test fails if the test suite doesn't itself has a reference to the dependency.
Unfortunately I can't see a great way around this as there doesn't seem to be a good way to detect an assembly's dependencies before loading in a way that allows us to shadow copy and load them as well.
I'm going to keep investigating this, and look into how DotNetCorePlugins handled dependencies, but in the meantime an interrim solution may be to update the current plugin loading to use the AssemblyLoadContext approach, and provide an option on the UI to unload a plugin without having to restart DM itself.
This would still be an improvement over the current development cycle as you would just need to unload a single plugin, update the files, then reload
<!-- gh-comment-id:2465015980 -->
@dylanrenwick commented on GitHub (Nov 8, 2024):
Running some further tests I was able to confirm your results; that the `DependencyPlugin` test fails if the test suite doesn't itself has a reference to the dependency.
Unfortunately I can't see a great way around this as there doesn't seem to be a good way to detect an assembly's dependencies before loading in a way that allows us to shadow copy and load them as well.
I'm going to keep investigating this, and look into how DotNetCorePlugins handled dependencies, but in the meantime an interrim solution may be to update the current plugin loading to use the `AssemblyLoadContext` approach, and provide an option on the UI to unload a plugin without having to restart DM itself.
This would still be an improvement over the current development cycle as you would just need to unload a single plugin, update the files, then reload
@Stone-Red-Code commented on GitHub (Nov 12, 2024):
Thanks for looking into this.
Can't we just load the dependencies into the AssemblyLoadContext? If I understood that correctly AssemblyLoadContexts don't share dependencies by default.
<!-- gh-comment-id:2471642455 -->
@Stone-Red-Code commented on GitHub (Nov 12, 2024):
Thanks for looking into this.
Can't we just load the dependencies into the `AssemblyLoadContext`? If I understood that correctly `AssemblyLoadContext`s don't share dependencies by default.
We can. My concern is that we don't know what dependencies a plugin has without loading it, so when shadow copying the plugin assembly we don't know what dependencies also need to be shadow copied.
If we shadow copy the plugin assembly and not its dependencies, the library will fail to load with unresolved dependencies.
<!-- gh-comment-id:2476459877 -->
@dylanrenwick commented on GitHub (Nov 14, 2024):
We can. My concern is that we don't know what dependencies a plugin has without loading it, so when shadow copying the plugin assembly we don't know what dependencies also need to be shadow copied.
If we shadow copy the plugin assembly and not its dependencies, the library will fail to load with unresolved dependencies.
@Stone-Red-Code commented on GitHub (Nov 14, 2024):
Well the it's already a requirement that all plugins have to include all the necessary dependencies in the zip file/plugin directory.
So we can just copy the whole directory and load everything necessary.
<!-- gh-comment-id:2476502775 -->
@Stone-Red-Code commented on GitHub (Nov 14, 2024):
Well the it's already a requirement that all plugins have to include all the necessary dependencies in the zip file/plugin directory.
So we can just copy the whole directory and load everything necessary.
@Stone-Red-Code commented on GitHub (Dec 9, 2024):
Hi, any updates?
If not, I will implement a temporary solution to at least make plugin development less frustrating.
<!-- gh-comment-id:2528135244 -->
@Stone-Red-Code commented on GitHub (Dec 9, 2024):
Hi, any updates?
If not, I will implement a temporary solution to at least make plugin development less frustrating.
Hi, sorry I haven't had much time to work on it the last couple of weeks with holidays coming up.
I have a local fork that isolates each plugin assembly into its own AssemblyLoadContext, and loads the assemblies from memory instead of from file.
This prevents the files being locked, and allows DM to reload the plugin assembly from file each time it's enabled, making development smoother.
However, when unloading the load contexts on plugin disable, the load context isn't being cleaned up properly and I haven't been successful in figuring out why. This means each time you toggle a plugin the fork leaks around 80kb of memory in the form of an orphaned load context.
<!-- gh-comment-id:2538845568 -->
@dylanrenwick commented on GitHub (Dec 12, 2024):
Hi, sorry I haven't had much time to work on it the last couple of weeks with holidays coming up.
I have a local fork that isolates each plugin assembly into its own `AssemblyLoadContext`, and loads the assemblies from memory instead of from file.
This prevents the files being locked, and allows DM to reload the plugin assembly from file each time it's enabled, making development smoother.
However, when unloading the load contexts on plugin disable, the load context isn't being cleaned up properly and I haven't been successful in figuring out why. This means each time you toggle a plugin the fork leaks around 80kb of memory in the form of an orphaned load context.
@Stone-Red-Code commented on GitHub (Dec 12, 2024):
No worries
After looking through the plugin code, I noticed multiple possible references that could prevent the assembly from unloading.
There's the PluginData class that gets passed to the plugin, but the bigger problem is probably the references to the Settings objects.
DM has a reference to each Setting object that's defined in the Plugin class.
BTW is your fork on GH up to date?
<!-- gh-comment-id:2539654880 -->
@Stone-Red-Code commented on GitHub (Dec 12, 2024):
No worries
After looking through the plugin code, I noticed multiple possible references that could prevent the assembly from unloading.
There's the `PluginData` class that gets passed to the plugin, but the bigger problem is probably the references to the `Setting`s objects.
DM has a reference to each `Setting` object that's defined in the `Plugin` class.
BTW is your fork on GH up to date?
It wasn't up to date, but I just pushed my changes to it.
I did catch the Setting references, and tried to encapsulate that in my load/unload logic.
dotSpy doesn't find any references to the AssemblyLoadContext instances, but they don't get cleaned up by GC regardless.
<!-- gh-comment-id:2554506474 -->
@dylanrenwick commented on GitHub (Dec 19, 2024):
It wasn't up to date, but I just pushed my changes to it.
I did catch the `Setting` references, and tried to encapsulate that in my load/unload logic.
dotSpy doesn't find any references to the `AssemblyLoadContext` instances, but they don't get cleaned up by GC regardless.
Finally found the time to revisit this.
Looking back through my fork changes, I had separated the actual plugin loading logic out into its own class, because IMO it's not ideal to have that logic directly in the WPF code-behind (for best practice reasons).
The isolated PluginLoader class also uses AssemblyLoadContext over Assembly.Load as discussed above, isolating each plugin into its own context.
I've been running this fork for a while at work now, though I've only been running the built-in date/time and CPU usage plugins, alongside my own CPU/RAM usage bars plugin.
If you're on-board with the separation of concerns with plugin loading, I'll do some more thorough testing with other publicly available plugins to make sure everything's working as expected, and submit a PR. If you're unsure but want to look through the changes I can still submit a draft PR for your review.
Notably I have yet to track down the cause of the load context memory leak (AssemblyLoadContext instances are not being GC'd despite no detectable references to them), but given that's only a concern when unloading a plugin and is still appropriately cleaned up when DM is closed, my suggestion would be that we merge the changes to resolve this issue, and create a new issue to track investigation of the memory leak. As far as I can tell it looks like the leak is on the order of a few hundred bytes at most each time a plugin is unloaded which, while not ideal, isn't a major concern IMO.
Let me know how you want to proceed.
<!-- gh-comment-id:2682183707 -->
@dylanrenwick commented on GitHub (Feb 25, 2025):
Finally found the time to revisit this.
Looking back through my fork changes, I had separated the actual plugin loading logic out into its own class, because IMO it's not ideal to have that logic directly in the WPF code-behind (for best practice reasons).
The isolated PluginLoader class also uses `AssemblyLoadContext` over `Assembly.Load` as discussed above, isolating each plugin into its own context.
I've been running this fork for a while at work now, though I've only been running the built-in date/time and CPU usage plugins, alongside my own CPU/RAM usage bars plugin.
If you're on-board with the separation of concerns with plugin loading, I'll do some more thorough testing with other publicly available plugins to make sure everything's working as expected, and submit a PR. If you're unsure but want to look through the changes I can still submit a draft PR for your review.
Notably I have yet to track down the cause of the load context memory leak (`AssemblyLoadContext` instances are not being GC'd despite no detectable references to them), but given that's only a concern when unloading a plugin and is still appropriately cleaned up when DM is closed, my suggestion would be that we merge the changes to resolve this issue, and create a new issue to track investigation of the memory leak. As far as I can tell it looks like the leak is on the order of a few hundred bytes at most each time a plugin is unloaded which, while not ideal, isn't a major concern IMO.
Let me know how you want to proceed.
@Stone-Red-Code commented on GitHub (Feb 25, 2025):
Looking back through my fork changes, I had separated the actual plugin loading logic out into its own class, because IMO it's not ideal to have that logic directly in the WPF code-behind (for best practice reasons).
Yeah, that does indeed make sense, I mean some code in this project is ~5 years old. Some refactoring won't hurt. xD
If you're on-board with the separation of concerns with plugin loading, I'll do some more thorough testing with other publicly available plugins to make sure everything's working as expected, and submit a PR. If you're unsure but want to look through the changes I can still submit a draft PR for your review.
If you can get the Media Display plugin to load and work as intended, then your implementation should be fine.
And yeah, just submit a draft PR.
As far as I can tell it looks like the leak is on the order of a few hundred bytes at most each time a plugin is unloaded which, while not ideal, isn't a major concern IMO.
That's also what I would have suggested. The memory leak probably isn't a big deal.
So, do some more testing and submit a PR whenever you’re good to go.
<!-- gh-comment-id:2682944883 -->
@Stone-Red-Code commented on GitHub (Feb 25, 2025):
> Looking back through my fork changes, I had separated the actual plugin loading logic out into its own class, because IMO it's not ideal to have that logic directly in the WPF code-behind (for best practice reasons).
Yeah, that does indeed make sense, I mean some code in this project is ~5 years old. Some refactoring won't hurt. xD
> If you're on-board with the separation of concerns with plugin loading, I'll do some more thorough testing with other publicly available plugins to make sure everything's working as expected, and submit a PR. If you're unsure but want to look through the changes I can still submit a draft PR for your review.
If you can get the [Media Display](https://mod.io/g/desktopmagic/m/media-display1) plugin to load and work as intended, then your implementation should be fine.
And yeah, just submit a draft PR.
> As far as I can tell it looks like the leak is on the order of a few hundred bytes at most each time a plugin is unloaded which, while not ideal, isn't a major concern IMO.
That's also what I would have suggested. The memory leak probably isn't a big deal.
So, do some more testing and submit a PR whenever you’re good to go.
I've been trying to find the free time to test this at home for a few weeks now, early march has been kinda crazy.
I have carved out some time tonight to give it a proper test though.
Unfortunately I mainly use DM at work, in a VM with no audio output or graphics device, so I've been trying to find the opportunity to test it at home on my desktop.
I'll return tonight or tomorrow morning with the test results. I imagine it should all work especially with your dependency fixes, but best to make sure.
<!-- gh-comment-id:2752352393 -->
@dylanrenwick commented on GitHub (Mar 25, 2025):
Ah nice catch!
I've been trying to find the free time to test this at home for a few weeks now, early march has been kinda crazy.
I have carved out some time tonight to give it a proper test though.
Unfortunately I mainly use DM at work, in a VM with no audio output or graphics device, so I've been trying to find the opportunity to test it at home on my desktop.
I'll return tonight or tomorrow morning with the test results. I imagine it should all work especially with your dependency fixes, but best to make sure.
Ran some tests with the media display plugin and everything seemed to work fine. I was able to play audio and video files with no issues.
I'll commit your dependency fixes into my fork and submit a PR today
<!-- gh-comment-id:2754304917 -->
@dylanrenwick commented on GitHub (Mar 26, 2025):
Ran some tests with the media display plugin and everything seemed to work fine. I was able to play audio and video files with no issues.
I'll commit your dependency fixes into my fork and submit a PR today
I ran some more tests before opening the PR, and Media Display Plugin wasn't working. It worked fine on the main branch of DM, but not my fork. It displays fine, but won't detect any media playing. I haven't had the chance to look into it too much yet but I'm going to have a good deal more free time after next week so I'm planning to investigate then.
<!-- gh-comment-id:2797017614 -->
@dylanrenwick commented on GitHub (Apr 11, 2025):
I ran some more tests before opening the PR, and Media Display Plugin wasn't working. It worked fine on the main branch of DM, but not my fork. It displays fine, but won't detect any media playing. I haven't had the chance to look into it too much yet but I'm going to have a good deal more free time after next week so I'm planning to investigate then.
<!-- gh-comment-id:3676990672 -->
@Stone-Red-Code commented on GitHub (Dec 19, 2025):
My implementation in https://github.com/Stone-Red-Code/DesktopMagic/commit/780b5061039c40d0db9d7e32255c81dc462d6da5 seems to work and is surprisingly simple.
Implementation looks solid and close to what I had! I might give it a peek with dotPeek one day to see if that memory leak is still there, but as we discussed before it's a very minor and slow leak if it even is there.
Either way this change solves my original issue!
<!-- gh-comment-id:3677078767 -->
@dylanrenwick commented on GitHub (Dec 20, 2025):
Implementation looks solid and close to what I had! I might give it a peek with dotPeek one day to see if that memory leak is still there, but as we discussed before it's a very minor and slow leak if it even is there.
Either way this change solves my original issue!
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Originally created by @dylanrenwick on GitHub (Oct 25, 2024).
Original GitHub issue: https://github.com/Stone-Red-Code/DesktopMagic/issues/5
Is your feature request related to a problem? Please describe.
When developing plugins, DesktopMagic needs to be fully closed to overwrite a plugin. This results in a frustrating loop of Make Change -> Close DM -> Update plugin file -> Reopen DM -> Repeat
Currently DesktopMagic uses
Assembly.LoadFromto load plugin DLLs. This results in a file lock on the plugin'smain.dll, which prevents hot updates.Describe the solution you'd like
A possible solution seems to be shadow copying, which copies the target DLL to a temp directory and loads the copy, but still uses the original path for binding context. This would allow DesktopMagic to load plugin assemblies without locking the original file, allowing the plugin to be hot-updated by overwriting the file and hitting "Reload" in DM.
A caveat to this is that the article linked in the above SO thread only applies to .NET Framework, and I'm struggling to find resources on shadow copying in general assembly loading on .NET 8+
Describe alternatives you've considered
It seems shadow copying could be implemented manually, though that could have unforeseen issues and inflates the scope of the change. I've also seen mention of 3rd party frameworks/libraries that can accomplish generic dotnet assembly "plugin" loading with hot reload support, such as MEF (Managed Extensibility Framework).
MEF is specific to .NET Framework 4, but modern alternatives may be available.
@Stone-Red-Code commented on GitHub (Oct 25, 2024):
Hi there,
I'm actually aware of this, it's also a problem when you are trying to uninstall a loaded plugin because DM can't delete the files.
This is a problem since the last updated that fixed another issue causing plugin dependencies to not being loaded.
Before that update, the DLL files got loaded into memory, so the file wouldn't be locked. But since that prevented dependencies from loading correctly, I had to use another method that locks the files.
I also looked into shadow copying, but wasn't able to find a reasonable way to implement it.
As you mentioned, a third-party library is likely the best option. This probably would also simplify the whole plugin system, but would obviously require a rewrite of that system.
@Stone-Red-Code commented on GitHub (Oct 25, 2024):
I will try to find a library that would work with the DM plugin system and try to implement it as soon as possible.
DotNetCorePlugins looks promising.
@dylanrenwick commented on GitHub (Oct 25, 2024):
Loading the file into memory to avoid locking the file-on-disk was my first thought, so it's good to know that's been tried and ruled out. I hadn't considered plugin dependencies.
I'll do some research into and testing of shadow copy implementations and/or third-party libraries and see if I can find a good solution for DesktopMagic. DotNetCorePlugins does look very promising, hopefully it lives up to that promise!
I'm excited about the prospect of a C#/dotnet oriented replacement for Rainmeter (who thought object-oriented ini files were a good idea?) so I'm looking to contribute a bit 😄
@Stone-Red-Code commented on GitHub (Oct 29, 2024):
It seems like the maintainer of DotNetCorePlugins doesn't want to maintain the library anymore and is considering archiving the repository. So that doesn't seem like a viable option.
The biggest problem with loading assemblies in .NET is unloading them.
The
AssemblyLoadContextclass should theoretically solve this problem, but I haven't played around with it yet.Another option would be to load the plugins into a separate process that communicates with IPC with the main process.
This would make it very easy to unload the assembly because we can just stop/kill the process if required. And it would also solve some performance issues that are caused by WPF because it requires all UI interactions to run on the main UI thread. (The main window and other plugins can stutter when plugins are updating the
Bitmap)Both of these ideas would require some form of shadow copying because of the
Assembly.Loadmethod would still lock the files.@dylanrenwick commented on GitHub (Nov 1, 2024):
It's looking to me like we may need to implement shadow copying ourselves. I'm not finding many good options that are still maintained.
I'll take a stab over the weekend at implementing a standalone lib that can load an
Assemblyfrom a file without locking it, whilst still maintaining proper Load Context and dependencies@dylanrenwick commented on GitHub (Nov 1, 2024):
After some brief testing, it seems the solution may actually be as simple as copying the target file before loading it.
I created a simple proof-of-concept here to demonstrate this.
All it does is copy the
.dllwe want to load to a local_shadowdirectory before loading it. To my surprise this doesn't seem to break dependency resolution at all, and even appears to avoid locking loaded dependencies (Dependency.dllwas not locked, even before the test project itself depended on it)If you can think of a more complex real world use case/edge case that this PoC doesn't cover let me know and I'll add it to the test suite.
@dylanrenwick commented on GitHub (Nov 1, 2024):
Additionally, it looks like if we load each plugin into its own
AssemblyLoadContextand ensure they're set to collectible when created, we can unload plugins at runtime withAssemblyLoadContext.Unload().Only whole LoadContexts can be unloaded though, not individual plugins, so each plugin would need its own LoadContext. I don't think this would cause problems, but it may warrant further investigation.
@Stone-Red-Code commented on GitHub (Nov 1, 2024):
Your PoC looks good, but are the dependencies not copied into the
_shadowdirectory?When removing the
Dependencyproject from the project references of thePluginDotNet.Testproject, theDependentPlugin.dllfails to load because it can't findDependency.dllYeah, that's how the
AssemblyLoadContextis supposed to be used. Loading each plugin into its ownAssemblyLoadContextshouldn't be a problem.@dylanrenwick commented on GitHub (Nov 1, 2024):
That's odd. I had run the
DependencyPlugintest before adding the project reference and it had worked fine. I added the project reference solater that I could add the interface check.I admittedly didn't check if the dependencies were also copied into
_shadow, though I don't see that being an issue as we would probably need the dependencies shadow copied as well anyway, or at the very least not locked.For that reason it may also make sense to give each loaded plugin its own subdirectory, to avoid file conflicts.
My one concern with this approach 8s shared dependencies, ie if multiple plugins reference the same dependency (is each plugin going to need its own PluginAPI assembly?)
I'll do some more testing and double check my results.
@dylanrenwick commented on GitHub (Nov 8, 2024):
Running some further tests I was able to confirm your results; that the
DependencyPlugintest fails if the test suite doesn't itself has a reference to the dependency.Unfortunately I can't see a great way around this as there doesn't seem to be a good way to detect an assembly's dependencies before loading in a way that allows us to shadow copy and load them as well.
I'm going to keep investigating this, and look into how DotNetCorePlugins handled dependencies, but in the meantime an interrim solution may be to update the current plugin loading to use the
AssemblyLoadContextapproach, and provide an option on the UI to unload a plugin without having to restart DM itself.This would still be an improvement over the current development cycle as you would just need to unload a single plugin, update the files, then reload
@Stone-Red-Code commented on GitHub (Nov 12, 2024):
Thanks for looking into this.
Can't we just load the dependencies into the
AssemblyLoadContext? If I understood that correctlyAssemblyLoadContexts don't share dependencies by default.@dylanrenwick commented on GitHub (Nov 14, 2024):
We can. My concern is that we don't know what dependencies a plugin has without loading it, so when shadow copying the plugin assembly we don't know what dependencies also need to be shadow copied.
If we shadow copy the plugin assembly and not its dependencies, the library will fail to load with unresolved dependencies.
@Stone-Red-Code commented on GitHub (Nov 14, 2024):
Well the it's already a requirement that all plugins have to include all the necessary dependencies in the zip file/plugin directory.
So we can just copy the whole directory and load everything necessary.
@Stone-Red-Code commented on GitHub (Dec 9, 2024):
Hi, any updates?
If not, I will implement a temporary solution to at least make plugin development less frustrating.
@dylanrenwick commented on GitHub (Dec 12, 2024):
Hi, sorry I haven't had much time to work on it the last couple of weeks with holidays coming up.
I have a local fork that isolates each plugin assembly into its own
AssemblyLoadContext, and loads the assemblies from memory instead of from file.This prevents the files being locked, and allows DM to reload the plugin assembly from file each time it's enabled, making development smoother.
However, when unloading the load contexts on plugin disable, the load context isn't being cleaned up properly and I haven't been successful in figuring out why. This means each time you toggle a plugin the fork leaks around 80kb of memory in the form of an orphaned load context.
@Stone-Red-Code commented on GitHub (Dec 12, 2024):
No worries
After looking through the plugin code, I noticed multiple possible references that could prevent the assembly from unloading.
There's the
PluginDataclass that gets passed to the plugin, but the bigger problem is probably the references to theSettings objects.DM has a reference to each
Settingobject that's defined in thePluginclass.BTW is your fork on GH up to date?
@dylanrenwick commented on GitHub (Dec 19, 2024):
It wasn't up to date, but I just pushed my changes to it.
I did catch the
Settingreferences, and tried to encapsulate that in my load/unload logic.dotSpy doesn't find any references to the
AssemblyLoadContextinstances, but they don't get cleaned up by GC regardless.@dylanrenwick commented on GitHub (Feb 25, 2025):
Finally found the time to revisit this.
Looking back through my fork changes, I had separated the actual plugin loading logic out into its own class, because IMO it's not ideal to have that logic directly in the WPF code-behind (for best practice reasons).
The isolated PluginLoader class also uses
AssemblyLoadContextoverAssembly.Loadas discussed above, isolating each plugin into its own context.I've been running this fork for a while at work now, though I've only been running the built-in date/time and CPU usage plugins, alongside my own CPU/RAM usage bars plugin.
If you're on-board with the separation of concerns with plugin loading, I'll do some more thorough testing with other publicly available plugins to make sure everything's working as expected, and submit a PR. If you're unsure but want to look through the changes I can still submit a draft PR for your review.
Notably I have yet to track down the cause of the load context memory leak (
AssemblyLoadContextinstances are not being GC'd despite no detectable references to them), but given that's only a concern when unloading a plugin and is still appropriately cleaned up when DM is closed, my suggestion would be that we merge the changes to resolve this issue, and create a new issue to track investigation of the memory leak. As far as I can tell it looks like the leak is on the order of a few hundred bytes at most each time a plugin is unloaded which, while not ideal, isn't a major concern IMO.Let me know how you want to proceed.
@Stone-Red-Code commented on GitHub (Feb 25, 2025):
Yeah, that does indeed make sense, I mean some code in this project is ~5 years old. Some refactoring won't hurt. xD
If you can get the Media Display plugin to load and work as intended, then your implementation should be fine.
And yeah, just submit a draft PR.
That's also what I would have suggested. The memory leak probably isn't a big deal.
So, do some more testing and submit a PR whenever you’re good to go.
@Stone-Red-Code commented on GitHub (Mar 25, 2025):
Hi there, I checked out your plugin loader code.
Dependencies weren't loaded correctly but after adding
and
to
PluginLoader.csdependencies seemed to load fine.I also haven't tested everything yet, though.
@dylanrenwick commented on GitHub (Mar 25, 2025):
Ah nice catch!
I've been trying to find the free time to test this at home for a few weeks now, early march has been kinda crazy.
I have carved out some time tonight to give it a proper test though.
Unfortunately I mainly use DM at work, in a VM with no audio output or graphics device, so I've been trying to find the opportunity to test it at home on my desktop.
I'll return tonight or tomorrow morning with the test results. I imagine it should all work especially with your dependency fixes, but best to make sure.
@dylanrenwick commented on GitHub (Mar 26, 2025):
Ran some tests with the media display plugin and everything seemed to work fine. I was able to play audio and video files with no issues.
I'll commit your dependency fixes into my fork and submit a PR today
@Stone-Red-Code commented on GitHub (Apr 6, 2025):
How's it going?
@dylanrenwick commented on GitHub (Apr 11, 2025):
I ran some more tests before opening the PR, and Media Display Plugin wasn't working. It worked fine on the main branch of DM, but not my fork. It displays fine, but won't detect any media playing. I haven't had the chance to look into it too much yet but I'm going to have a good deal more free time after next week so I'm planning to investigate then.
@Stone-Red-Code commented on GitHub (Dec 19, 2025):
My implementation in https://github.com/Stone-Red-Code/DesktopMagic/commit/780b5061039c40d0db9d7e32255c81dc462d6da5 seems to work and is surprisingly simple.
@dylanrenwick commented on GitHub (Dec 20, 2025):
Implementation looks solid and close to what I had! I might give it a peek with dotPeek one day to see if that memory leak is still there, but as we discussed before it's a very minor and slow leak if it even is there.
Either way this change solves my original issue!