diff --git a/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj b/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj index 01983e7..2112dd8 100644 --- a/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj +++ b/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj @@ -1,5 +1,12 @@  + + + true + + diff --git a/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs b/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs new file mode 100644 index 0000000..4089a04 --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs @@ -0,0 +1,10 @@ +namespace Engine.Kernel.Diagnostics; + +/// Prefixes every line with the owning plugin's id — one instance +/// per , not shared. +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}"); +} diff --git a/src/Engine.Kernel/Events/NullEventBus.cs b/src/Engine.Kernel/Events/NullEventBus.cs new file mode 100644 index 0000000..c835d79 --- /dev/null +++ b/src/Engine.Kernel/Events/NullEventBus.cs @@ -0,0 +1,7 @@ +namespace Engine.Kernel.Events; + +/// +/// IEventBus has no members yet — see the open question in +/// docs/kernel-contract.md's footer. Nothing to implement until it does. +/// +public sealed class NullEventBus : IEventBus; diff --git a/src/Engine.Kernel/Plugins/LoadedPlugin.cs b/src/Engine.Kernel/Plugins/LoadedPlugin.cs new file mode 100644 index 0000000..0030812 --- /dev/null +++ b/src/Engine.Kernel/Plugins/LoadedPlugin.cs @@ -0,0 +1,7 @@ +namespace Engine.Kernel.Plugins; + +internal sealed record LoadedPlugin( + PluginManifest Manifest, + PluginLoadContext Alc, + IPlugin Instance, + IPluginContext Context); diff --git a/src/Engine.Kernel/Plugins/PluginContext.cs b/src/Engine.Kernel/Plugins/PluginContext.cs new file mode 100644 index 0000000..38d8688 --- /dev/null +++ b/src/Engine.Kernel/Plugins/PluginContext.cs @@ -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; + +/// 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. +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); +} diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs new file mode 100644 index 0000000..8b0783a --- /dev/null +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -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; + +/// +/// 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 dependsOn 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. +/// +public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events) +{ + private static readonly JsonSerializerOptions ManifestOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly Dictionary _loaded = []; + + /// + /// Loads the plugin described by plugin.json in + /// . Contracts load into the Default + /// ALC; the implementation loads into a fresh collectible ALC. Returns + /// the plugin's id. + /// + 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; + } + + /// + /// 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. + /// + 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(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], + }; + } +} diff --git a/src/Engine.Kernel/Plugins/PluginLoadContext.cs b/src/Engine.Kernel/Plugins/PluginLoadContext.cs new file mode 100644 index 0000000..845cae8 --- /dev/null +++ b/src/Engine.Kernel/Plugins/PluginLoadContext.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Engine.Kernel.Plugins; + +/// +/// The collectible ALC a plugin's implementation assembly loads into. See +/// docs/kernel-contract.md §4. +/// +/// The one thing this has to get right: 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 Type objects, and every +/// is T / GetComponent<T>() check across the plugin +/// boundary would silently fail. Only a genuinely private dependency of +/// this specific plugin should ever load through this context. +/// +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; + } +} diff --git a/src/Engine.Kernel/Scheduling/Schedule.cs b/src/Engine.Kernel/Scheduling/Schedule.cs new file mode 100644 index 0000000..c09cfe8 --- /dev/null +++ b/src/Engine.Kernel/Scheduling/Schedule.cs @@ -0,0 +1,42 @@ +using System.Reflection; + +namespace Engine.Kernel.Scheduling; + +/// +/// 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 +/// 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 . +/// +public sealed class Schedule : ISchedule +{ + private readonly List _systems = []; + private readonly Dictionary _pluginAssemblies = []; + + /// Called by PluginHost right after loading a plugin's + /// implementation assembly, before Configure() runs. + 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); + } +} diff --git a/src/Engine.Kernel/Scheduling/SystemBuilder.cs b/src/Engine.Kernel/Scheduling/SystemBuilder.cs new file mode 100644 index 0000000..f2f33ab --- /dev/null +++ b/src/Engine.Kernel/Scheduling/SystemBuilder.cs @@ -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() where T : Component + { + entry.Reads.Add(typeof(T)); + return this; + } + + public ISystemBuilder Writes() where T : Component + { + entry.Writes.Add(typeof(T)); + return this; + } +} diff --git a/src/Engine.Kernel/Scheduling/SystemEntry.cs b/src/Engine.Kernel/Scheduling/SystemEntry.cs new file mode 100644 index 0000000..b02eac3 --- /dev/null +++ b/src/Engine.Kernel/Scheduling/SystemEntry.cs @@ -0,0 +1,17 @@ +namespace Engine.Kernel.Scheduling; + +using Engine.Kernel.World; + +/// +/// 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. +/// +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 Reads { get; } = []; + public HashSet Writes { get; } = []; +} diff --git a/src/Engine.Kernel/Services/ServiceRegistry.cs b/src/Engine.Kernel/Services/ServiceRegistry.cs new file mode 100644 index 0000000..605fd84 --- /dev/null +++ b/src/Engine.Kernel/Services/ServiceRegistry.cs @@ -0,0 +1,28 @@ +namespace Engine.Kernel.Services; + +public sealed class ServiceRegistry : IServiceRegistry +{ + private readonly Dictionary _services = []; + + public void Provide(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() 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() where T : class + => _services.Remove(typeof(T)); +} diff --git a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs index 33d1fca..fab7870 100644 --- a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs +++ b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs @@ -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; /// @@ -5,20 +12,70 @@ namespace Engine.ConformanceHarness; /// architecture from slowly degrading: load and unload a plugin 200 times, /// and after every cycle verify the ALC actually collected. Runs against /// 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. /// public class AlcUnloadTests { - // TODO(M0): once PluginHost exists — - // for (int i = 0; i < 200; i++) { - // var handle = host.Load("sandbox.echo"); - // var weakAlc = host.Unload(handle); - // GC.Collect(); GC.WaitForPendingFinalizers(); - // Assert.False(weakAlc.IsAlive, $"ALC survived cycle {i}"); - // } - // 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.")] + private static string PluginDirectory => + Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo"); + + private static PluginHost NewHost() => + new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus()); + + [Fact] 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(() => host.Load(PluginDirectory)); + } + + [Fact] + public void Unload_Throws_When_The_Plugin_Was_Never_Loaded() + { + var host = NewHost(); + + Assert.Throws(() => host.Unload("sandbox.echo")); } } diff --git a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj index 938b1e7..37079ea 100644 --- a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj +++ b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj @@ -26,4 +26,26 @@ ReferenceOutputAssembly="false" /> + + + + plugins\sandbox.echo\plugin.json + PreserveNewest + + + plugins\sandbox.echo\Sandbox.Echo.Contracts.dll + PreserveNewest + + + plugins\sandbox.echo\Sandbox.Echo.dll + PreserveNewest + + + diff --git a/tests/Engine.Kernel.Tests/PluginHostTests.cs b/tests/Engine.Kernel.Tests/PluginHostTests.cs new file mode 100644 index 0000000..2d1e5ea --- /dev/null +++ b/tests/Engine.Kernel.Tests/PluginHostTests.cs @@ -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; + +/// +/// 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). +/// +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(() => host.Load(emptyDir.FullName)); + } + finally + { + emptyDir.Delete(recursive: true); + } + } +}