Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement isolated plugins, runtime plugin load/unload #751

Merged
merged 33 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e96c26b
Add System.Reflection.MetadataLoadContext package.
jhett12321 Dec 18, 2023
0572631
Use CoreServiceContainer to inject properties until AnvilServiceConta…
jhett12321 Dec 18, 2023
81110c7
Plugins/Services: Refactor loading/type resolution to isolate plugins.
jhett12321 Dec 18, 2023
dccf410
Use MetadataLoadContext for resolving PluginInfo without full assembl…
jhett12321 Dec 18, 2023
2c42c8c
Implement isolated plugins.
jhett12321 Dec 20, 2023
b9501dd
Cleanup logger.
jhett12321 Dec 20, 2023
00f564e
Implement environment var to disable plugins.
jhett12321 Dec 20, 2023
db28dc5
Add plugin class documentation.
jhett12321 Feb 13, 2024
0da2cb1
Implement runtime plugin load/unload.
jhett12321 Feb 13, 2024
26962bf
Merge branch 'development' into plugin-toggles
jhett12321 Apr 15, 2024
1d8a408
Merge branch 'development' into plugin-toggles
jhett12321 Dec 28, 2024
7bc3cdd
Separate service messaging to new class.
jhett12321 Dec 28, 2024
57d8fbb
Define explicit execution order for core services.
jhett12321 Dec 28, 2024
c606b4f
Merge branch 'development' into plugin-toggles
jhett12321 Dec 30, 2024
2c9debd
Add plugin lifecycle test.
jhett12321 Dec 30, 2024
e5d4ada
Cleanup import.
jhett12321 Dec 30, 2024
a48751b
Use primary constructor.
jhett12321 Dec 30, 2024
f57b54d
Fix crash when updating server loop targets inside server loop.
jhett12321 Dec 30, 2024
af06a82
Fix plugin loading when deps.json is missing.
jhett12321 Dec 30, 2024
247ebcc
Don't unload plugins that are not loaded.
jhett12321 Dec 30, 2024
069cd82
Use runtime assembly list.
jhett12321 Dec 30, 2024
bf5564f
Fix not found exceptions when loading plugin metadata with other plug…
jhett12321 Dec 30, 2024
b2d23ab
Don't skip isolated plugins for unload.
jhett12321 Dec 30, 2024
8495bc7
Hide Microsoft.CodeAnalysis.CSharp dependency. Add Microsoft.CodeAnal…
jhett12321 Dec 30, 2024
349fbde
Use correct assembly simple name.
jhett12321 Dec 30, 2024
b7e3596
Bind test service to IUpdateable.
jhett12321 Dec 30, 2024
dd7f82c
Ensure test plugin is unloadable.
jhett12321 Dec 30, 2024
0057653
Add MemberNotNullWhen attributes to plugin for redundant null checks.
jhett12321 Dec 30, 2024
baf0aca
Fix static property injection. Fix unloadability issues. Add addition…
jhett12321 Dec 30, 2024
be9b518
Cleanup.
jhett12321 Dec 30, 2024
255eff8
Update System.Reflection.MetadataLoadContext to 8.0.1.
jhett12321 Dec 30, 2024
fa246c7
Rename container message service.
jhett12321 Dec 30, 2024
53b5782
Use active runtime version for paket packages.
jhett12321 Dec 30, 2024
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
2 changes: 1 addition & 1 deletion NWN.Anvil.TestRunner/NWN.Anvil.TestRunner.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="all" />
</ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions NWN.Anvil.Tests/NWN.Anvil.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="NWN.Core" Version="8193.36.5" PrivateAssets="compile" />
<PackageReference Include="NWN.Native" Version="8193.36.7" PrivateAssets="compile" />
</ItemGroup>
Expand Down
42 changes: 42 additions & 0 deletions NWN.Anvil.Tests/src/main/Generators/AssemblyGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Linq;
using Anvil.Internal;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;

namespace Anvil.Tests.Generators
{
internal sealed class AssemblyGenerator
{
public static void GenerateAssembly(Stream writeAssemblyStream, string assemblyName, string sourceCode)
{
CSharpCompilation compileJob = GenerateCode(assemblyName, sourceCode);
EmitResult compileResult = compileJob.Emit(writeAssemblyStream);

if (!compileResult.Success)
{
throw new Exception($"Compilation failed:\n{string.Join('\n', compileResult.Diagnostics)}");
}
}

private static CSharpCompilation GenerateCode(string assemblyName, string sourceCode)
{
SourceText codeString = SourceText.From(sourceCode);
CSharpParseOptions options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Default);

SyntaxTree tree = CSharpSyntaxTree.ParseText(codeString, options);

MetadataReference[] references =
[
..Assemblies.RuntimeAssemblies.Select(assemblyPath => MetadataReference.CreateFromFile(assemblyPath)),
MetadataReference.CreateFromFile(typeof(AnvilCore).Assembly.Location),
MetadataReference.CreateFromFile(typeof(AssemblyGenerator).Assembly.Location),
];

return CSharpCompilation.Create(assemblyName, [tree], references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Debug, assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default));
}
}
}
57 changes: 57 additions & 0 deletions NWN.Anvil.Tests/src/main/Plugins/PluginTestUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Text;

namespace Anvil.Tests.Plugins
{
internal static class PluginTestUtils
{
private const string PluginInfo = "[assembly: Anvil.Plugins.PluginInfo(Isolated = true)]";

public static string GenerateServiceClass(string serviceName, string[] imports, string[] bindings, string[] baseTypes, string implementation)
{
StringBuilder source = new StringBuilder();
AppendImports(source, imports);
source.AppendLine();
source.AppendLine(PluginInfo);
source.AppendLine();

AppendClassDefinition(source, serviceName, bindings, baseTypes);

source.AppendLine("{");
source.Append(implementation);
source.AppendLine();
source.AppendLine("}");

return source.ToString();
}

private static void AppendClassDefinition(StringBuilder source, string serviceName, string[] bindings, string[] baseTypes)
{
source.AppendLine($"[Anvil.Services.ServiceBinding(typeof({serviceName}))]");
foreach (string binding in bindings)
{
source.AppendLine($"[Anvil.Services.ServiceBinding(typeof({binding}))]");
}

source.Append($"public class {serviceName}");
if (baseTypes.Length > 0)
{
source.Append($" : {string.Join(", ", baseTypes)}");
}

source.AppendLine();
}

private static void AppendImports(StringBuilder source, string[]? imports)
{
if (imports == null)
{
return;
}

foreach (string import in imports)
{
source.AppendLine($"using {import};");
}
}
}
}
260 changes: 260 additions & 0 deletions NWN.Anvil.Tests/src/main/Plugins/PluginTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Anvil.API;
using Anvil.Plugins;
using Anvil.Services;
using Anvil.Tests.Generators;
using NUnit.Framework;

namespace Anvil.Tests.Plugins
{
[TestFixture(Category = "Plugins")]
public sealed class PluginTests
{
[Inject]
private static PluginManager PluginManager { get; set; } = null!;

[Inject]
private static PluginStorageService PluginStorageService { get; set; } = null!;

// Test Dependencies
[Inject]
private static SchedulerService SchedulerService { get; set; } = null!;

[Inject]
private static PluginTestDependency PluginTestDependency { get; set; } = null!;

[Inject]
private static HookService HookService { get; set; } = null!;

[Test]
public async Task PluginLifecycleTest()
{
const string pluginName = "LifecycleTestPlugin";
const string serviceName = "PluginLifecycleTestService";

const string implementation =
"""
public static bool InitCalled;
public static bool UpdateCalled;
public static bool DisposeCalled;

public void Init()
{
InitCalled = true;
}

public void Update()
{
UpdateCalled = true;
}

public void Dispose()
{
DisposeCalled = true;
}
""";

string source = PluginTestUtils.GenerateServiceClass(serviceName,
[nameof(System), $"{nameof(Anvil)}.{nameof(Anvil.Services)}"],
[nameof(IUpdateable)],
[nameof(IInitializable), nameof(IUpdateable), nameof(IDisposable)],
implementation);

string pluginPath = CreatePlugin(pluginName, source);
WeakReference pluginRef = await RunPluginLifecycleTest(pluginPath, serviceName);

WaitAndCheckForPluginUnload(pluginRef);
}

[MethodImpl(MethodImplOptions.NoInlining)] // Required to allow GC/unload of plugin.
private async Task<WeakReference> RunPluginLifecycleTest(string pluginPath, string serviceName)
{
(Plugin plugin, Type pluginServiceType) = LoadPlugin(pluginPath, serviceName);

FieldInfo initCalledField = pluginServiceType.GetField("InitCalled", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo updateCalledField = pluginServiceType.GetField("UpdateCalled", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo disposeCalledField = pluginServiceType.GetField("DisposeCalled", BindingFlags.Public | BindingFlags.Static)!;

Assert.That(initCalledField, Is.Not.Null);
Assert.That(updateCalledField, Is.Not.Null);
Assert.That(disposeCalledField, Is.Not.Null);

Assert.That(initCalledField.GetValue(null), Is.True);
Assert.That(updateCalledField.GetValue(null), Is.False);
Assert.That(disposeCalledField.GetValue(null), Is.False);

await NwTask.NextFrame();

Assert.That(updateCalledField.GetValue(null), Is.True);
Assert.That(disposeCalledField.GetValue(null), Is.False);

WeakReference pluginRef = PluginManager.UnloadPlugin(plugin, false);
await NwTask.NextFrame();

Assert.That(disposeCalledField.GetValue(null), Is.True);

await NwTask.NextFrame();

return pluginRef;
}

[Test]
public void PluginAnvilApiTest()
{
const string pluginName = "AnvilApiTestPlugin";
const string serviceName = "AnvilApiTestService";

const string implementation =
"""
public static string ModuleName;
public static NwServer ServerInstance;

public void Init()
{
ModuleName = NwModule.Instance.Name;
ServerInstance = NwServer.Instance;
}
""";

string source = PluginTestUtils.GenerateServiceClass(serviceName,
[nameof(System), $"{nameof(Anvil)}.{nameof(Anvil.Services)}", $"{nameof(Anvil)}.{nameof(Anvil.API)}"],
[],
[nameof(IInitializable)],
implementation);

string pluginPath = CreatePlugin(pluginName, source);
WeakReference pluginRef = RunPluginAnvilApiTest(pluginPath, serviceName);

WaitAndCheckForPluginUnload(pluginRef);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private WeakReference RunPluginAnvilApiTest(string pluginPath, string serviceName)
{
(Plugin plugin, Type pluginServiceType) = LoadPlugin(pluginPath, serviceName);

FieldInfo moduleNameField = pluginServiceType.GetField("ModuleName", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo serverInstanceField = pluginServiceType.GetField("ServerInstance", BindingFlags.Public | BindingFlags.Static)!;
Assert.That(moduleNameField.GetValue(null), Is.EqualTo(NwModule.Instance.Name));
Assert.That(serverInstanceField.GetValue(null), Is.EqualTo(NwServer.Instance));

return PluginManager.UnloadPlugin(plugin, false);
}

[Test]
public void PluginDependencyTest()
{
const string pluginName = "DependencyTestPlugin";
const string serviceName = "DependencyTestService";

const string implementation =
"""
public static SchedulerService SchedulerService;
public static PluginManager PluginManager;
public static HookService HookService;
public static PluginTestDependency PluginTestDependency;

[Inject]
private static PluginManager InjectedPluginManager { get; set; } = null!;

[Inject]
private HookService InjectedHookService { get; init; } = null!;

public DependencyTestService(SchedulerService schedulerService, PluginTestDependency pluginTestDependency)
{
SchedulerService = schedulerService;
PluginTestDependency = pluginTestDependency;
}

public void Init()
{
PluginManager = InjectedPluginManager;
HookService = InjectedHookService;
}
""";

string source = PluginTestUtils.GenerateServiceClass(serviceName,
[nameof(System), $"{nameof(Anvil)}.{nameof(Anvil.Services)}", $"{nameof(Anvil)}.{nameof(Anvil.API)}", $"{nameof(Anvil)}.{nameof(Anvil.Plugins)}", $"{nameof(Anvil)}.{nameof(Tests)}.{nameof(Plugins)}"],
[],
[nameof(IInitializable)],
implementation);

string pluginPath = CreatePlugin(pluginName, source);
WeakReference pluginRef = RunPluginDependencyTest(pluginPath, serviceName);

WaitAndCheckForPluginUnload(pluginRef);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private WeakReference RunPluginDependencyTest(string pluginPath, string serviceName)
{
(Plugin plugin, Type pluginServiceType) = LoadPlugin(pluginPath, serviceName);

FieldInfo schedulerServiceField = pluginServiceType.GetField("SchedulerService", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo pluginManagerField = pluginServiceType.GetField("PluginManager", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo hookServiceField = pluginServiceType.GetField("HookService", BindingFlags.Public | BindingFlags.Static)!;
FieldInfo testDependencyField = pluginServiceType.GetField("PluginTestDependency", BindingFlags.Public | BindingFlags.Static)!;

Assert.That(schedulerServiceField.GetValue(null), Is.EqualTo(SchedulerService));
Assert.That(pluginManagerField.GetValue(null), Is.EqualTo(PluginManager));
Assert.That(hookServiceField.GetValue(null), Is.EqualTo(HookService));
Assert.That(testDependencyField.GetValue(null), Is.EqualTo(PluginTestDependency));

WeakReference pluginRef = PluginManager.UnloadPlugin(plugin, false);

return pluginRef;
}

private string CreatePlugin(string pluginName, string source)
{
string pluginRoot = Path.Combine(PluginStorageService.GetPluginStoragePath(typeof(PluginTests).Assembly), "TestPlugins", pluginName);
if (Directory.Exists(pluginRoot))
{
Directory.Delete(pluginRoot, true);
}

Directory.CreateDirectory(pluginRoot);

string pluginPath = Path.Combine(pluginRoot, $"{pluginName}.dll");

using FileStream assemblyStream = File.Create(pluginPath);
AssemblyGenerator.GenerateAssembly(assemblyStream, pluginName, source);

return pluginRoot;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private (Plugin, Type) LoadPlugin(string pluginPath, string serviceName)
{
Plugin plugin = PluginManager.LoadPlugin(pluginPath);

Assert.That(plugin, Is.Not.Null);
Assert.That(plugin.IsLoaded, Is.True);
Assert.That(plugin.PluginInfo, Is.Not.Null);
Assert.That(plugin.Assembly, Is.Not.Null);

Type pluginServiceType = plugin.Assembly!.GetType(serviceName)!;
Assert.That(pluginServiceType, Is.Not.Null);

return (plugin, pluginServiceType);
}

private void WaitAndCheckForPluginUnload(WeakReference pluginRef)
{
for (int i = 0; i < 10 && pluginRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}

Assert.That(pluginRef.IsAlive, Is.False.After(10000, 1000), "Plugin was not unloaded");
}
}

[ServiceBinding(typeof(PluginTestDependency))]
public class PluginTestDependency;
}
1 change: 1 addition & 0 deletions NWN.Anvil/NWN.Anvil.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.1" />
<PackageReference Include="LightInject" Version="6.6.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.8" />
Expand Down
1 change: 1 addition & 0 deletions NWN.Anvil/NWN.Anvil.csproj.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Cgameloop/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Chooking/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Clogredirector/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Cmessages/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Cobjectstorage/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Cresourcemanager/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=src_005Cmain_005Cservices_005Cresources/@EntryIndexedValue">True</s:Boolean>
Expand Down
Loading
Loading