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
47 lines
1.8 KiB
C#
47 lines
1.8 KiB
C#
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;
|
|
}
|
|
}
|