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:
Emil
2026-09-02 00:47:23 +03:00
co-authored by Claude Sonnet 5
parent f040f71045
commit 978f34727f
14 changed files with 458 additions and 10 deletions
@@ -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}");
}
+7
View File
@@ -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);
}
+123
View File
@@ -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&lt;T&gt;()</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;
}
}
+42
View File
@@ -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));
}