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:
@@ -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; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user