Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 99 additions & 35 deletions src/Neo/Plugins/Plugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

using Microsoft.Extensions.Configuration;
using System.Reflection;
using System.Runtime.Loader;
using static System.IO.Path;

namespace Neo.Plugins;
Expand All @@ -33,6 +34,7 @@ public abstract class Plugin : IDisposable
Combine(GetDirectoryName(AppContext.BaseDirectory)!, "Plugins");

private static readonly FileSystemWatcher? s_configWatcher;
private static readonly List<PluginLoadContext> s_pluginLoadContexts = [];

/// <summary>
/// Indicates the root path of the plugin.
Expand Down Expand Up @@ -90,7 +92,6 @@ static Plugin()
s_configWatcher.Created += ConfigWatcher_Changed;
s_configWatcher.Renamed += ConfigWatcher_Changed;
s_configWatcher.Deleted += ConfigWatcher_Changed;
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
}

/// <summary>
Expand Down Expand Up @@ -125,33 +126,71 @@ private static void ConfigWatcher_Changed(object? sender, FileSystemEventArgs e)
}
}

private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs args)
private sealed class PluginLoadContext : AssemblyLoadContext
Copy link
Member

Choose a reason for hiding this comment

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

Move it to a separate file?

Copy link
Member

Choose a reason for hiding this comment

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

Ping @Jim8y

{
if (args.Name.Contains(".resources"))
return null;
private readonly AssemblyDependencyResolver _resolver;
private readonly string _pluginDirectory;
private readonly HashSet<string> _sharedAssemblies;

AssemblyName an = new(args.Name);
public PluginLoadContext(string mainAssemblyPath)
: base(isCollectible: false)
{
_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
_pluginDirectory = GetDirectoryName(mainAssemblyPath)!;
_sharedAssemblies = new(StringComparer.OrdinalIgnoreCase)
{
typeof(Plugin).Assembly.GetName().Name!
};
}

var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.FullName == args.Name) ??
AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == an.Name);
if (assembly != null) return assembly;
protected override Assembly? Load(AssemblyName assemblyName)
{
if (_sharedAssemblies.Contains(assemblyName.Name!))
return Default.LoadFromAssemblyName(assemblyName);

var filename = an.Name + ".dll";
var path = filename;
if (!File.Exists(path)) path = Combine(GetDirectoryName(AppContext.BaseDirectory)!, filename);
if (!File.Exists(path)) path = Combine(PluginsDirectory, filename);
if (!File.Exists(path) && !string.IsNullOrEmpty(args.RequestingAssembly?.GetName().Name))
path = Combine(PluginsDirectory, args.RequestingAssembly!.GetName().Name!, filename);
if (!File.Exists(path)) return null;
var path = _resolver.ResolveAssemblyToPath(assemblyName);
if (path != null)
return LoadFromAssemblyPath(path);

try
{
return Assembly.Load(File.ReadAllBytes(path));
if (assemblyName.Name is null)
return null;

var localPath = Combine(_pluginDirectory, $"{assemblyName.Name}.dll");
return File.Exists(localPath) ? LoadFromAssemblyPath(localPath) : null;
}
catch (Exception ex)

protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
Utility.Log(nameof(Plugin), LogLevel.Error, ex);
return null;
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (path != null)
return LoadUnmanagedDllFromPath(path);

var localPath = Combine(_pluginDirectory, unmanagedDllName);
if (File.Exists(localPath))
return LoadUnmanagedDllFromPath(localPath);

if (OperatingSystem.IsWindows())
{
localPath = Combine(_pluginDirectory, $"{unmanagedDllName}.dll");
if (File.Exists(localPath))
return LoadUnmanagedDllFromPath(localPath);
}

if (OperatingSystem.IsLinux())
{
localPath = Combine(_pluginDirectory, $"lib{unmanagedDllName}.so");
if (File.Exists(localPath))
return LoadUnmanagedDllFromPath(localPath);
}

if (OperatingSystem.IsMacOS())
{
localPath = Combine(_pluginDirectory, $"lib{unmanagedDllName}.dylib");
if (File.Exists(localPath))
return LoadUnmanagedDllFromPath(localPath);
}

return IntPtr.Zero;
}
}

Expand All @@ -173,7 +212,7 @@ protected IConfigurationSection GetConfiguration()
.GetSection("PluginConfiguration");
}

private static void LoadPlugin(Assembly assembly)
private static int LoadPlugin(Assembly assembly)
{
Type[] exportedTypes;

Expand All @@ -189,6 +228,7 @@ private static void LoadPlugin(Assembly assembly)
throw;
}

var loaded = 0;
foreach (var type in exportedTypes)
{
if (!type.IsSubclassOf(typeof(Plugin))) continue;
Expand All @@ -200,36 +240,60 @@ private static void LoadPlugin(Assembly assembly)
try
{
constructor.Invoke(null);
loaded++;
}
catch (Exception ex)
{
Utility.Log(nameof(Plugin), LogLevel.Error, $"Failed to initialize plugin type {type.FullName} of {assemblyName}: {ex}");
}
}

return loaded;
}

internal static void LoadPlugins()
{
if (!Directory.Exists(PluginsDirectory)) return;
List<Assembly> assemblies = [];
foreach (var rootPath in Directory.GetDirectories(PluginsDirectory))
{
foreach (var filename in Directory.EnumerateFiles(rootPath, "*.dll", SearchOption.TopDirectoryOnly))
var pluginName = new DirectoryInfo(rootPath).Name;
var mainAssemblyPath = Combine(rootPath, $"{pluginName}.dll");
if (!File.Exists(mainAssemblyPath))
{
try
{
assemblies.Add(Assembly.Load(File.ReadAllBytes(filename)));
}
catch (Exception ex)
Utility.Log(nameof(Plugin), LogLevel.Warning, $"Plugin assembly not found: {mainAssemblyPath}");
continue;
}

try
{
var loadContext = new PluginLoadContext(mainAssemblyPath);
s_pluginLoadContexts.Add(loadContext);
var assembly = loadContext.LoadFromAssemblyPath(mainAssemblyPath);
var loaded = LoadPlugin(assembly);
if (loaded > 0)
continue;

foreach (var filename in Directory.EnumerateFiles(rootPath, "*.dll", SearchOption.TopDirectoryOnly))
{
Utility.Log(nameof(Plugin), LogLevel.Error, $"Failed to load plugin assembly file {filename}: {ex}");
if (string.Equals(filename, mainAssemblyPath, StringComparison.OrdinalIgnoreCase))
continue;

try
{
assembly = loadContext.LoadFromAssemblyPath(filename);
if (LoadPlugin(assembly) > 0)
break;
}
catch (Exception ex)
{
Utility.Log(nameof(Plugin), LogLevel.Error, $"Failed to load plugin assembly file {filename}: {ex}");
}
}
}
}

foreach (var assembly in assemblies)
{
LoadPlugin(assembly);
catch (Exception ex)
{
Utility.Log(nameof(Plugin), LogLevel.Error, $"Failed to load plugin assembly file {mainAssemblyPath}: {ex}");
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions tests/Neo.PluginFixture.Dependency/FixtureDependency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (C) 2015-2025 The Neo Project.

Check warning on line 1 in tests/Neo.PluginFixture.Dependency/FixtureDependency.cs

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

A source file contains a header that does not match the required text
//
// FixtureDependency.cs file belongs to the neo project and is free
// software distributed under the MIT software license, see the
// accompanying file LICENSE in the main directory of the
// repository or http://www.opensource.org/licenses/mit-license.php
// for more details.
//
// Redistribution and use in source and binary forms with or without
// modifications are permitted.

namespace Neo.PluginFixture.Dependency;

public static class FixtureDependency
{
public static string Value => "fixture-dependency";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyName>Neo.PluginFixture.Dependency</AssemblyName>
<OutputType>Library</OutputType>
</PropertyGroup>

</Project>
20 changes: 20 additions & 0 deletions tests/Neo.PluginFixture/FixturePlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (C) 2015-2025 The Neo Project.

Check warning on line 1 in tests/Neo.PluginFixture/FixturePlugin.cs

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

A source file contains a header that does not match the required text
//
// FixturePlugin.cs file belongs to the neo project and is free
// software distributed under the MIT software license, see the
// accompanying file LICENSE in the main directory of the
// repository or http://www.opensource.org/licenses/mit-license.php
// for more details.
//
// Redistribution and use in source and binary forms with or without
// modifications are permitted.

using Neo.Plugins;

namespace Neo.PluginFixture;

public sealed class FixturePlugin : Plugin
{
public string GetDependencyAssemblyLocation() =>
typeof(Dependency.FixtureDependency).Assembly.Location;
}
13 changes: 13 additions & 0 deletions tests/Neo.PluginFixture/Neo.PluginFixture.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyName>Neo.PluginFixture</AssemblyName>
<OutputType>Library</OutputType>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Neo\Neo.csproj" />
<ProjectReference Include="..\Neo.PluginFixture.Dependency\Neo.PluginFixture.Dependency.csproj" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions tests/Neo.UnitTests/Neo.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Neo.Extensions\Neo.Extensions.csproj" />
<ProjectReference Include="..\..\src\Neo\Neo.csproj" />
<ProjectReference Include="..\Neo.PluginFixture\Neo.PluginFixture.csproj" />
</ItemGroup>

</Project>
102 changes: 102 additions & 0 deletions tests/Neo.UnitTests/Plugins/UT_PluginLoadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (C) 2015-2025 The Neo Project.

Check warning on line 1 in tests/Neo.UnitTests/Plugins/UT_PluginLoadContext.cs

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

A source file contains a header that does not match the required text
//
// UT_PluginLoadContext.cs file belongs to the neo project and is free
// software distributed under the MIT software license, see the
// accompanying file LICENSE in the main directory of the
// repository or http://www.opensource.org/licenses/mit-license.php
// for more details.
//
// Redistribution and use in source and binary forms with or without
// modifications are permitted.

using Neo.Plugins;
using System.Runtime.Loader;

namespace Neo.UnitTests.Plugins;

[TestClass]
public class UT_PluginLoadContext
{
private const string FixtureAssemblyName = "Neo.PluginFixture";
private const string DependencyAssemblyName = "Neo.PluginFixture.Dependency";
private static readonly Lock s_locker = new();

[TestMethod]
public void TestLoadPluginsUsesIsolatedContext()
{
lock (s_locker)
{
try
{
var pluginRoot = PrepareFixturePluginDirectory(out var pluginAssemblyPath);
Plugin.Plugins.Clear();

Plugin.LoadPlugins();

var plugin = Plugin.Plugins.SingleOrDefault(p =>
string.Equals(p.GetType().Assembly.Location, pluginAssemblyPath, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(plugin);

var loadContext = AssemblyLoadContext.GetLoadContext(plugin.GetType().Assembly);
Assert.IsNotNull(loadContext);
Assert.AreNotSame(AssemblyLoadContext.Default, loadContext);
Assert.AreEqual(pluginAssemblyPath, plugin.GetType().Assembly.Location);
}
finally
{
Plugin.Plugins.Clear();
}
}
}

[TestMethod]
public void TestLoadPluginsResolvesDependencyFromPluginDirectory()
{
lock (s_locker)
{
try
{
var pluginRoot = PrepareFixturePluginDirectory(out _);
Plugin.Plugins.Clear();

Plugin.LoadPlugins();

var plugin = Plugin.Plugins.SingleOrDefault(p =>
p.GetType().Assembly.Location.StartsWith(pluginRoot, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(plugin);

var method = plugin.GetType().GetMethod("GetDependencyAssemblyLocation");
Assert.IsNotNull(method);

var location = method.Invoke(plugin, null) as string;
Assert.IsFalse(string.IsNullOrEmpty(location));
Assert.AreEqual(Path.Combine(pluginRoot, $"{DependencyAssemblyName}.dll"), location);
}
finally
{
Plugin.Plugins.Clear();
}
}
}

private static string PrepareFixturePluginDirectory(out string pluginAssemblyPath)
{
var pluginsDir = Plugin.PluginsDirectory;
Directory.CreateDirectory(pluginsDir);
var pluginDirectoryName = $"{FixtureAssemblyName}.{Guid.NewGuid():N}";
var pluginRoot = Path.Combine(pluginsDir, pluginDirectoryName);
Directory.CreateDirectory(pluginRoot);

var sourcePluginPath = Path.Combine(AppContext.BaseDirectory, $"{FixtureAssemblyName}.dll");
var sourceDependencyPath = Path.Combine(AppContext.BaseDirectory, $"{DependencyAssemblyName}.dll");

Assert.IsTrue(File.Exists(sourcePluginPath), $"Missing fixture plugin assembly: {sourcePluginPath}");
Assert.IsTrue(File.Exists(sourceDependencyPath), $"Missing fixture dependency assembly: {sourceDependencyPath}");

pluginAssemblyPath = Path.Combine(pluginRoot, $"{pluginDirectoryName}.dll");
File.Copy(sourcePluginPath, pluginAssemblyPath, true);
File.Copy(sourceDependencyPath, Path.Combine(pluginRoot, $"{DependencyAssemblyName}.dll"), true);
Comment on lines +97 to +98
Copy link
Member

Choose a reason for hiding this comment

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

This ut is not cleaned, we should delete the folder after execute it


return pluginRoot;
}
}
Loading