Implement PluginHost: two-ALC plugin loading, unloading verified live
The centerpiece of the whole "no domain reload" claim, now proven empirically rather than argued on paper: 200 load/unload cycles against a real plugin (sandbox.echo), each one checked with a WeakReference that the collectible ALC actually collected — docs/kernel-contract.md §4's leak test, previously Skip-marked since the very first scaffold, now runs and passes (stable across repeated runs). New pieces, minimal by design: - PluginLoadContext: the collectible ALC a plugin's implementation loads into. Load() defers to whatever's already in the Default ALC (Engine.Kernel, the plugin's own Contracts assembly) before consulting AssemblyDependencyResolver for genuinely private dependencies — the standard .NET plugin pattern, needed so component types stay identical across the plugin boundary instead of loading as two distinct, incompatible copies. - PluginHost: reads plugin.json, loads Contracts into the Default ALC (once — verified directly, not just inferred from the leak test), loads the implementation into a fresh PluginLoadContext, finds the IPlugin type via reflection, calls Configure(). Unload() calls Shutdown() first, then .Unload()s the ALC and hands back a WeakReference for the caller to check. - Schedule, ServiceRegistry, NullEventBus, ConsoleLogger: minimal real implementations of the remaining IPluginContext pieces — no stage execution or parallelism in Schedule yet, that's separate Scheduler work. Schedule.RemoveAllFrom is the one piece that has to be correct now, not later: it's what lets a plugin's Shutdown() actually drop the delegate reference into its own collectible ALC, which is exactly what the leak test is checking end to end. Explicitly out of scope for this pass: resolving a project's or plugin's dependsOn graph to order loading across multiple plugins. Nothing to test that against yet — sandbox.echo is deliberately the only, dependency-free fixture. Noted as a TODO on PluginHost rather than built speculatively. Sandbox.Echo.csproj gets <EnableDynamicLoading>true</EnableDynamicLoading> (future plugins with real dependencies will need the deps.json this generates). Engine.ConformanceHarness.csproj now copies plugin.json and both built DLLs into one flat directory under its own output, matching the layout PluginHost.Load(directory) expects — via $(Configuration)/ $(TargetFramework)-aware CopyToOutputDirectory items, correct in Release too, not just Debug. 17 tests total now (13 in Engine.Kernel.Tests, 4 in Engine.ConformanceHarness), all passing on a clean build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Generates the deps.json AssemblyDependencyResolver needs to find
|
||||||
|
this plugin's own private dependencies once it has any — see
|
||||||
|
PluginLoadContext in Engine.Kernel. -->
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\Sandbox.Echo.Contracts\Sandbox.Echo.Contracts.csproj" />
|
<ProjectReference Include="..\Sandbox.Echo.Contracts\Sandbox.Echo.Contracts.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Engine.Kernel.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Prefixes every line with the owning plugin's id — one instance
|
||||||
|
/// per <see cref="Plugins.PluginContext"/>, not shared.</summary>
|
||||||
|
internal sealed class ConsoleLogger(string pluginId) : ILogger
|
||||||
|
{
|
||||||
|
public void Info(string message) => Console.WriteLine($"[{pluginId}] {message}");
|
||||||
|
public void Warn(string message) => Console.WriteLine($"[{pluginId}] WARN: {message}");
|
||||||
|
public void Error(string message) => Console.Error.WriteLine($"[{pluginId}] ERROR: {message}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Engine.Kernel.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IEventBus has no members yet — see the open question in
|
||||||
|
/// docs/kernel-contract.md's footer. Nothing to implement until it does.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullEventBus : IEventBus;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Engine.Kernel.Plugins;
|
||||||
|
|
||||||
|
internal sealed record LoadedPlugin(
|
||||||
|
PluginManifest Manifest,
|
||||||
|
PluginLoadContext Alc,
|
||||||
|
IPlugin Instance,
|
||||||
|
IPluginContext Context);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
|
using Engine.Kernel.Events;
|
||||||
|
using Engine.Kernel.Scheduling;
|
||||||
|
using Engine.Kernel.Services;
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Plugins;
|
||||||
|
|
||||||
|
/// <summary>One per loaded plugin, built by PluginHost. Everything it
|
||||||
|
/// exposes except Log is a shared, kernel-owned singleton — the context
|
||||||
|
/// itself is the only thing scoped per plugin.</summary>
|
||||||
|
internal sealed class PluginContext(
|
||||||
|
string pluginId,
|
||||||
|
IWorld world,
|
||||||
|
IServiceRegistry services,
|
||||||
|
ISchedule schedule,
|
||||||
|
IEventBus events) : IPluginContext
|
||||||
|
{
|
||||||
|
public IWorld World { get; } = world;
|
||||||
|
public IServiceRegistry Services { get; } = services;
|
||||||
|
public ISchedule Schedule { get; } = schedule;
|
||||||
|
public IEventBus Events { get; } = events;
|
||||||
|
public ILogger Log { get; } = new ConsoleLogger(pluginId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Engine.Kernel.Events;
|
||||||
|
using Engine.Kernel.Scheduling;
|
||||||
|
using Engine.Kernel.Services;
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads, unloads, and (eventually) reloads plugins. See
|
||||||
|
/// docs/kernel-contract.md §3-§4.
|
||||||
|
///
|
||||||
|
/// Scope of this pass: single-plugin load/unload with the correct two-ALC
|
||||||
|
/// split, and enough bookkeeping that a plugin's Shutdown() can be honest.
|
||||||
|
/// NOT yet built: resolving a project's or a plugin's <c>dependsOn</c> graph
|
||||||
|
/// to determine load order across multiple plugins — that needs at least
|
||||||
|
/// two real interdependent plugins to test against meaningfully, and we
|
||||||
|
/// only have one (sandbox.echo, deliberately dependency-free). Loading a
|
||||||
|
/// plugin whose dependencies aren't already loaded will fail wherever the
|
||||||
|
/// plugin's own code first touches them, not with a host-level error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ManifestOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<string, LoadedPlugin> _loaded = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the plugin described by <c>plugin.json</c> in
|
||||||
|
/// <paramref name="pluginDirectory"/>. Contracts load into the Default
|
||||||
|
/// ALC; the implementation loads into a fresh collectible ALC. Returns
|
||||||
|
/// the plugin's id.
|
||||||
|
/// </summary>
|
||||||
|
public string Load(string pluginDirectory)
|
||||||
|
{
|
||||||
|
var manifest = ReadManifest(pluginDirectory);
|
||||||
|
|
||||||
|
if (_loaded.ContainsKey(manifest.Id))
|
||||||
|
throw new InvalidOperationException($"Plugin '{manifest.Id}' is already loaded.");
|
||||||
|
|
||||||
|
LoadContractsIntoDefaultAlc(pluginDirectory, manifest);
|
||||||
|
|
||||||
|
var implPath = Path.Combine(pluginDirectory, manifest.Assembly);
|
||||||
|
var alc = new PluginLoadContext(manifest.Id, implPath);
|
||||||
|
var implAssembly = alc.LoadFromAssemblyPath(implPath);
|
||||||
|
|
||||||
|
var pluginType = FindPluginType(implAssembly, manifest.Id);
|
||||||
|
var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
|
||||||
|
|
||||||
|
schedule.RegisterPlugin(manifest.Id, implAssembly);
|
||||||
|
var ctx = new PluginContext(manifest.Id, world, services, schedule, events);
|
||||||
|
|
||||||
|
instance.Configure(ctx);
|
||||||
|
|
||||||
|
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
|
||||||
|
return manifest.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
|
||||||
|
/// reference to the ALC so a caller can verify it actually collected —
|
||||||
|
/// see the leak test this exists for in
|
||||||
|
/// Engine.ConformanceHarness/AlcUnloadTests.cs.
|
||||||
|
/// </summary>
|
||||||
|
public WeakReference Unload(string pluginId)
|
||||||
|
{
|
||||||
|
if (!_loaded.Remove(pluginId, out var loaded))
|
||||||
|
throw new InvalidOperationException($"Plugin '{pluginId}' is not loaded.");
|
||||||
|
|
||||||
|
loaded.Instance.Shutdown(loaded.Context);
|
||||||
|
|
||||||
|
var weakAlc = new WeakReference(loaded.Alc);
|
||||||
|
loaded.Alc.Unload();
|
||||||
|
|
||||||
|
return weakAlc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginManifest ReadManifest(string pluginDirectory)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(pluginDirectory, "plugin.json");
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
throw new FileNotFoundException($"No plugin.json found in '{pluginDirectory}'.", path);
|
||||||
|
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<PluginManifest>(json, ManifestOptions)
|
||||||
|
?? throw new InvalidOperationException($"'{path}' did not deserialize to a plugin manifest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
|
||||||
|
{
|
||||||
|
var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts);
|
||||||
|
var name = AssemblyName.GetAssemblyName(contractsPath).Name;
|
||||||
|
|
||||||
|
var alreadyLoaded = AssemblyLoadContext.Default.Assemblies
|
||||||
|
.Any(a => a.GetName().Name == name);
|
||||||
|
|
||||||
|
if (!alreadyLoaded)
|
||||||
|
AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type FindPluginType(Assembly assembly, string pluginId)
|
||||||
|
{
|
||||||
|
var candidates = assembly.GetTypes()
|
||||||
|
.Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IPlugin).IsAssignableFrom(t))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return candidates.Count switch
|
||||||
|
{
|
||||||
|
0 => throw new InvalidOperationException(
|
||||||
|
$"'{assembly.GetName().Name}' (plugin '{pluginId}') has no IPlugin implementation."),
|
||||||
|
> 1 => throw new InvalidOperationException(
|
||||||
|
$"'{assembly.GetName().Name}' (plugin '{pluginId}') has more than one IPlugin " +
|
||||||
|
$"implementation: {string.Join(", ", candidates.Select(t => t.FullName))}."),
|
||||||
|
_ => candidates[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The collectible ALC a plugin's implementation assembly loads into. See
|
||||||
|
/// docs/kernel-contract.md §4.
|
||||||
|
///
|
||||||
|
/// The one thing this has to get right: <see cref="Load"/> must defer to
|
||||||
|
/// whatever's already sitting in the Default ALC — Engine.Kernel, and this
|
||||||
|
/// plugin's own Contracts assembly, both loaded there before this context
|
||||||
|
/// exists — rather than loading a second copy of either. A second copy
|
||||||
|
/// would carry its own distinct runtime <c>Type</c> objects, and every
|
||||||
|
/// <c>is T</c> / <c>GetComponent<T>()</c> check across the plugin
|
||||||
|
/// boundary would silently fail. Only a genuinely private dependency of
|
||||||
|
/// this specific plugin should ever load through this context.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PluginLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
private readonly AssemblyDependencyResolver _resolver;
|
||||||
|
|
||||||
|
public PluginLoadContext(string pluginId, string mainAssemblyPath)
|
||||||
|
: base(name: $"plugin:{pluginId}", isCollectible: true)
|
||||||
|
{
|
||||||
|
_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
var alreadyInDefault = Default.Assemblies
|
||||||
|
.Any(a => a.GetName().Name == assemblyName.Name);
|
||||||
|
|
||||||
|
if (alreadyInDefault)
|
||||||
|
return null; // defer — the runtime resolves this to the Default copy
|
||||||
|
|
||||||
|
var path = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||||
|
return path is not null ? LoadFromAssemblyPath(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||||
|
{
|
||||||
|
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||||
|
return path is not null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Scheduling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bookkeeping only for now — no stage execution, no parallelism, no
|
||||||
|
/// enforcement of declared access. That's the real Scheduler work described
|
||||||
|
/// in docs/kernel-contract.md §2 and §7; this pass exists to make
|
||||||
|
/// <see cref="RemoveAllFrom"/> actually correct, because it's the one thing
|
||||||
|
/// PluginHost's reload correctness depends on.
|
||||||
|
///
|
||||||
|
/// Ownership is tracked by the registering delegate's declaring assembly,
|
||||||
|
/// not by a string tag on each entry — a plugin can only accidentally
|
||||||
|
/// mislabel a system if it hands another plugin's delegate to Add(), which
|
||||||
|
/// isn't a realistic failure mode. See <see cref="RegisterPlugin"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Schedule : ISchedule
|
||||||
|
{
|
||||||
|
private readonly List<SystemEntry> _systems = [];
|
||||||
|
private readonly Dictionary<string, Assembly> _pluginAssemblies = [];
|
||||||
|
|
||||||
|
/// <summary>Called by PluginHost right after loading a plugin's
|
||||||
|
/// implementation assembly, before Configure() runs.</summary>
|
||||||
|
internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
|
||||||
|
=> _pluginAssemblies[pluginId] = implementationAssembly;
|
||||||
|
|
||||||
|
public ISystemBuilder Add(Stage stage, Delegate system)
|
||||||
|
{
|
||||||
|
var entry = new SystemEntry(stage, system);
|
||||||
|
_systems.Add(entry);
|
||||||
|
return new SystemBuilder(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveAllFrom(string pluginId)
|
||||||
|
{
|
||||||
|
// Removing the assembly mapping here too, not just the systems —
|
||||||
|
// leaving it behind would itself be a stray reference into the
|
||||||
|
// ALC the caller is about to unload.
|
||||||
|
if (_pluginAssemblies.Remove(pluginId, out var assembly))
|
||||||
|
_systems.RemoveAll(e => e.System.Method.DeclaringType?.Assembly == assembly);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Engine.Kernel.Scheduling;
|
||||||
|
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
|
internal sealed class SystemBuilder(SystemEntry entry) : ISystemBuilder
|
||||||
|
{
|
||||||
|
public ISystemBuilder After(string systemId)
|
||||||
|
{
|
||||||
|
entry.After = systemId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISystemBuilder Reads<T>() where T : Component
|
||||||
|
{
|
||||||
|
entry.Reads.Add(typeof(T));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISystemBuilder Writes<T>() where T : Component
|
||||||
|
{
|
||||||
|
entry.Writes.Add(typeof(T));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Engine.Kernel.Scheduling;
|
||||||
|
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One registered system. Reads/Writes are recorded here so a future
|
||||||
|
/// Scheduler can build the conflict graph described in docs/kernel-contract.md
|
||||||
|
/// §2 — nothing consumes them yet; that's real Scheduler work, not this pass.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SystemEntry(Stage stage, Delegate system)
|
||||||
|
{
|
||||||
|
public Stage Stage { get; } = stage;
|
||||||
|
public Delegate System { get; } = system;
|
||||||
|
public string? After { get; set; }
|
||||||
|
public HashSet<Type> Reads { get; } = [];
|
||||||
|
public HashSet<Type> Writes { get; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Engine.Kernel.Services;
|
||||||
|
|
||||||
|
public sealed class ServiceRegistry : IServiceRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, object> _services = [];
|
||||||
|
|
||||||
|
public void Provide<T>(T instance) where T : class
|
||||||
|
{
|
||||||
|
if (!_services.TryAdd(typeof(T), instance))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"A service for '{typeof(T).FullName}' is already registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Require<T>() where T : class
|
||||||
|
{
|
||||||
|
if (_services.TryGetValue(typeof(T), out var instance))
|
||||||
|
return (T)instance;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No service is registered for '{typeof(T).FullName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op if absent, deliberately: Shutdown() is expected to revoke
|
||||||
|
// unconditionally, including services a partially-failed Configure()
|
||||||
|
// never got around to providing.
|
||||||
|
public void Revoke<T>() where T : class
|
||||||
|
=> _services.Remove(typeof(T));
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
using System.Runtime.Loader;
|
||||||
|
using Engine.Kernel.Events;
|
||||||
|
using Engine.Kernel.Plugins;
|
||||||
|
using Engine.Kernel.Scheduling;
|
||||||
|
using Engine.Kernel.Services;
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
namespace Engine.ConformanceHarness;
|
namespace Engine.ConformanceHarness;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,20 +12,70 @@ namespace Engine.ConformanceHarness;
|
|||||||
/// architecture from slowly degrading: load and unload a plugin 200 times,
|
/// architecture from slowly degrading: load and unload a plugin 200 times,
|
||||||
/// and after every cycle verify the ALC actually collected. Runs against
|
/// and after every cycle verify the ALC actually collected. Runs against
|
||||||
/// Sandbox.Echo — see the ReferenceOutputAssembly="false" note in this
|
/// Sandbox.Echo — see the ReferenceOutputAssembly="false" note in this
|
||||||
/// project's .csproj for why that reference doesn't link its types in.
|
/// project's .csproj for why that reference doesn't link its types in, and
|
||||||
|
/// the CopyToOutputDirectory items for how its built DLLs end up sitting
|
||||||
|
/// next to plugin.json under this project's own output.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AlcUnloadTests
|
public class AlcUnloadTests
|
||||||
{
|
{
|
||||||
// TODO(M0): once PluginHost exists —
|
private static string PluginDirectory =>
|
||||||
// for (int i = 0; i < 200; i++) {
|
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
|
||||||
// var handle = host.Load("sandbox.echo");
|
|
||||||
// var weakAlc = host.Unload(handle);
|
private static PluginHost NewHost() =>
|
||||||
// GC.Collect(); GC.WaitForPendingFinalizers();
|
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
|
||||||
// Assert.False(weakAlc.IsAlive, $"ALC survived cycle {i}");
|
|
||||||
// }
|
[Fact]
|
||||||
// and assert working-set memory hasn't grown beyond noise.
|
|
||||||
[Fact(Skip = "PluginHost has no implementation yet — see M0 in docs/kernel-contract.md §8.")]
|
|
||||||
public void Plugin_Survives_200_Load_Unload_Cycles()
|
public void Plugin_Survives_200_Load_Unload_Cycles()
|
||||||
{
|
{
|
||||||
|
var host = NewHost();
|
||||||
|
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
var id = host.Load(PluginDirectory);
|
||||||
|
var weakAlc = host.Unload(id);
|
||||||
|
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
|
||||||
|
Assert.False(weakAlc.IsAlive, $"ALC survived unload cycle {i}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The WeakReference check above only proves the collectible ALC
|
||||||
|
// let go. It says nothing about the Default ALC, which is never
|
||||||
|
// supposed to grow at all across reloads — Contracts loads once
|
||||||
|
// and every later cycle should find it already there. A count
|
||||||
|
// above 1 here would be a real, separate leak this test would
|
||||||
|
// otherwise miss entirely.
|
||||||
|
var contractsCopies = AssemblyLoadContext.Default.Assemblies
|
||||||
|
.Count(a => a.GetName().Name == "Sandbox.Echo.Contracts");
|
||||||
|
Assert.Equal(1, contractsCopies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Configures_The_Plugin_Without_Throwing()
|
||||||
|
{
|
||||||
|
var host = NewHost();
|
||||||
|
|
||||||
|
var id = host.Load(PluginDirectory);
|
||||||
|
|
||||||
|
Assert.Equal("sandbox.echo", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Throws_When_The_Same_Plugin_Is_Already_Loaded()
|
||||||
|
{
|
||||||
|
var host = NewHost();
|
||||||
|
host.Load(PluginDirectory);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unload_Throws_When_The_Plugin_Was_Never_Loaded()
|
||||||
|
{
|
||||||
|
var host = NewHost();
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => host.Unload("sandbox.echo"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,26 @@
|
|||||||
ReferenceOutputAssembly="false" />
|
ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Flatten plugin.json + both built DLLs into one directory under this
|
||||||
|
project's own output — plugins/sandbox.echo/ — matching the flat
|
||||||
|
layout PluginHost.Load(directory) expects. The ProjectReference
|
||||||
|
above guarantees these DLLs exist by the time this runs (transitive
|
||||||
|
build order also covers Sandbox.Echo.Contracts, which Sandbox.Echo
|
||||||
|
itself references). $(Configuration)/$(TargetFramework) keep this
|
||||||
|
correct in Release, not just Debug. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\plugins\sandbox.echo\plugin.json">
|
||||||
|
<Link>plugins\sandbox.echo\plugin.json</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\..\plugins\sandbox.echo\Sandbox.Echo.Contracts\bin\$(Configuration)\$(TargetFramework)\Sandbox.Echo.Contracts.dll">
|
||||||
|
<Link>plugins\sandbox.echo\Sandbox.Echo.Contracts.dll</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="..\..\plugins\sandbox.echo\Sandbox.Echo\bin\$(Configuration)\$(TargetFramework)\Sandbox.Echo.dll">
|
||||||
|
<Link>plugins\sandbox.echo\Sandbox.Echo.dll</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Engine.Kernel.Events;
|
||||||
|
using Engine.Kernel.Plugins;
|
||||||
|
using Engine.Kernel.Scheduling;
|
||||||
|
using Engine.Kernel.Services;
|
||||||
|
using Engine.Kernel.World;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge cases that don't need a real, compiled plugin — see
|
||||||
|
/// Engine.ConformanceHarness/AlcUnloadTests.cs for the load/unload/reload
|
||||||
|
/// path exercised against a real one (Sandbox.Echo).
|
||||||
|
/// </summary>
|
||||||
|
public class PluginHostTests
|
||||||
|
{
|
||||||
|
private static PluginHost NewHost() =>
|
||||||
|
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_Throws_When_The_Directory_Has_No_Manifest()
|
||||||
|
{
|
||||||
|
var host = NewHost();
|
||||||
|
var emptyDir = Directory.CreateTempSubdirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Throws<FileNotFoundException>(() => host.Load(emptyDir.FullName));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
emptyDir.Delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user