From 978f34727f961d750770e20d504953ed3fa0a644 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 00:47:23 +0300 Subject: [PATCH] Implement PluginHost: two-ALC plugin loading, unloading verified live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 true (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 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- .../Sandbox.Echo/Sandbox.Echo.csproj | 7 + .../Diagnostics/ConsoleLogger.cs | 10 ++ src/Engine.Kernel/Events/NullEventBus.cs | 7 + src/Engine.Kernel/Plugins/LoadedPlugin.cs | 7 + src/Engine.Kernel/Plugins/PluginContext.cs | 24 ++++ src/Engine.Kernel/Plugins/PluginHost.cs | 123 ++++++++++++++++++ .../Plugins/PluginLoadContext.cs | 46 +++++++ src/Engine.Kernel/Scheduling/Schedule.cs | 42 ++++++ src/Engine.Kernel/Scheduling/SystemBuilder.cs | 24 ++++ src/Engine.Kernel/Scheduling/SystemEntry.cs | 17 +++ src/Engine.Kernel/Services/ServiceRegistry.cs | 28 ++++ .../AlcUnloadTests.cs | 77 +++++++++-- .../Engine.ConformanceHarness.csproj | 22 ++++ tests/Engine.Kernel.Tests/PluginHostTests.cs | 34 +++++ 14 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 src/Engine.Kernel/Diagnostics/ConsoleLogger.cs create mode 100644 src/Engine.Kernel/Events/NullEventBus.cs create mode 100644 src/Engine.Kernel/Plugins/LoadedPlugin.cs create mode 100644 src/Engine.Kernel/Plugins/PluginContext.cs create mode 100644 src/Engine.Kernel/Plugins/PluginHost.cs create mode 100644 src/Engine.Kernel/Plugins/PluginLoadContext.cs create mode 100644 src/Engine.Kernel/Scheduling/Schedule.cs create mode 100644 src/Engine.Kernel/Scheduling/SystemBuilder.cs create mode 100644 src/Engine.Kernel/Scheduling/SystemEntry.cs create mode 100644 src/Engine.Kernel/Services/ServiceRegistry.cs create mode 100644 tests/Engine.Kernel.Tests/PluginHostTests.cs 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); + } + } +}