From 95f4be94db100f5602a56c0a80d12ec3d113221b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:12:59 +0200 Subject: [PATCH 1/3] feat(plugins): complete Campaign LA5 cross-host hosting --- AcDream.slnx | 1 + docs/architecture/acdream-architecture.md | 18 +- src/AcDream.App/Plugins/AppPluginHost.cs | 1 + .../Plugins/GraphicalPluginSession.cs | 78 ++++ src/AcDream.App/Program.cs | 80 +--- src/AcDream.App/Rendering/GameWindow.cs | 1 + src/AcDream.Core/Plugins/PluginLoader.cs | 20 +- src/AcDream.Core/Plugins/PluginSession.cs | 361 ++++++++++++++++++ .../Hosting/HeadlessProcessHost.cs | 8 +- .../Hosting/HeadlessSessionHost.cs | 28 +- .../Platform/HeadlessPathSet.cs | 3 + .../Plugins/HeadlessPluginHost.cs | 150 ++++++++ .../Plugins/HeadlessPluginLogger.cs | 43 +++ .../Plugins/HeadlessPluginSession.cs | 109 ++++++ .../IPluginHost.cs | 8 + .../IUiRegistry.cs | 24 +- .../Session/SessionStatusWriter.cs | 35 +- .../AcDream.App.Tests.csproj | 9 + .../Plugins/GraphicalPluginSessionTests.cs | 202 ++++++++++ .../Plugins/PluginLoaderTests.cs | 3 + .../Plugins/PluginSessionTests.cs | 201 ++++++++++ .../AcDream.Headless.Tests.csproj | 9 + .../HeadlessPluginSessionTests.cs | 235 ++++++++++++ ...am.Plugin.Tests.Fixtures.HostPlugin.csproj | 19 + .../HostPlugin.cs | 46 +++ .../Session/SessionStatusWriterTests.cs | 37 +- 26 files changed, 1630 insertions(+), 99 deletions(-) create mode 100644 src/AcDream.App/Plugins/GraphicalPluginSession.cs create mode 100644 src/AcDream.Core/Plugins/PluginSession.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginHost.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginSession.cs create mode 100644 tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs create mode 100644 tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs diff --git a/AcDream.slnx b/AcDream.slnx index 20f17f98..2dc2d0fd 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -28,6 +28,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4696577b..855aa04d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels never inspect or construct wire messages. Plugins register retained gameplay markup through the BCL-only `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or -presentation assemblies. Core `SelectionState` is the sole selected-object owner for world, +presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge: +the graphical host supplies its retained registry, while no-window hosts +return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin +binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator +and the same config allow-list semantics (absent loads all; explicit empty +loads none). The headless adapter projects entity snapshots on demand from the +canonical Runtime view, subscribes to Runtime's ordered events, and borrows the +exact Runtime selection owner; it does not mirror gameplay state. + +Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. Temporary pointer modes are separate App orchestration in `InteractionState` and @@ -174,6 +183,9 @@ parallel window-lifecycle map. ``` src/ AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic + Plugins/ + PluginSession.cs -> shared per-host allow-list, failure isolation, + status outcome, and collectible ALC lifetime Physics/ PhysicsBody.cs -> body state / integration foundation (done) CollisionPrimitives.cs -> retail primitive helpers (partial, active) @@ -288,6 +300,8 @@ src/ Configuration/ -> strict versioned process/session config Credentials/ -> redacted env/stdin/owner-only-file providers Hosting/ -> one GameRuntime/session/lease/policy lifetime + Plugins/ -> no-window IPluginHost borrowing Runtime/Core; + BCL no-op UI and per-session plugin lifetime Policies/ -> typed Runtime-view/command consumers -> references Runtime only; no presentation/backend package -> Slice K complete: portable single/multi-session production host, @@ -298,6 +312,7 @@ src/ AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces IAcDreamPlugin.cs -> done IPluginHost.cs -> done + IUiRegistry.cs -> capability-aware retained/no-op UI contract IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done @@ -352,6 +367,7 @@ src/ PlayerMovementController.cs -> active movement driver Plugins/ AppPluginHost.cs -> done + GraphicalPluginSession.cs -> thin shared-session/root/status adapter ``` The 4B2 production SetPosition routes and shared local-controller body remain diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index bfabab86..dc81ec44 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost Ui = ui; } + public bool HasUi => true; public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs new file mode 100644 index 00000000..a41ceaab --- /dev/null +++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs @@ -0,0 +1,78 @@ +using AcDream.Core.Plugins; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Session; + +namespace AcDream.App.Plugins; + +/// +/// Graphical-host composition for one plugin set. The shared +/// owns discovery and collectible lifetimes; this +/// adapter supplies the graphical roots and translates outcomes into the +/// launcher status stream. +/// +internal sealed class GraphicalPluginSession : IDisposable +{ + private readonly PluginSession _plugins; + + private GraphicalPluginSession(PluginSession plugins) + { + _plugins = plugins; + } + + internal int LoadedCount => _plugins.LoadedCount; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static GraphicalPluginSession Start( + ApplicationPathSet paths, + IReadOnlyList? allowList, + string sessionId, + IPluginHost host, + SessionStatusWriter statusWriter) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(statusWriter); + + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + try + { + plugins.Start( + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ], + allowList); + return new GraphicalPluginSession(plugins); + } + catch + { + plugins.Dispose(); + throw; + } + } + + public void Dispose() => _plugins.Dispose(); + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 52e647be..7b2c6265 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -4,7 +4,6 @@ using AcDream.App.Credentials; using AcDream.App.Plugins; using AcDream.App.Platform; using AcDream.App.Rendering; -using AcDream.Core.Plugins; using AcDream.Platform; using Serilog; @@ -162,76 +161,15 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry); - -var loaded = new List(); -var loadedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); -StringComparer pathComparer = - graphicalPlatform.OperatingSystem - == GraphicalHostOperatingSystem.Windows - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; -string[] pluginRoots = -[ - .. new[] - { - Path.Combine(AppContext.BaseDirectory, "plugins"), - applicationPaths.PluginsDirectory, - }.Distinct(pathComparer), -]; - -foreach (string pluginsDir in pluginRoots) -{ - Log.Information("scanning plugins in {PluginsDir}", pluginsDir); - foreach (var result in PluginDiscovery.Scan(pluginsDir)) - { - if (!result.Success) - { - Log.Warning( - "plugin discovery failed for {Dir}: {Error}", - result.PluginDirectory, - result.Error); - continue; - } - - if (loadedPluginIds.Contains(result.Manifest!.Id)) - { - Log.Warning( - "skipping duplicate plugin id {Id} from {Dir}", - result.Manifest.Id, - result.PluginDirectory); - continue; - } - - var loadResult = PluginLoader.Load( - result.PluginDirectory, - result.Manifest, - host); - if (!loadResult.Success) - { - Log.Warning( - "plugin load failed for {Id}: {Error}", - result.Manifest.Id, - loadResult.Error); - continue; - } - - loadedPluginIds.Add(result.Manifest.Id); - loaded.Add(loadResult); - Log.Information( - "loaded plugin {Id} ({DisplayName})", - result.Manifest.Id, - result.Manifest.DisplayName); - } -} +using var pluginSession = GraphicalPluginSession.Start( + applicationPaths, + runtimeOptions.Plugins, + runtimeOptions.SessionId ?? "app", + host, + window.StatusWriter); try { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Enable(); } - catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } - } - try { window.Run(); @@ -244,11 +182,7 @@ try } finally { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Disable(); } - catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); } - } + pluginSession.Dispose(); Log.CloseAndFlush(); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f3be7ef2..a3ec07eb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -362,6 +362,7 @@ public sealed class GameWindow : private RuntimeActionState _runtimeActions => _runtime.ActionOwner; public AcDream.Core.Selection.SelectionState Selection => _runtimeActions.Selection; + internal SessionStatusWriter StatusWriter => _statusWriter; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.TurbineChatState TurbineChat => _runtimeCommunication.TurbineChat; diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index ba2ba07d..85581642 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -14,6 +14,10 @@ public static class PluginLoader /// public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(host); + var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll); if (!File.Exists(dllPath)) return new LoadedPlugin( @@ -22,9 +26,11 @@ public static class PluginLoader LoadContext: null, Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath)); + PluginAssemblyLoadContext? alc = null; + IAcDreamPlugin? instance = null; try { - var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); + alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); var asm = alc.LoadFromAssemblyPath(dllPath); IEnumerable types; @@ -41,19 +47,29 @@ public static class PluginLoader .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); if (pluginType is null) + { + alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, LoadContext: null, Error: new InvalidOperationException( $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + } - var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; + instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance.Initialize(host); return new LoadedPlugin(manifest, instance, alc, Error: null); } catch (Exception ex) { + // Initialize may have attached host callbacks before it failed. + // Give that partial instance the same best-effort cleanup chance + // as an Enable failure before releasing the collectible context. + try { instance?.Disable(); } + catch { } + try { alc?.Unload(); } + catch { } return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs new file mode 100644 index 00000000..5c939fd7 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -0,0 +1,361 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public enum PluginSessionStatusKind +{ + Loaded, + Failed, +} + +/// +/// Final startup outcome for one configured plugin id. Hosts translate these +/// outcomes into their own diagnostics and the Campaign LA status stream. +/// +public readonly record struct PluginSessionStatus( + string Plugin, + PluginSessionStatusKind Kind, + string? Error = null); + +/// +/// One host/session-scoped plugin lifetime. Discovery, allow-listing, +/// initialize/enable, failure isolation, reverse-order disable, and collectible +/// load-context release are shared by graphical and no-window hosts so their +/// configured plugin-set semantics cannot drift. +/// +public sealed class PluginSession : IDisposable +{ + private readonly IPluginHost _host; + private readonly Action? _report; + private readonly List _loaded = []; + private bool _started; + private bool _disposed; + + public PluginSession( + IPluginHost host, + Action? report = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _report = report; + } + + public int LoadedCount => _loaded.Count; + + public IReadOnlyList LoadedPluginIds => + _loaded.Select(static plugin => plugin.Manifest.Id).ToArray(); + + /// + /// Discovers and starts the configured set exactly once. A + /// allow-list loads every discovered id; an explicit + /// empty list loads none. Matching and duplicate-id handling are + /// ordinal-ignore-case on every operating system because plugin ids are + /// logical identifiers, not paths. + /// + public void Start( + IEnumerable pluginRoots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(pluginRoots); + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException("The plugin session has already started."); + _started = true; + + string[] roots = DistinctRoots(pluginRoots); + string[]? requested = allowList is null + ? null + : allowList + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (requested is { Length: 0 }) + return; + + var candidates = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var errors = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var discoveredOrder = new List(); + HashSet? requestedSet = requested is null + ? null + : new HashSet(requested, StringComparer.OrdinalIgnoreCase); + + foreach (string root in roots) + { + IReadOnlyList results; + try + { + results = PluginDiscovery.Scan(root); + } + catch (Exception error) when (IsDiscoveryFailure(error)) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin discovery failed for root '{root}'", + error); + continue; + } + + foreach (PluginDiscoveryResult result in results) + { + if (!result.Success) + { + string directoryId = Path.GetFileName( + Path.TrimEndingDirectorySeparator(result.PluginDirectory)); + if (string.IsNullOrWhiteSpace(directoryId) + || (requestedSet is not null + && !requestedSet.Contains(directoryId))) + { + continue; + } + + AddOrdered(discoveredOrder, directoryId); + AddError( + errors, + directoryId, + result.Error ?? new InvalidOperationException( + "plugin discovery failed")); + continue; + } + + string id = result.Manifest!.Id; + if (requestedSet is not null && !requestedSet.Contains(id)) + continue; + AddOrdered(discoveredOrder, id); + if (!candidates.TryGetValue(id, out List? list)) + { + list = []; + candidates.Add(id, list); + } + list.Add(result); + } + } + + IEnumerable loadOrder = requested is null + ? discoveredOrder + : requested; + foreach (string id in loadOrder) + LoadOne(id, candidates, errors); + } + + /// + /// Test/diagnostic observation of the exact collectible contexts currently + /// owned by this session. The returned weak references do not delay unload. + /// + public IReadOnlyList CaptureLoadContextWeakReferences() => + _loaded + .Select(static plugin => new WeakReference(plugin.LoadContext!)) + .ToArray(); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + for (int index = _loaded.Count - 1; index >= 0; index--) + { + LoadedPlugin loaded = _loaded[index]; + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin disable failed: {loaded.Manifest.Id}", + error); + } + + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload failed: {loaded.Manifest.Id}", + error); + } + } + + // Drop both plugin instances and AssemblyLoadContext references. The + // CLR completes collectible unload after no plugin-owned object remains + // reachable and a normal GC cycle observes the contexts. + _loaded.Clear(); + } + + private void LoadOne( + string id, + IReadOnlyDictionary> candidates, + Dictionary> errors) + { + if (candidates.TryGetValue(id, out List? available)) + { + foreach (PluginDiscoveryResult candidate in available) + { + LoadedPlugin loaded = PluginLoader.Load( + candidate.PluginDirectory, + candidate.Manifest!, + _host); + if (!loaded.Success) + { + AddError( + errors, + id, + loaded.Error ?? new InvalidOperationException( + "plugin load failed")); + continue; + } + + try + { + loaded.Plugin!.Enable(); + _loaded.Add(loaded); + SafeLog( + static (log, message, _) => log.Info(message), + $"plugin loaded: {loaded.Manifest.Id} " + + $"({loaded.Manifest.DisplayName})", + null); + Report(new PluginSessionStatus( + loaded.Manifest.Id, + PluginSessionStatusKind.Loaded)); + return; + } + catch (Exception error) + { + AddError(errors, id, error); + ReleaseFailedEnable(loaded); + } + } + } + + if (!errors.TryGetValue(id, out List? failures) + || failures.Count == 0) + { + failures = + [ + new FileNotFoundException( + $"plugin '{id}' was not found in the configured plugin roots."), + ]; + } + + string errorText = string.Join( + " | ", + failures.Select(Describe)); + Report(new PluginSessionStatus( + id, + PluginSessionStatusKind.Failed, + errorText)); + SafeLog( + static (log, message, _) => log.Warn(message), + $"plugin failed: {id}: {errorText}", + null); + } + + private void ReleaseFailedEnable(LoadedPlugin loaded) + { + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", + error); + } + + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after enable failure failed: {loaded.Manifest.Id}", + error); + } + } + + private void Report(PluginSessionStatus status) + { + if (_report is null) + return; + try + { + _report(status); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin status observer failed for {status.Plugin}", + error); + } + } + + private void SafeLog( + Action write, + string message, + Exception? error) + { + try { write(_host.Log, message, error); } + catch { } + } + + private static string[] DistinctRoots(IEnumerable roots) + { + StringComparer comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return roots + .Where(static root => !string.IsNullOrWhiteSpace(root)) + .Select(Path.GetFullPath) + .Distinct(comparer) + .ToArray(); + } + + private static void AddOrdered(List ordered, string id) + { + if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase)) + ordered.Add(id); + } + + private static void AddError( + Dictionary> errors, + string id, + Exception error) + { + if (!errors.TryGetValue(id, out List? list)) + { + list = []; + errors.Add(id, list); + } + list.Add(error); + } + + private static string Describe(Exception error) + { + Exception root = error.GetBaseException(); + return string.IsNullOrWhiteSpace(root.Message) + ? root.GetType().Name + : root.Message; + } + + private static bool IsDiscoveryFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Security.SecurityException; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 5d38edb0..c620c52a 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable paths.ConfigDirectory); var sessions = new List( configuration.Sessions.Count); + string[] pluginRoots = + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ]; HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; // FA6: constructed unconditionally — cheap, and every non-gate @@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable sessionOperations, timeProvider, contentLease: contentLease, - gateCoordinator: gateCoordinator)); + gateCoordinator: gateCoordinator, + pluginRoots: pluginRoots)); } catch { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0f6340a1..eb0ce2a5 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1,6 +1,7 @@ using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; +using AcDream.Headless.Plugins; using AcDream.Headless.Policies; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IDisposable _hostLease; private readonly IHeadlessBotPolicy _policy; private readonly IDisposable _policySubscription; + private readonly HeadlessPluginSession _pluginSession; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? @@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable contentLease = null, IHeadlessBotPolicy? policyOverride = null, IRuntimePlacementProjectionSink? placementSinkOverride = null, - FellowshipAllegianceGateCoordinator? gateCoordinator = null) + FellowshipAllegianceGateCoordinator? gateCoordinator = null, + IEnumerable? pluginRoots = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable IDisposable? hostLease = null; IHeadlessBotPolicy? policy = null; IDisposable? policySubscription = null; + HeadlessPluginSession? pluginSession = null; try { var gameplay = new HeadlessGameplayOperations(); @@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); + pluginSession = HeadlessPluginSession.Start( + runtime, + diagnostics, + statusWriter, + descriptor.Id, + pluginRoots ?? [], + descriptor.Plugins); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable _hostLease = hostLease; _policy = policy; _policySubscription = policySubscription; + _pluginSession = pluginSession; } catch { + pluginSession?.Dispose(); policySubscription?.Dispose(); policy?.Dispose(); hostLease?.Dispose(); @@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable /// production code uses to reach the same state. /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; + internal HeadlessPluginSession Plugins => _pluginSession; internal string SessionId => _descriptor.Id; internal string ActiveCharacterName { get; private set; } = string.Empty; @@ -638,22 +652,26 @@ internal sealed class HeadlessSessionHost : IDisposable _disposeStage++; break; case 4: - _hostLease.Dispose(); + _pluginSession.Dispose(); _disposeStage++; break; case 5: - _credential.Dispose(); + _hostLease.Dispose(); _disposeStage++; break; case 6: - Runtime.Dispose(); + _credential.Dispose(); _disposeStage++; break; case 7: - _contentLease?.Dispose(); + Runtime.Dispose(); _disposeStage++; break; case 8: + _contentLease?.Dispose(); + _disposeStage++; + break; + case 9: _diagnostics.Message( _descriptor.Id, "disposed", diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index bdbe1866..e8e110e2 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet( string DataDirectory, string CacheDirectory) { + internal string PluginsDirectory => + Path.Combine(DataDirectory, "plugins"); + internal static HeadlessPathSet Resolve( HeadlessPathOverrides overrides, IHeadlessPlatformEnvironment? platform = null) diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs new file mode 100644 index 00000000..f68c4bc5 --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -0,0 +1,150 @@ +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; + +namespace AcDream.Headless.Plugins; + +/// +/// No-window plugin surface over one exact . State is +/// projected on demand from Runtime's canonical entity view, events come from +/// Runtime's ordered event source, and selection is the exact J5 action owner; +/// this adapter owns no gameplay mirror. +/// +internal sealed class HeadlessPluginHost + : IPluginHost, + IGameState, + IEvents, + IRuntimeEventObserver, + IDisposable +{ + private readonly GameRuntime _runtime; + private readonly IDisposable _eventSubscription; + private readonly object _eventGate = new(); + private Action? _entitySpawned; + private bool _disposed; + + internal HeadlessPluginHost( + GameRuntime runtime, + IPluginLogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + Log = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventSubscription = runtime.Subscribe(this); + } + + public bool HasUi => false; + public IPluginLogger Log { get; } + public IGameState State => this; + public IEvents Events => this; + public ISelectionService Selection => _runtime.ActionOwner.Selection; + public IUiRegistry Ui => NoOpUiRegistry.Instance; + + /// + /// Immutable point-in-time values produced directly from Runtime on each + /// read. The caller owns the returned snapshot list; this host retains no + /// entity collection and therefore cannot become a second gameplay owner. + /// + public IReadOnlyList Entities + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + var visitor = new SnapshotVisitor(_runtime); + _runtime.Entities.Visit(visitor); + return visitor.Snapshots; + } + } + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + ObjectDisposedException.ThrowIf(_disposed, this); + lock (_eventGate) + _entitySpawned += value; + + // Match the graphical WorldEvents contract: a late subscriber + // immediately observes the canonical world that exists now. + foreach (WorldEntitySnapshot snapshot in Entities) + Invoke(value, snapshot); + } + remove + { + if (value is null) + return; + lock (_eventGate) + _entitySpawned -= value; + } + } + + public void Dispose() + { + if (_disposed) + return; + _eventSubscription.Dispose(); + _disposed = true; + lock (_eventGate) + _entitySpawned = null; + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + if (_disposed || delta.Change != RuntimeEntityChange.Registered) + return; + Action? handlers; + lock (_eventGate) + handlers = _entitySpawned; + if (handlers is null) + return; + + WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity); + foreach (Action handler + in handlers.GetInvocationList().Cast>()) + { + Invoke(handler, snapshot); + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) { } + public void OnCommand(in RuntimeCommandDelta delta) { } + public void OnInventory(in RuntimeInventoryDelta delta) { } + public void OnChat(in RuntimeChatDelta delta) { } + public void OnMovement(in RuntimeMovementDelta delta) { } + public void OnPortal(in RuntimePortalDelta delta) { } + public void OnCombat(in RuntimeCombatDelta delta) { } + + private static WorldEntitySnapshot Convert( + GameRuntime runtime, + in RuntimeEntitySnapshot entity) + { + uint sourceId = runtime.EntityObjects.Entities.TryGetActive( + entity.Identity.ServerGuid, + out AcDream.Runtime.Entities.RuntimeEntityRecord record) + ? record.Snapshot.SetupTableId ?? 0u + : 0u; + return new WorldEntitySnapshot( + entity.Identity.LocalEntityId, + sourceId, + entity.Position?.Frame.Origin ?? default, + entity.Position?.Frame.Orientation + ?? System.Numerics.Quaternion.Identity); + } + + private static void Invoke( + Action handler, + WorldEntitySnapshot snapshot) + { + try { handler(snapshot); } + catch { } + } + + private sealed class SnapshotVisitor(GameRuntime runtime) + : IRuntimeEntityVisitor + { + internal List Snapshots { get; } = + new(runtime.Entities.Count); + + public void Visit(in RuntimeEntitySnapshot entity) => + Snapshots.Add(Convert(runtime, entity)); + } +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs new file mode 100644 index 00000000..8b8494c2 --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs @@ -0,0 +1,43 @@ +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Plugins; + +internal sealed class HeadlessPluginLogger : IPluginLogger +{ + private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly string _sessionId; + private readonly Func _generation; + + internal HeadlessPluginLogger( + HeadlessDiagnosticWriter diagnostics, + string sessionId, + Func generation) + { + _diagnostics = diagnostics + ?? throw new ArgumentNullException(nameof(diagnostics)); + _sessionId = sessionId + ?? throw new ArgumentNullException(nameof(sessionId)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + } + + public void Info(string message) => Write("info", message); + public void Warn(string message) => Write("warn", message); + + public void Error(string message, Exception? exception = null) + { + if (exception is not null) + { + _diagnostics.Failure(_sessionId, "plugin", exception); + return; + } + Write("error", message); + } + + private void Write(string level, string message) => + _diagnostics.Message( + _sessionId, + $"plugin-{level}:{message}", + _generation()); +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs new file mode 100644 index 00000000..cff1226a --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -0,0 +1,109 @@ +using AcDream.Core.Plugins; +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Plugins; + +/// +/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload +/// ahead of canonical Runtime disposal. +/// +internal sealed class HeadlessPluginSession : IDisposable +{ + private readonly HeadlessPluginHost _host; + private readonly PluginSession _plugins; + private int _disposeStage; + private bool _disposed; + + private HeadlessPluginSession( + HeadlessPluginHost host, + PluginSession plugins) + { + _host = host; + _plugins = plugins; + } + + internal int LoadedCount => _plugins.LoadedCount; + internal IPluginHost Host => _host; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static HeadlessPluginSession Start( + GameRuntime runtime, + HeadlessDiagnosticWriter diagnostics, + SessionStatusWriter statusWriter, + string sessionId, + IEnumerable roots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(diagnostics); + ArgumentNullException.ThrowIfNull(statusWriter); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(roots); + + var host = new HeadlessPluginHost( + runtime, + new HeadlessPluginLogger( + diagnostics, + sessionId, + () => runtime.Generation.Value)); + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + try + { + plugins.Start(roots, allowList); + return new HeadlessPluginSession(host, plugins); + } + catch + { + plugins.Dispose(); + host.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + while (!_disposed) + { + switch (_disposeStage) + { + case 0: + _plugins.Dispose(); + _disposeStage++; + break; + case 1: + _host.Dispose(); + _disposeStage++; + _disposed = true; + break; + default: + throw new InvalidOperationException( + "Unknown headless plugin teardown stage."); + } + } + } + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index f3690107..4ece2480 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions; /// public interface IPluginHost { + /// + /// when registrations can be + /// projected by this host. No-window hosts return + /// and expose so a plugin may keep + /// one code path while deliberately omitting presentation work. + /// + bool HasUi { get; } + IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index 1b724f1a..0550f170 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions; /// /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + /// a binding object exposing the data properties the markup binds to, and -/// registers it from Enable(). Calls made before the GL window opens are -/// buffered and drained once the UI host exists. +/// registers it from Enable(). Graphical hosts buffer registrations until +/// their retained UI exists. A host whose is +/// exposes and +/// intentionally discards registrations. /// public interface IUiRegistry { @@ -12,3 +14,21 @@ public interface IUiRegistry /// Object whose properties the markup's {Bindings} resolve against. void AddMarkupPanel(string markupPath, object binding); } + +/// +/// BCL-only UI sink for no-window plugin hosts. It intentionally retains +/// neither markup paths nor binding objects, so a UI registration cannot keep +/// a plugin assembly alive after its collectible load context is unloaded. +/// +public sealed class NoOpUiRegistry : IUiRegistry +{ + public static NoOpUiRegistry Instance { get; } = new(); + + private NoOpUiRegistry() + { + } + + public void AddMarkupPanel(string markupPath, object binding) + { + } +} diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index b98e4761..2b3461fa 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session; /// is a single shared-stdout JSONL diagnostics stream with no per-session /// file; this class writes one file per session, meant to be read by an /// external process (the launcher) rather than scraped from console output. -/// Event shapes are versioned ("v":1) so a future event kind -/// (pluginLoaded/pluginFailed, LA5) can be added without -/// breaking an existing reader. +/// Event shapes are versioned ("v":1); LA5's +/// pluginLoaded/pluginFailed additions use that same envelope +/// without breaking an existing reader. /// /// /// @@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session; /// /// /// -/// Never write credential material into this stream. Every -/// event method below takes only identifiers, names, and counts — there is no -/// parameter shape that could carry a password, by construction. +/// Never write credential material into this stream. LA5's +/// diagnostic is caller-supplied text, so hosts may +/// pass only the plugin lifecycle failure and must never append session +/// credentials or other secrets. Neither plugin host exposes credentials +/// through IPluginHost. /// /// /// @@ -205,6 +207,27 @@ public sealed class SessionStatusWriter characterName, }); + public void PluginLoaded(string sessionId, string plugin) => + Write(new + { + v = VocabularyVersion, + e = "pluginLoaded", + t = Now(), + sessionId, + plugin, + }); + + public void PluginFailed(string sessionId, string plugin, string error) => + Write(new + { + v = VocabularyVersion, + e = "pluginFailed", + t = Now(), + sessionId, + plugin, + error, + }); + public void Disconnected(string sessionId, string reason) { if (!IsEnabled) diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 6760881b..8f82d7ba 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -25,6 +25,15 @@ + + + + false + true + + + diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs new file mode 100644 index 00000000..b77e2cb6 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using System.Runtime.CompilerServices; +using AcDream.App.Plugins; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Plugins; + +public sealed class GraphicalPluginSessionTests +{ + private const string FixtureId = "acdream.test.host-fixture"; + + [Fact] + public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + InstallFixture(paths.PluginsDirectory, FixtureId); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var logger = new CapturingLogger(); + var state = new WorldGameState(); + var events = new WorldEvents(); + var selection = new SelectionState(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost(logger, state, events, selection, ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + paths, + [FixtureId.ToUpperInvariant(), "acdream.test.missing"], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + + Assert.Equal(1, plugins.LoadedCount); + Assert.True(host.HasUi); + AssertPanelWasRegisteredAndReleaseBinding(ui); + Assert.Contains( + logger.Messages, + message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal)); + + JsonElement[] statuses = ReadStatuses(statusPath); + Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal( + "acdream.test.missing", + statuses[1].GetProperty("plugin").GetString()); + Assert.Contains( + "not found", + statuses[1].GetProperty("error").GetString(), + StringComparison.OrdinalIgnoreCase); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void ExplicitEmptyConfiguredSetLoadsNone() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + InstallFixture(paths.PluginsDirectory, FixtureId); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + new WorldEvents(), + new SelectionState(), + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + paths, + [], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(ui.Drain()); + Assert.False(File.Exists(statusPath)); + } + + private static ApplicationPathSet Paths(string root) => new( + Path.Combine(root, "config"), + Path.Combine(root, "data"), + Path.Combine(root, "cache"), + LegacyConfigDirectory: null); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AssertPanelWasRegisteredAndReleaseBinding( + BufferedUiRegistry ui) + { + BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain()); + Assert.EndsWith( + "fixture-panel.xml", + panel.MarkupPath, + StringComparison.Ordinal); + Assert.Equal( + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + panel.Binding.GetType().Assembly.GetName().Name); + } + + private static JsonElement[] ReadStatuses(string path) => + File.ReadAllLines(path) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static string[] EventNames(IEnumerable events) => + events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); + + private static void InstallFixture(string root, string id) + { + string source = FixtureAssemblyPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, "host-fixture"); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + File.WriteAllText( + Path.Combine(pluginDirectory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = "Host fixture", + version = "1.0.0", + entryDll = fileName, + apiVersion = 1, + })); + } + + private static string FixtureAssemblyPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private static void Collect(WeakReference reference) + { + for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private sealed class CapturingLogger : IPluginLogger + { + internal List Messages { get; } = []; + + public void Info(string message) => Messages.Add(message); + public void Warn(string message) => Messages.Add(message); + public void Error(string message, Exception? exception = null) => + Messages.Add(exception is null ? message : $"{message}: {exception.Message}"); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-graphical-plugins-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index a55fb398..3deebb94 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -28,6 +28,7 @@ public class PluginLoaderTests private sealed class StubHost : IPluginHost { + public bool HasUi => true; public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); @@ -84,6 +85,8 @@ public class PluginLoaderTests Assert.True(loaded.Success); Assert.NotNull(loaded.Plugin); Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); + loaded.Plugin.Disable(); + loaded.LoadContext!.Unload(); } [Fact] diff --git a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs new file mode 100644 index 00000000..5a3ef89c --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public sealed class PluginSessionTests +{ + [Fact] + public void AbsentAllowListLoadsEveryDiscoveredPlugin() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "alpha", "acdream.test.alpha"); + InstallFixture(temporary.Path, "beta", "acdream.test.beta"); + var statuses = new List(); + var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], allowList: null); + + Assert.Equal(2, plugins.LoadedCount); + Assert.Equal( + ["acdream.test.alpha", "acdream.test.beta"], + plugins.LoadedPluginIds); + Assert.All( + statuses, + status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind)); + ReleaseAndCollect(plugins); + } + + [Fact] + public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "good", "acdream.test.good"); + InstallBroken(temporary.Path, "broken", "acdream.test.broken"); + var statuses = new List(); + var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start( + [temporary.Path], + [ + "ACDREAM.TEST.BROKEN", + "ACDREAM.TEST.GOOD", + "acdream.test.missing", + ]); + + Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds); + Assert.Equal( + [ + ("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed), + ("acdream.test.good", PluginSessionStatusKind.Loaded), + ("acdream.test.missing", PluginSessionStatusKind.Failed), + ], + statuses.Select(static status => (status.Plugin, status.Kind))); + Assert.All( + statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed), + status => Assert.False(string.IsNullOrWhiteSpace(status.Error))); + ReleaseAndCollect(plugins); + } + + [Fact] + public void ExplicitEmptyAllowListLoadsNothing() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "fixture", "acdream.test.fixture"); + var statuses = new List(); + using var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], []); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(statuses); + } + + private static void ReleaseAndCollect(PluginSession plugins) + { + IReadOnlyList contexts = + plugins.CaptureLoadContextWeakReferences(); + plugins.Dispose(); + for (int attempt = 0; + attempt < 10 && contexts.Any(static context => context.IsAlive); + attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + Assert.All(contexts, static context => Assert.False(context.IsAlive)); + } + + private static void InstallFixture(string root, string folder, string id) + { + string source = FixturePluginPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, folder); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + WriteManifest(pluginDirectory, id, fileName); + } + + private static void InstallBroken(string root, string folder, string id) + { + string pluginDirectory = Path.Combine(root, folder); + Directory.CreateDirectory(pluginDirectory); + WriteManifest(pluginDirectory, id, "missing.dll"); + } + + private static void WriteManifest( + string directory, + string id, + string entryDll) => + File.WriteAllText( + Path.Combine(directory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = id, + version = "1.0.0", + entryDll, + apiVersion = 1, + })); + + private static string FixturePluginPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Core.Tests.Fixtures.HelloPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Core.Tests.Fixtures.HelloPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private sealed class StubHost : IPluginHost + { + public bool HasUi => false; + public IPluginLogger Log { get; } = new StubLogger(); + public IGameState State { get; } = new StubState(); + public IEvents Events { get; } = new StubEvents(); + public ISelectionService Selection { get; } = new SelectionState(); + public IUiRegistry Ui => NoOpUiRegistry.Instance; + } + + private sealed class StubLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + private sealed class StubState : IGameState + { + public IReadOnlyList Entities => []; + } + + private sealed class StubEvents : IEvents + { + public event Action EntitySpawned + { + add { } + remove { } + } + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-plugin-session-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj index 9591a359..fa871545 100644 --- a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj +++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj @@ -24,6 +24,15 @@ + + + + false + true + + + diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs new file mode 100644 index 00000000..130dc6d3 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessPluginSessionTests +{ + private const string FixtureId = "acdream.test.host-fixture"; + private const string BrokenId = "acdream.test.broken"; + + [Fact] + public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + InstallBrokenPlugin(temporary.Path, BrokenId); + var output = new StringWriter(); + var diagnostics = new HeadlessDiagnosticWriter(output); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), + credential, + diagnostics, + pluginRoots: [temporary.Path]); + HeadlessPluginSession plugins = session.Plugins; + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + + Assert.Equal(1, plugins.LoadedCount); + Assert.False(plugins.Host.HasUi); + Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui); + Assert.Same( + session.Runtime.ActionOwner.Selection, + plugins.Host.Selection); + WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities); + Assert.Equal(1_000_000u, first.Id); + Assert.Equal(0x02000001u, first.SourceId); + + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); + Assert.Equal(2, plugins.Host.State.Entities.Count); + + JsonElement[] statuses = ReadStatuses(statusPath); + Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); + Assert.Contains( + "entry dll not found", + statuses[1].GetProperty("error").GetString()!, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + session.Dispose(); + Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString()); + Assert.True(session.Runtime.CaptureOwnership().IsConverged); + Assert.True(credential.IsDisposed); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void ExplicitEmptyConfiguredSetLoadsNone() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + var output = new StringWriter(); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + + using var session = new HeadlessSessionHost( + Descriptor([], statusPath), + credential, + new HeadlessDiagnosticWriter(output), + pluginRoots: [temporary.Path]); + + Assert.Equal(0, session.Plugins.LoadedCount); + Assert.False(File.Exists(statusPath)); + Assert.DoesNotContain("fixture-", output.ToString()); + } + + private static HeadlessSessionDescriptor Descriptor( + List plugins, + string statusPath) => new() + { + Id = "headless-session", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "Fixture", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "FIXTURE_PASSWORD", + }, + Plugins = plugins, + StatusFile = statusPath, + }; + + private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new( + guid, + new CreateObject.ServerPosition( + 0x01010001u, + x, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f), + 0x02000001u, + [], + [], + [], + null, + null, + "Fixture", + null, + null, + null); + + private static JsonElement[] ReadStatuses(string path) => + File.ReadAllLines(path) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static string[] EventNames(IEnumerable events) => + events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); + + private static void InstallFixture(string root, string id) + { + string source = FixtureAssemblyPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, "host-fixture"); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + WriteManifest(pluginDirectory, id, fileName); + } + + private static void InstallBrokenPlugin(string root, string id) + { + string pluginDirectory = Path.Combine(root, "broken"); + Directory.CreateDirectory(pluginDirectory); + WriteManifest(pluginDirectory, id, "missing.dll"); + } + + private static void WriteManifest( + string directory, + string id, + string entryDll) => + File.WriteAllText( + Path.Combine(directory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = "Host fixture", + version = "1.0.0", + entryDll, + apiVersion = 1, + })); + + private static string FixtureAssemblyPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private static void Collect(WeakReference reference) + { + for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-headless-plugins-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj new file mode 100644 index 00000000..7b200e49 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + false + true + + + + + false + runtime + + + diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs new file mode 100644 index 00000000..9a1a9143 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -0,0 +1,46 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; + +/// +/// Cross-host LA5 fixture. It deliberately takes the same path on graphical +/// and no-window hosts: observe the capability, register UI, and subscribe to +/// gameplay events. A headless registry must make the UI call harmless without +/// retaining this instance in the default load context. +/// +public sealed class HostPlugin : IAcDreamPlugin +{ + private IPluginHost? _host; + private int _entitiesSeen; + + public void Initialize(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); + } + + public void Enable() + { + IPluginHost host = _host + ?? throw new InvalidOperationException("The fixture was not initialized."); + host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), + this); + host.Events.EntitySpawned += OnEntitySpawned; + host.Log.Info( + $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}"); + } + + public void Disable() + { + IPluginHost? host = _host; + if (host is null) + return; + host.Events.EntitySpawned -= OnEntitySpawned; + host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); + _host = null; + } + + private void OnEntitySpawned(WorldEntitySnapshot snapshot) => + _entitiesSeen++; +} diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 899c0e0f..f9208079 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests new LiveSessionRosterEntry(0x50000002u, "Grey", 10u), ])); writer.EnteredWorld("s1", 0x50000001u, "Ready"); + writer.PluginLoaded("s1", "acdream.good"); + writer.PluginFailed("s1", "acdream.bad", "enable failed"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(6, lines.Length); + Assert.Equal(8, lines.Length); JsonElement started = Parse(lines[0]); Assert.Equal(1, started.GetProperty("v").GetInt32()); @@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32()); Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString()); - JsonElement disconnected = Parse(lines[4]); + JsonElement pluginLoaded = Parse(lines[4]); + Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString()); + Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString()); + + JsonElement pluginFailed = Parse(lines[5]); + Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString()); + Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString()); + Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString()); + + JsonElement disconnected = Parse(lines[6]); Assert.Equal("disconnected", disconnected.GetProperty("e").GetString()); Assert.Equal("stopped", disconnected.GetProperty("reason").GetString()); - JsonElement exited = Parse(lines[5]); + JsonElement exited = Parse(lines[7]); Assert.Equal("exited", exited.GetProperty("e").GetString()); Assert.Equal(0, exited.GetProperty("code").GetInt32()); Assert.Equal("disposed", exited.GetProperty("reason").GetString()); @@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests writer.Started("s1"); writer.Connected("s1"); + writer.PluginLoaded("s1", "acdream.good"); + writer.PluginFailed("s1", "acdream.bad", "failed"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests /// credential material into this stream" contract: each event kind /// serializes EXACTLY its pinned property set — the shared envelope /// (v/e/t/sessionId) plus that event's own - /// named fields, nothing else. An extra property (a smuggled password, - /// or any other accidental field) fails this test by construction, - /// regardless of what value it carries. + /// named fields, nothing else. An extra credential-shaped or otherwise + /// accidental property fails this test by construction. LA5's documented + /// pluginFailed.error diagnostic is the one free-text value and its + /// caller remains responsible for never appending session secrets. /// [Fact] public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse() @@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests 11, [new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)])); writer.EnteredWorld("bot", 0x50000001u, "Ready"); + writer.PluginLoaded("bot", "acdream.good"); + writer.PluginFailed("bot", "acdream.bad", "enable failed"); writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(6, lines.Length); + Assert.Equal(8, lines.Length); AssertExactProperties(lines[0], "v", "e", "t", "sessionId"); AssertExactProperties(lines[1], "v", "e", "t", "sessionId"); @@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests "v", "e", "t", "sessionId", "accountName", "slotCount", "characters"); AssertExactProperties( lines[3], "v", "e", "t", "sessionId", "characterId", "characterName"); - AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason"); - AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason"); + AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin"); + AssertExactProperties( + lines[5], "v", "e", "t", "sessionId", "plugin", "error"); + AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason"); + AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason"); // The nested characters[] entries are exact too — the exact shape a // password could otherwise be smuggled through. From fbe9c8a28882e8033fe8caa3c52458640320dd14 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:05:13 +0200 Subject: [PATCH 2/3] fix(plugins): close LA5 host lifecycle review --- .../Composition/SessionStartComposition.cs | 9 +- src/AcDream.App/Plugins/BufferedUiRegistry.cs | 116 ++++++++++- .../Plugins/GraphicalPluginSession.cs | 47 +++-- src/AcDream.App/Program.cs | 4 +- src/AcDream.App/Rendering/GameWindow.cs | 24 ++- .../Rendering/GameWindowLifetime.cs | 5 + src/AcDream.App/UI/RetailUiRuntime.cs | 2 + src/AcDream.Core/Plugins/LoadedPlugin.cs | 9 +- src/AcDream.Core/Plugins/PluginLoader.cs | 12 +- src/AcDream.Core/Plugins/PluginSession.cs | 42 +++- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 171 +++++++++++++++ .../Hosting/HeadlessSessionHost.cs | 3 +- .../Plugins/HeadlessPluginHost.cs | 152 +++++++++++--- .../Plugins/HeadlessPluginSession.cs | 39 ++-- .../Launching/SessionConfigComposer.cs | 12 +- .../IUiRegistry.cs | 26 ++- .../SessionConfigurationSharedFixtureTests.cs | 12 ++ .../Plugins/BufferedUiRegistryTests.cs | 23 +++ .../Plugins/GraphicalPluginSessionTests.cs | 107 ++++++++-- .../GameWindowSlice8BoundaryTests.cs | 28 +++ .../HeadlessPluginSessionTests.cs | 194 +++++++++++++++++- .../SessionConfigurationSharedFixtureTests.cs | 27 +++ .../Launching/SessionConfigComposerTests.cs | 11 +- .../HostPlugin.cs | 26 ++- .../LauncherCoreSessionConfigFixture.cs | 62 ++++++ 25 files changed, 1043 insertions(+), 120 deletions(-) create mode 100644 src/AcDream.Core/Plugins/ScopedPluginHost.cs diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs index 35b48db9..64e423dc 100644 --- a/src/AcDream.App/Composition/SessionStartComposition.cs +++ b/src/AcDream.App/Composition/SessionStartComposition.cs @@ -4,11 +4,7 @@ using AcDream.Runtime.Session; namespace AcDream.App.Composition; internal sealed record SessionStartDependencies( - Action Log, - /// Campaign LA slice LA1: no-op when no statusFile was - /// configured. - SessionStatusWriter StatusWriter, - string SessionId); + Action Log); /// /// Terminal startup phase. Every callback, command target, and frame root is @@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase public void Start(FrameRootResult frame) { ArgumentNullException.ThrowIfNull(frame); - // Campaign LA slice LA1: "started" = session host start — the - // earliest point the graphical host actually attempts to connect. - _dependencies.StatusWriter.Started(_dependencies.SessionId); RuntimeSessionStartResult result = frame.GameRuntime.Session.Start(frame.GameRuntime.Generation); Report(result, _dependencies.Log); diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index bcab04fb..dc3c565e 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using AcDream.App.UI; using AcDream.Plugin.Abstractions; namespace AcDream.App.Plugins; @@ -8,20 +9,119 @@ namespace AcDream.App.Plugins; /// Program.cs before the GL window opens) until GameWindow drains them into the /// UiHost tree after construction. /// -public sealed class BufferedUiRegistry : IUiRegistry +public sealed class BufferedUiRegistry : IScopedUiRegistry { - public readonly record struct Pending(string MarkupPath, object Binding); + public readonly record struct Pending(string MarkupPath, object Binding) + { + internal long RegistrationId { get; init; } + } - private readonly List _pending = new(); + private sealed class Registration(string markupPath, object binding) + { + internal string MarkupPath { get; } = markupPath; + internal object Binding { get; } = binding; + internal bool Drained { get; set; } + internal UiRoot? Root { get; set; } + internal UiElement? Element { get; set; } + } + + private readonly object _gate = new(); + private readonly Dictionary _registrations = []; + private long _nextRegistrationId; public void AddMarkupPanel(string markupPath, object binding) - => _pending.Add(new Pending(markupPath, binding)); + => _ = RegisterMarkupPanel(markupPath, binding); - /// Return + clear all buffered registrations. + public IDisposable RegisterMarkupPanel(string markupPath, object binding) + { + ArgumentException.ThrowIfNullOrWhiteSpace(markupPath); + ArgumentNullException.ThrowIfNull(binding); + long id; + lock (_gate) + { + id = checked(++_nextRegistrationId); + _registrations.Add(id, new Registration(markupPath, binding)); + } + return new RegistrationToken(this, id); + } + + /// Returns each not-yet-drained active registration once. public IReadOnlyList Drain() { - var copy = _pending.ToArray(); - _pending.Clear(); - return copy; + lock (_gate) + { + var pending = new List(_registrations.Count); + foreach ((long id, Registration registration) in _registrations) + { + if (registration.Drained) + continue; + registration.Drained = true; + pending.Add(new Pending( + registration.MarkupPath, + registration.Binding) + { + RegistrationId = id, + }); + } + return pending; + } + } + + internal void CompleteMount(Pending pending, UiRoot root, UiElement element) + { + bool stillRegistered; + lock (_gate) + { + stillRegistered = _registrations.TryGetValue( + pending.RegistrationId, + out Registration? registration); + if (stillRegistered) + { + registration!.Root = root; + registration.Element = element; + } + } + + // A plugin can fail/disable while markup is being built. Never leave + // the just-built child mounted if its host-owned token was rolled back. + if (!stillRegistered) + root.RemoveChild(element); + } + + internal void FailMount(Pending pending) => Remove(pending.RegistrationId); + + internal int RegistrationCount + { + get + { + lock (_gate) + return _registrations.Count; + } + } + + private void Remove(long id) + { + UiRoot? root; + UiElement? element; + lock (_gate) + { + if (!_registrations.Remove(id, out Registration? registration)) + return; + root = registration.Root; + element = registration.Element; + } + + if (root is not null && element is not null) + root.RemoveChild(element); + } + + private sealed class RegistrationToken( + BufferedUiRegistry owner, + long registrationId) : IDisposable + { + private BufferedUiRegistry? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Remove(registrationId); } } diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs index a41ceaab..a968028c 100644 --- a/src/AcDream.App/Plugins/GraphicalPluginSession.cs +++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs @@ -14,10 +14,24 @@ namespace AcDream.App.Plugins; internal sealed class GraphicalPluginSession : IDisposable { private readonly PluginSession _plugins; + private readonly string[] _roots; + private readonly IReadOnlyList? _allowList; + private readonly string _sessionId; + private readonly SessionStatusWriter _statusWriter; + private bool _started; - private GraphicalPluginSession(PluginSession plugins) + private GraphicalPluginSession( + PluginSession plugins, + string[] roots, + IReadOnlyList? allowList, + string sessionId, + SessionStatusWriter statusWriter) { _plugins = plugins; + _roots = roots; + _allowList = allowList; + _sessionId = sessionId; + _statusWriter = statusWriter; } internal int LoadedCount => _plugins.LoadedCount; @@ -25,7 +39,7 @@ internal sealed class GraphicalPluginSession : IDisposable internal IReadOnlyList CaptureLoadContextWeakReferences() => _plugins.CaptureLoadContextWeakReferences(); - internal static GraphicalPluginSession Start( + internal static GraphicalPluginSession Create( ApplicationPathSet paths, IReadOnlyList? allowList, string sessionId, @@ -40,21 +54,28 @@ internal sealed class GraphicalPluginSession : IDisposable var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status)); - try - { - plugins.Start( + return new GraphicalPluginSession( + plugins, [ Path.Combine(AppContext.BaseDirectory, "plugins"), paths.PluginsDirectory, ], - allowList); - return new GraphicalPluginSession(plugins); - } - catch - { - plugins.Dispose(); - throw; - } + allowList, + sessionId, + statusWriter); + } + + internal void Start() + { + if (_started) + throw new InvalidOperationException( + "The graphical plugin session has already started."); + _started = true; + + // Both real hosts publish the same startup prefix: started first, + // then one outcome for each configured plugin, then connection work. + _statusWriter.Started(_sessionId); + _plugins.Start(_roots, _allowList); } public void Dispose() => _plugins.Dispose(); diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 7b2c6265..8b0407bb 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -161,12 +161,13 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry); -using var pluginSession = GraphicalPluginSession.Start( +GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, runtimeOptions.SessionId ?? "app", host, window.StatusWriter); +window.StartPluginHosting(pluginSession); try { @@ -182,7 +183,6 @@ try } finally { - pluginSession.Dispose(); Log.CloseAndFlush(); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a3ec07eb..ac349c78 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -431,6 +431,7 @@ public sealed class GameWindow : _creatureAppraisalFramePresenter; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; + private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; // Campaign V slice V11 deleted the ImGui developer-tools frontend along // with the OpenGL backend it required, so no host ever composes a // developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches @@ -723,6 +724,24 @@ public sealed class GameWindow : _movementTruthDiagnostics); } + /// + /// Transfers the graphical plugin lifetime into the window shutdown graph + /// and starts it before retained UI construction drains registrations. + /// + internal void StartPluginHosting( + AcDream.App.Plugins.GraphicalPluginSession pluginSession) + { + ArgumentNullException.ThrowIfNull(pluginSession); + if (_pluginSession is not null) + { + throw new InvalidOperationException( + "The graphical plugin session is already attached."); + } + + _pluginSession = pluginSession; + pluginSession.Start(); + } + public void Run() { _platformServices.ConfigureWindowBackend(); @@ -1555,9 +1574,7 @@ public sealed class GameWindow : sessionPlayer), frameRoots => new SessionStartCompositionPhase( new SessionStartDependencies( - Console.WriteLine, - _statusWriter, - _options.SessionId ?? "app")) + Console.WriteLine)) .Start(frameRoots)); } @@ -1704,6 +1721,7 @@ public sealed class GameWindow : _kbSource, _retailUiLease, _uiHost, + _pluginSession, _runtime, _movementInput, _cameraInput, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 72605e26..92001eae 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots( RetailUiRuntimeLease RetailUi, // Keeps failed physical UI bindings alive through native-window release. UiHost? RetainedUiHost, + IDisposable? Plugins, GameRuntime Runtime, DispatcherMovementInputSource MovementInput, DispatcherCameraInputSource CameraInput, @@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)), Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)), ]), + new ResourceShutdownStage("plugin host", + [ + Hard("plugins", () => ingress.Plugins?.Dispose()), + ]), new ResourceShutdownStage("frame borrowers", [ Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()), diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8c21f9c0..b4164367 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, _bindings.Assets.Controls); Host.Root.AddChild(element); + _bindings.Plugins.CompleteMount(panel, Host.Root, element); Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); } catch (Exception ex) { + _bindings.Plugins.FailMount(panel); Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}"); } } diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs index a3f6d249..ad48f6db 100644 --- a/src/AcDream.Core/Plugins/LoadedPlugin.cs +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -7,14 +7,17 @@ namespace AcDream.Core.Plugins; /// Outcome of a plugin load attempt. /// On success, is the instantiated plugin, /// owns its assembly, and is null. -/// On failure, and are null and -/// describes what went wrong. +/// On failure, and are null, +/// describes what went wrong, and +/// weakly observes any collectible context +/// that was already released during rollback. /// public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, AssemblyLoadContext? LoadContext, - Exception? Error) + Exception? Error, + WeakReference? ReleasedLoadContext = null) { public bool Success => Plugin is not null && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index 85581642..54042a51 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -48,13 +48,15 @@ public static class PluginLoader if (pluginType is null) { + var released = new WeakReference(alc); alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, LoadContext: null, Error: new InvalidOperationException( - $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"), + ReleasedLoadContext: released); } instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; @@ -68,9 +70,15 @@ public static class PluginLoader // as an Enable failure before releasing the collectible context. try { instance?.Disable(); } catch { } + WeakReference? released = alc is null ? null : new WeakReference(alc); try { alc?.Unload(); } catch { } - return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: null, + Error: ex, + ReleasedLoadContext: released); } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 5c939fd7..dfbbb3ef 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -27,7 +27,8 @@ public sealed class PluginSession : IDisposable { private readonly IPluginHost _host; private readonly Action? _report; - private readonly List _loaded = []; + private readonly List _loaded = []; + private readonly List _releasedContexts = []; private bool _started; private bool _disposed; @@ -42,7 +43,7 @@ public sealed class PluginSession : IDisposable public int LoadedCount => _loaded.Count; public IReadOnlyList LoadedPluginIds => - _loaded.Select(static plugin => plugin.Manifest.Id).ToArray(); + _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray(); /// /// Discovers and starts the configured set exactly once. A @@ -143,9 +144,11 @@ public sealed class PluginSession : IDisposable /// owned by this session. The returned weak references do not delay unload. /// public IReadOnlyList CaptureLoadContextWeakReferences() => - _loaded - .Select(static plugin => new WeakReference(plugin.LoadContext!)) - .ToArray(); + [ + .. _releasedContexts, + .. _loaded.Select(static active => + new WeakReference(active.Loaded.LoadContext!)), + ]; public void Dispose() { @@ -155,7 +158,8 @@ public sealed class PluginSession : IDisposable for (int index = _loaded.Count - 1; index >= 0; index--) { - LoadedPlugin loaded = _loaded[index]; + ActivePlugin active = _loaded[index]; + LoadedPlugin loaded = active.Loaded; try { loaded.Plugin!.Disable(); @@ -169,6 +173,11 @@ public sealed class PluginSession : IDisposable error); } + // Host-owned registrations are released even when Disable throws. + // This must precede ALC unload so no UI binding or event delegate + // can keep the plugin assembly reachable. + active.Scope.Dispose(); + try { loaded.LoadContext!.Unload(); @@ -198,12 +207,16 @@ public sealed class PluginSession : IDisposable { foreach (PluginDiscoveryResult candidate in available) { + var scope = new ScopedPluginHost(_host); LoadedPlugin loaded = PluginLoader.Load( candidate.PluginDirectory, candidate.Manifest!, - _host); + scope); if (!loaded.Success) { + scope.Dispose(); + if (loaded.ReleasedLoadContext is { } released) + _releasedContexts.Add(released); AddError( errors, id, @@ -215,7 +228,7 @@ public sealed class PluginSession : IDisposable try { loaded.Plugin!.Enable(); - _loaded.Add(loaded); + _loaded.Add(new ActivePlugin(loaded, scope)); SafeLog( static (log, message, _) => log.Info(message), $"plugin loaded: {loaded.Manifest.Id} " @@ -229,7 +242,7 @@ public sealed class PluginSession : IDisposable catch (Exception error) { AddError(errors, id, error); - ReleaseFailedEnable(loaded); + ReleaseFailedEnable(loaded, scope); } } } @@ -257,7 +270,9 @@ public sealed class PluginSession : IDisposable null); } - private void ReleaseFailedEnable(LoadedPlugin loaded) + private void ReleaseFailedEnable( + LoadedPlugin loaded, + ScopedPluginHost scope) { try { @@ -272,6 +287,9 @@ public sealed class PluginSession : IDisposable error); } + scope.Dispose(); + + _releasedContexts.Add(new WeakReference(loaded.LoadContext!)); try { loaded.LoadContext!.Unload(); @@ -358,4 +376,8 @@ public sealed class PluginSession : IDisposable or ArgumentException or NotSupportedException or System.Security.SecurityException; + + private sealed record ActivePlugin( + LoadedPlugin Loaded, + ScopedPluginHost Scope); } diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs new file mode 100644 index 00000000..61786b22 --- /dev/null +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -0,0 +1,171 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Per-plugin host view that owns every registration made through the public +/// event/UI surfaces. Disposal is the host's rollback boundary: it removes +/// registrations even when plugin Initialize/Enable/Disable code throws. +/// +internal sealed class ScopedPluginHost : IPluginHost, IDisposable +{ + private readonly IPluginHost _inner; + private readonly ScopedEvents _events; + private readonly ScopedUiRegistry _ui; + private bool _disposed; + + internal ScopedPluginHost(IPluginHost inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _events = new ScopedEvents(inner.Events); + _ui = new ScopedUiRegistry(inner.Ui); + } + + public bool HasUi => _inner.HasUi; + public IPluginLogger Log => _inner.Log; + public IGameState State => _inner.State; + public IEvents Events => _events; + public ISelectionService Selection => _inner.Selection; + public IUiRegistry Ui => _ui; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _events.Dispose(); + _ui.Dispose(); + } + + private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.EntitySpawned += value; + } + catch + { + // A custom event source may mutate before its add accessor + // faults. Best-effort removal keeps the scope transactional. + try { inner.EntitySpawned -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + // Disposal may race the host subscription call. In that case + // the disposal snapshot could not see this registration, so + // the attaching thread must roll it back before returning. + try { inner.EntitySpawned -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedEvents)); + } + remove + { + if (value is null) + return; + inner.EntitySpawned -= value; + lock (_gate) + RemoveLast(value); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.EntitySpawned -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + + private sealed class ScopedUiRegistry : IUiRegistry, IDisposable + { + private readonly IScopedUiRegistry _inner; + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + internal ScopedUiRegistry(IUiRegistry inner) + { + _inner = inner as IScopedUiRegistry + ?? throw new InvalidOperationException( + "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); + } + + public void AddMarkupPanel(string markupPath, object binding) + { + IDisposable registration = _inner.RegisterMarkupPanel( + markupPath, + binding); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return; + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedUiRegistry)); + } + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { registrations[index].Dispose(); } + catch { } + } + } + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index eb0ce2a5..9e025f61 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -301,7 +301,7 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); - pluginSession = HeadlessPluginSession.Start( + pluginSession = HeadlessPluginSession.Create( runtime, diagnostics, statusWriter, @@ -488,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable // Campaign LA slice LA1: "started" = session host start — the // earliest point this session actually attempts to connect. _statusWriter.Started(_descriptor.Id); + _pluginSession.Start(); RuntimeSessionStartResult result = Commands.Session.Start(Runtime.Generation); _startOutcome = result.Status; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index f68c4bc5..5eda1706 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -19,9 +19,23 @@ internal sealed class HeadlessPluginHost private readonly GameRuntime _runtime; private readonly IDisposable _eventSubscription; private readonly object _eventGate = new(); - private Action? _entitySpawned; + private readonly List _subscriptions = []; + private Subscription[] _liveSnapshot = []; private bool _disposed; + private readonly record struct ReplayEntity( + RuntimeEntityIdentity Identity, + WorldEntitySnapshot Snapshot); + + private sealed class Subscription(Action handler) + { + internal Action Handler { get; } = handler; + internal Queue Pending { get; } = new(); + internal HashSet Delivered { get; } = []; + internal bool Replaying { get; set; } = true; + internal bool Active { get; set; } = true; + } + internal HeadlessPluginHost( GameRuntime runtime, IPluginLogger logger) @@ -38,6 +52,10 @@ internal sealed class HeadlessPluginHost public ISelectionService Selection => _runtime.ActionOwner.Selection; public IUiRegistry Ui => NoOpUiRegistry.Instance; + /// Test-only barrier after the borrowed replay snapshot is + /// captured and before delivery starts. + internal Action? ReplayCapturedForTest { get; set; } + /// /// Immutable point-in-time values produced directly from Runtime on each /// read. The caller owns the returned snapshot list; this host retains no @@ -50,7 +68,7 @@ internal sealed class HeadlessPluginHost ObjectDisposedException.ThrowIf(_disposed, this); var visitor = new SnapshotVisitor(_runtime); _runtime.Entities.Visit(visitor); - return visitor.Snapshots; + return visitor.Items.Select(static item => item.Snapshot).ToArray(); } } @@ -60,20 +78,81 @@ internal sealed class HeadlessPluginHost { ArgumentNullException.ThrowIfNull(value); ObjectDisposedException.ThrowIf(_disposed, this); + var subscription = new Subscription(value); lock (_eventGate) - _entitySpawned += value; + { + ObjectDisposedException.ThrowIf(_disposed, this); + _subscriptions.Add(subscription); + } - // Match the graphical WorldEvents contract: a late subscriber - // immediately observes the canonical world that exists now. - foreach (WorldEntitySnapshot snapshot in Entities) - Invoke(value, snapshot); + // Arm the pending queue before borrowing Runtime's snapshot. This + // avoids a host-lock/Runtime-lock inversion while the identity + // dedup below collapses any registration present in both views. + var visitor = new SnapshotVisitor(_runtime); + _runtime.Entities.Visit(visitor); + ReplayEntity[] replay = visitor.Items.ToArray(); + + ReplayCapturedForTest?.Invoke(); + foreach (ReplayEntity item in replay) + { + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!_runtime.Entities.TryGet( + item.Identity.ServerGuid, + out RuntimeEntitySnapshot current) + || current.Identity != item.Identity + || !subscription.Delivered.Add(item.Identity)) + { + continue; + } + } + + Invoke(subscription.Handler, item.Snapshot); + } + + while (true) + { + ReplayEntity pending; + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!subscription.Pending.TryDequeue(out pending)) + { + subscription.Replaying = false; + subscription.Delivered.Clear(); + RebuildLiveSnapshotLocked(); + return; + } + if (!subscription.Delivered.Add(pending.Identity)) + continue; + } + + Invoke(subscription.Handler, pending.Snapshot); + } } remove { if (value is null) return; lock (_eventGate) - _entitySpawned -= value; + { + for (int index = _subscriptions.Count - 1; index >= 0; index--) + { + Subscription subscription = _subscriptions[index]; + if (subscription.Handler != value) + continue; + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + _subscriptions.RemoveAt(index); + if (!subscription.Replaying) + RebuildLiveSnapshotLocked(); + break; + } + } } } @@ -81,28 +160,45 @@ internal sealed class HeadlessPluginHost { if (_disposed) return; - _eventSubscription.Dispose(); - _disposed = true; lock (_eventGate) - _entitySpawned = null; + { + _disposed = true; + foreach (Subscription subscription in _subscriptions) + { + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + } + _subscriptions.Clear(); + _liveSnapshot = []; + } + _eventSubscription.Dispose(); } public void OnEntity(in RuntimeEntityDelta delta) { - if (_disposed || delta.Change != RuntimeEntityChange.Registered) + if (delta.Change != RuntimeEntityChange.Registered) return; - Action? handlers; + Subscription[] toNotify; + var pending = new ReplayEntity( + delta.Entity.Identity, + Convert(_runtime, delta.Entity)); lock (_eventGate) - handlers = _entitySpawned; - if (handlers is null) + { + if (_disposed) + return; + foreach (Subscription subscription in _subscriptions) + { + if (subscription.Active && subscription.Replaying) + subscription.Pending.Enqueue(pending); + } + toNotify = _liveSnapshot; + } + if (toNotify.Length == 0) return; - WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity); - foreach (Action handler - in handlers.GetInvocationList().Cast>()) - { - Invoke(handler, snapshot); - } + foreach (Subscription subscription in toNotify) + Invoke(subscription.Handler, pending.Snapshot); } public void OnLifecycle(in RuntimeLifecycleDelta delta) { } @@ -141,10 +237,20 @@ internal sealed class HeadlessPluginHost private sealed class SnapshotVisitor(GameRuntime runtime) : IRuntimeEntityVisitor { - internal List Snapshots { get; } = + internal List Items { get; } = new(runtime.Entities.Count); public void Visit(in RuntimeEntitySnapshot entity) => - Snapshots.Add(Convert(runtime, entity)); + Items.Add(new ReplayEntity( + entity.Identity, + Convert(runtime, entity))); + } + + private void RebuildLiveSnapshotLocked() + { + _liveSnapshot = _subscriptions + .Where(static subscription => + subscription.Active && !subscription.Replaying) + .ToArray(); } } diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index cff1226a..d199752b 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -14,24 +14,31 @@ internal sealed class HeadlessPluginSession : IDisposable { private readonly HeadlessPluginHost _host; private readonly PluginSession _plugins; + private readonly string[] _roots; + private readonly IReadOnlyList? _allowList; private int _disposeStage; + private bool _started; private bool _disposed; private HeadlessPluginSession( HeadlessPluginHost host, - PluginSession plugins) + PluginSession plugins, + string[] roots, + IReadOnlyList? allowList) { _host = host; _plugins = plugins; + _roots = roots; + _allowList = allowList; } internal int LoadedCount => _plugins.LoadedCount; - internal IPluginHost Host => _host; + internal HeadlessPluginHost Host => _host; internal IReadOnlyList CaptureLoadContextWeakReferences() => _plugins.CaptureLoadContextWeakReferences(); - internal static HeadlessPluginSession Start( + internal static HeadlessPluginSession Create( GameRuntime runtime, HeadlessDiagnosticWriter diagnostics, SessionStatusWriter statusWriter, @@ -54,17 +61,21 @@ internal sealed class HeadlessPluginSession : IDisposable var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status)); - try - { - plugins.Start(roots, allowList); - return new HeadlessPluginSession(host, plugins); - } - catch - { - plugins.Dispose(); - host.Dispose(); - throw; - } + return new HeadlessPluginSession( + host, + plugins, + roots.ToArray(), + allowList); + } + + internal void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException( + "The headless plugin session has already started."); + _started = true; + _plugins.Start(_roots, _allowList); } public void Dispose() diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 1b4349d2..e8452714 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -74,7 +74,9 @@ public static class SessionConfigComposer Character = selector, Policy = policy, Credential = new SessionCredentialDescriptor(), - Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null, + // LA5 distinguishes an omitted allow-list (load all, preserving + // the developer flow) from an explicit empty list (load none). + Plugins = [.. character.Plugins], LoginCommands = character.LoginCommands.Count > 0 ? [.. character.LoginCommands] : null, @@ -107,9 +109,9 @@ public static class SessionConfigComposer /// §LA3 review finding F2): the session carries mode: "probe", /// no character selector, and no policy — the host /// reports the account's character roster over the status stream and - /// exits without entering the world. plugins/loginCommands - /// don't apply to a probe and are always omitted, exactly like an - /// empty configured set on a normal session. + /// exits without entering the world. Probes carry an explicit empty + /// plugins allow-list so a plugin installed on the machine cannot + /// run merely because the probe has no character-level plugin settings. /// public static ComposedSessionConfig ComposeProbe( ServerProfile server, @@ -139,7 +141,7 @@ public static class SessionConfigComposer Character = null, Policy = null, Credential = new SessionCredentialDescriptor(), - Plugins = null, + Plugins = [], LoginCommands = null, LoginCommandDelayMs = null, StatusFile = statusFilePath, diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index 0550f170..ca587dcf 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -15,12 +15,24 @@ public interface IUiRegistry void AddMarkupPanel(string markupPath, object binding); } +/// +/// Host-infrastructure extension used to give each plugin a removable UI +/// registration lifetime. Plugins continue to call +/// ; the shared plugin host wraps that +/// call and owns the returned token so failed initialization, failed enable, +/// and shutdown can roll the registration back without trusting plugin code. +/// +public interface IScopedUiRegistry : IUiRegistry +{ + IDisposable RegisterMarkupPanel(string markupPath, object binding); +} + /// /// BCL-only UI sink for no-window plugin hosts. It intentionally retains /// neither markup paths nor binding objects, so a UI registration cannot keep /// a plugin assembly alive after its collectible load context is unloaded. /// -public sealed class NoOpUiRegistry : IUiRegistry +public sealed class NoOpUiRegistry : IScopedUiRegistry { public static NoOpUiRegistry Instance { get; } = new(); @@ -31,4 +43,16 @@ public sealed class NoOpUiRegistry : IUiRegistry public void AddMarkupPanel(string markupPath, object binding) { } + + public IDisposable RegisterMarkupPanel(string markupPath, object binding) => + NoOpRegistration.Instance; + + private sealed class NoOpRegistration : IDisposable + { + internal static NoOpRegistration Instance { get; } = new(); + + public void Dispose() + { + } + } } diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs index c37ce9fe..5e83aa49 100644 --- a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests StringComparison.Ordinal); } + [Fact] + public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + + (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path); + + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + [Fact] public void AppReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs index 6e22e17f..23e84896 100644 --- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Plugins; +using AcDream.App.UI; namespace AcDream.App.Tests.Plugins; @@ -18,4 +19,26 @@ public class BufferedUiRegistryTests Assert.Empty(reg.Drain()); // consumed } + + [Fact] + public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement() + { + var registry = new BufferedUiRegistry(); + IDisposable registration = registry.RegisterMarkupPanel( + "plugin.xml", + new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + var root = new UiRoot(); + var element = new UiPanel(); + root.AddChild(element); + registry.CompleteMount(pending, root, element); + + Assert.Contains(element, root.Children); + Assert.Equal(1, registry.RegistrationCount); + + registration.Dispose(); + + Assert.DoesNotContain(element, root.Children); + Assert.Equal(0, registry.RegistrationCount); + } } diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index b77e2cb6..8f4a2e10 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -1,17 +1,20 @@ using System.Text.Json; using System.Runtime.CompilerServices; +using AcDream.App.Configuration; using AcDream.App.Plugins; using AcDream.Core.Plugins; using AcDream.Core.Selection; using AcDream.Platform; using AcDream.Plugin.Abstractions; using AcDream.Runtime.Session; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.App.Tests.Plugins; public sealed class GraphicalPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; + private const string ThrowingId = "acdream.test.throwing-fixture"; [Fact] public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() @@ -27,12 +30,13 @@ public sealed class GraphicalPluginSessionTests var ui = new BufferedUiRegistry(); var host = new AppPluginHost(logger, state, events, selection, ui); - using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( paths, [FixtureId.ToUpperInvariant(), "acdream.test.missing"], "gui-session", host, new SessionStatusWriter(statusPath)); + plugins.Start(); Assert.Equal(1, plugins.LoadedCount); Assert.True(host.HasUi); @@ -42,19 +46,20 @@ public sealed class GraphicalPluginSessionTests message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal)); JsonElement[] statuses = ReadStatuses(statusPath); - Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); - Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString()); Assert.Equal( "acdream.test.missing", - statuses[1].GetProperty("plugin").GetString()); + statuses[2].GetProperty("plugin").GetString()); Assert.Contains( "not found", - statuses[1].GetProperty("error").GetString(), + statuses[2].GetProperty("error").GetString(), StringComparison.OrdinalIgnoreCase); WeakReference context = Assert.Single( plugins.CaptureLoadContextWeakReferences()); plugins.Dispose(); + Assert.Equal(0, ui.RegistrationCount); Collect(context); Assert.False(context.IsAlive); } @@ -66,6 +71,12 @@ public sealed class GraphicalPluginSessionTests ApplicationPathSet paths = Paths(temporary.Path); InstallFixture(paths.PluginsDirectory, FixtureId); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + string configPath = Path.Combine(temporary.Path, "session.json"); + File.WriteAllText( + configPath, + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + (_, SessionDescriptor descriptor) = + SessionConfigurationLoader.Load(configPath); var ui = new BufferedUiRegistry(); var host = new AppPluginHost( new CapturingLogger(), @@ -74,16 +85,70 @@ public sealed class GraphicalPluginSessionTests new SelectionState(), ui); - using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( paths, - [], + descriptor.Plugins, "gui-session", host, new SessionStatusWriter(statusPath)); + plugins.Start(); + + Assert.Equal(0, plugins.LoadedCount); + Assert.NotNull(descriptor.Plugins); + Assert.Empty(descriptor.Plugins); + Assert.Empty(ui.Drain()); + Assert.Equal(["started"], EventNames(ReadStatuses(statusPath))); + } + + [Fact] + public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string pluginDirectory = InstallFixture( + paths.PluginsDirectory, + ThrowingId, + "throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-after-register"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var events = new WorldEvents(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + events, + new SelectionState(), + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( + paths, + [ThrowingId], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + plugins.Start(); Assert.Equal(0, plugins.LoadedCount); Assert.Empty(ui.Drain()); - Assert.False(File.Exists(statusPath)); + Assert.Equal(0, ui.RegistrationCount); + events.FireEntitySpawned(new WorldEntitySnapshot( + 1u, + 2u, + default, + System.Numerics.Quaternion.Identity)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + ["started", "pluginFailed"], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); } private static ApplicationPathSet Paths(string root) => new( @@ -114,11 +179,14 @@ public sealed class GraphicalPluginSessionTests private static string[] EventNames(IEnumerable events) => events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); - private static void InstallFixture(string root, string id) + private static string InstallFixture( + string root, + string id, + string directoryName = "host-fixture") { string source = FixtureAssemblyPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); - string pluginDirectory = Path.Combine(root, "host-fixture"); + string pluginDirectory = Path.Combine(root, directoryName); Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); @@ -132,6 +200,7 @@ public sealed class GraphicalPluginSessionTests entryDll = fileName, apiVersion = 1, })); + return pluginDirectory; } private static string FixtureAssemblyPath() @@ -195,8 +264,22 @@ public sealed class GraphicalPluginSessionTests public void Dispose() { - if (Directory.Exists(Path)) - Directory.Delete(Path, recursive: true); + for (int attempt = 0; Directory.Exists(Path); attempt++) + { + try + { + Directory.Delete(Path, recursive: true); + return; + } + catch (Exception error) + when (error is IOException or UnauthorizedAccessException + && attempt < 9) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(10); + } + } } } } diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 017762cd..f6ce4c9c 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests public void Shutdown_PreservesDependencyStagesAndNativeWindowLast() { string source = GameWindowSource(); + string program = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Program.cs")); string lifetime = GameWindowLifetimeSource(); string manifest = Slice( lifetime, @@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests [ "new ResourceShutdownStage(\"host and session barriers\"", "new ResourceShutdownStage(\"physical ingress cleanup\"", + "new ResourceShutdownStage(\"plugin host\"", "new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"live entities\"", @@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests "Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))", "Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))", "Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))", + "new ResourceShutdownStage(\"plugin host\"", + "Hard(\"plugins\", () => ingress.Plugins?.Dispose())", "new ResourceShutdownStage(\"session dependents\"", "Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())"); Assert.Contains( @@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests "UiHost? RetainedUiHost,", lifetime, StringComparison.Ordinal); + Assert.Contains( + "IDisposable? Plugins,", + lifetime, + StringComparison.Ordinal); Assert.Contains("_uiHost,", source, StringComparison.Ordinal); + Assert.Contains("_pluginSession,", source, StringComparison.Ordinal); + AssertAppearsInOrder( + program, + "GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(", + "window.StartPluginHosting(pluginSession);", + "window.Run();"); + AssertAppearsInOrder( + source, + "_pluginSession = pluginSession;", + "pluginSession.Start();", + "public void Run()"); + AssertAppearsInOrder( + manifest, + "Hard(\"plugins\", () => ingress.Plugins?.Dispose())", + "Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))", + "Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))"); AssertAppearsInOrder( nativeRelease, "TryComplete();", diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index 130dc6d3..e2aa76ad 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Net; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Headless.Configuration; @@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Headless.Plugins; using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.Headless.Tests; @@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; private const string BrokenId = "acdream.test.broken"; + private const string ThrowingId = "acdream.test.throwing-fixture"; [Fact] public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() @@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), credential, diagnostics, + new FixtureSessionOperations(), pluginRoots: [temporary.Path]); HeadlessPluginSession plugins = session.Plugins; + _ = session.Start(); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); Assert.Equal(1, plugins.LoadedCount); @@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests Assert.Equal(2, plugins.Host.State.Entities.Count); JsonElement[] statuses = ReadStatuses(statusPath); - Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); - Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); - Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); + Assert.Equal( + [ + "started", "pluginLoaded", "pluginFailed", "connected", + "characterList", "enteredWorld", + ], + EventNames(statuses)); + Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString()); + Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString()); Assert.Contains( "entry dll not found", - statuses[1].GetProperty("error").GetString()!, + statuses[2].GetProperty("error").GetString()!, StringComparison.OrdinalIgnoreCase); Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); @@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests Descriptor([], statusPath), credential, new HeadlessDiagnosticWriter(output), + new FixtureSessionOperations(), pluginRoots: [temporary.Path]); + _ = session.Start(); Assert.Equal(0, session.Plugins.LoadedCount); - Assert.False(File.Exists(statusPath)); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + EventNames(ReadStatuses(statusPath))); Assert.DoesNotContain("fixture-", output.ToString()); } + [Fact] + public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext() + { + using var temporary = new TemporaryDirectory(); + string pluginDirectory = InstallFixture( + temporary.Path, + ThrowingId, + "throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-after-register"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([ThrowingId], statusPath), + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + + _ = session.Start(); + Assert.Equal(0, session.Plugins.LoadedCount); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + [ + "started", "pluginFailed", "connected", "characterList", + "enteredWorld", + ], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + session.Plugins.CaptureLoadContextWeakReferences()); + session.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + string configPath = Path.Combine(temporary.Path, "probe.json"); + File.WriteAllText( + configPath, + LauncherCoreSessionConfigFixture.ComposeProbe()); + HeadlessSessionDescriptor descriptor = Assert.Single( + HeadlessConfigurationLoader.Load(configPath).Sessions)! with + { + StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"), + }; + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + descriptor, + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + + RuntimeSessionStartResult result = session.Start(); + + Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status); + Assert.NotNull(descriptor.Plugins); + Assert.Empty(descriptor.Plugins); + Assert.Equal(0, session.Plugins.LoadedCount); + Assert.Equal( + ["started", "connected", "characterList"], + EventNames(ReadStatuses(descriptor.StatusFile!))); + } + + [Fact] + public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder() + { + using var temporary = new TemporaryDirectory(); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([], Path.Combine(temporary.Path, "status.jsonl")), + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + HeadlessPluginHost host = session.Plugins.Host; + using var replayCaptured = new ManualResetEventSlim(); + using var releaseReplay = new ManualResetEventSlim(); + host.ReplayCapturedForTest = () => + { + replayCaptured.Set(); + Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10))); + }; + var observed = new List(); + Action handler = snapshot => + { + lock (observed) + observed.Add(snapshot.Id); + }; + + Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); + Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); + releaseReplay.Set(); + await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); + host.Events.EntitySpawned -= handler; + + Assert.Equal([1_000_000u, 1_000_001u], observed); + } + private static HeadlessSessionDescriptor Descriptor( List plugins, string statusPath) => new() @@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests private static string[] EventNames(IEnumerable events) => events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); - private static void InstallFixture(string root, string id) + private static string InstallFixture( + string root, + string id, + string directoryName = "host-fixture") { string source = FixtureAssemblyPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); - string pluginDirectory = Path.Combine(root, "host-fixture"); + string pluginDirectory = Path.Combine(root, directoryName); Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); WriteManifest(pluginDirectory, id, fileName); + return pluginDirectory; } private static void InstallBrokenPlugin(string root, string id) @@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests } } + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + private static readonly CharacterList.Parsed Characters = new( + 0u, + [new CharacterList.Character(0x50000001u, "Fixture", 0u)], + [], + 1, + "account", + true, + true); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + private sealed class TemporaryDirectory : IDisposable { internal TemporaryDirectory() @@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests public void Dispose() { - if (Directory.Exists(Path)) - Directory.Delete(Path, recursive: true); + for (int attempt = 0; Directory.Exists(Path); attempt++) + { + try + { + Directory.Delete(Path, recursive: true); + return; + } + catch (Exception error) + when (error is IOException or UnauthorizedAccessException + && attempt < 9) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(10); + } + } } } } diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 44e32af9..bc410558 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests StringComparison.Ordinal); } + [Fact] + public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + + HeadlessSessionDescriptor session = Assert.Single( + HeadlessConfigurationLoader.Load(file.Path).Sessions)!; + + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + + [Fact] + public void HeadlessReaderPreservesProbeLoadNoneAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeProbe()); + + HeadlessSessionDescriptor session = Assert.Single( + HeadlessConfigurationLoader.Load(file.Path).Sessions)!; + + Assert.Equal(HeadlessSessionMode.Probe, session.Mode); + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + [Fact] public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs index 26bd3c0a..4baeae42 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs @@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests } [Fact] - public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() + public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted() { CharacterProfile character = Character(LaunchMode.Gui); character.Plugins = []; @@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests sessionId: "session-empty-lists"); JsonObject session = SingleSession(composed); - Assert.False(session.ContainsKey("plugins")); + Assert.True(session.ContainsKey("plugins")); + Assert.Empty(session["plugins"]!.AsArray()); Assert.False(session.ContainsKey("loginCommands")); } @@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests } [Fact] - public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands() + public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList() { ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( Server(), @@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests AssertKeys( session, - "id", "mode", "endpoint", "account", "credential", "statusFile"); + "id", "mode", "endpoint", "account", "credential", "plugins", "statusFile"); Assert.Equal("session-probe", (string?)session["id"]); Assert.Equal("probe", (string?)session["mode"]); @@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); Assert.False(session.ContainsKey("character")); Assert.False(session.ContainsKey("policy")); - Assert.False(session.ContainsKey("plugins")); + Assert.Empty(session["plugins"]!.AsArray()); Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommandDelayMs")); Assert.Equal( diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs index 9a1a9143..5349fcd7 100644 --- a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -11,11 +11,17 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; public sealed class HostPlugin : IAcDreamPlugin { private IPluginHost? _host; + private string? _assemblyDirectory; + private bool _throwAfterRegistration; private int _entitiesSeen; public void Initialize(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); + _assemblyDirectory = Path.GetDirectoryName( + typeof(HostPlugin).Assembly.Location); + _throwAfterRegistration = File.Exists( + Path.Combine(_assemblyDirectory!, "throw-after-register")); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); } @@ -27,6 +33,11 @@ public sealed class HostPlugin : IAcDreamPlugin Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), this); host.Events.EntitySpawned += OnEntitySpawned; + if (_throwAfterRegistration) + { + throw new InvalidOperationException( + "fixture enable failed after registering UI and events"); + } host.Log.Info( $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}"); } @@ -36,11 +47,24 @@ public sealed class HostPlugin : IAcDreamPlugin IPluginHost? host = _host; if (host is null) return; + if (_throwAfterRegistration) + { + throw new InvalidOperationException( + "fixture disable intentionally refuses cleanup"); + } host.Events.EntitySpawned -= OnEntitySpawned; host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); _host = null; } - private void OnEntitySpawned(WorldEntitySnapshot snapshot) => + private void OnEntitySpawned(WorldEntitySnapshot snapshot) + { _entitiesSeen++; + if (_throwAfterRegistration && _assemblyDirectory is not null) + { + File.AppendAllText( + Path.Combine(_assemblyDirectory, "unexpected-callback"), + $"{snapshot.Id}{Environment.NewLine}"); + } + } } diff --git a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs index 71b6581e..cc841868 100644 --- a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs +++ b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs @@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture return SessionConfigComposer.Serialize(composed.Document); } + + internal static string ComposeEmptyPlugins() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + var character = new CharacterProfile + { + Name = "Composer Character", + Id = "0x50000001", + LaunchMode = LaunchMode.Headless, + Plugins = [], + LoginCommands = [], + }; + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + server, + account, + character, + install, + paths, + "composer-empty-plugins"); + return SessionConfigComposer.Serialize(composed.Document); + } + + internal static string ComposeProbe() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + server, + account, + install, + paths, + "composer-probe"); + return SessionConfigComposer.Serialize(composed.Document); + } + + private static ( + ServerProfile Server, + AccountProfile Account, + LauncherInstallRecord Install, + ApplicationPathSet Paths) Inputs() => + ( + new ServerProfile + { + Name = "Composer Server", + Host = "composer.example", + Port = 9010, + }, + new AccountProfile + { + Account = "composer-account", + Password = Password, + }, + new LauncherInstallRecord( + "composer-dats", + "composer-dats/acdream.pak"), + new ApplicationPathSet( + Path.Combine(Path.GetTempPath(), "composer-config"), + Path.Combine(Path.GetTempPath(), "composer-data"), + Path.Combine(Path.GetTempPath(), "composer-cache"), + LegacyConfigDirectory: null)); } From f820eb258d4cd8dc30364de121b72d1cbac29b7d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:28:14 +0200 Subject: [PATCH 3/3] fix(plugins): close LA5 ownership races --- docs/plans/2026-08-14-launcher-campaign.md | 10 +- .../2026-08-14-launcher-campaign-design.md | 4 +- src/AcDream.Core/Plugins/LoadedPlugin.cs | 14 +- src/AcDream.Core/Plugins/PluginLoader.cs | 28 ++- src/AcDream.Core/Plugins/PluginSession.cs | 42 ++++- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 105 ++++++++++- .../Plugins/HeadlessPluginHost.cs | 21 ++- .../Launching/SessionConfigDocument.cs | 6 +- .../Entities/RuntimeEntityDirectory.cs | 176 ++++++++++++------ .../Entities/RuntimeEntityObjectViews.cs | 4 + .../Plugins/GraphicalPluginSessionTests.cs | 64 ++++++- .../Plugins/PluginLoaderTests.cs | 2 + .../HeadlessPluginSessionTests.cs | 24 ++- .../HostPlugin.cs | 74 +++++++- 14 files changed, 467 insertions(+), 107 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 00913fb2..0de911ff 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -155,7 +155,11 @@ Field rules: for gui/guiSelect/probe. - `credential`: always `{ "provider": "standardInput", "reference": "session" }` for launcher-composed configs. -- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, +- `plugins`: absent/null means load all discovered plugins (preserving the + developer flow); explicit `[]` means load none. Launcher-composed + normal-empty and probe sessions emit `[]` so they cannot load arbitrary + machine-local plugins. +- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, omitted-when-unset (never null, never `[]` for empty). Absent `loginCommandDelayMs` means 500. @@ -297,7 +301,9 @@ are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is genuinely App-only. Headless has zero plugin hosting today (confirmed). 1. Session-config `Plugins` allow-list filters the discovery result on BOTH - hosts (absent list = load all, preserving today's dev behavior). + hosts (absent/null list = load all, preserving today's dev behavior; + explicit `[]` = load none). Launcher-composed normal-empty and probe + sessions emit `[]`. 2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 42360f1d..fb4a37d6 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow. `HeadlessConfiguration` shape extended with: - `Plugins: string[]` — plugin names to load from the standard - `PluginsDirectory`; hosts load exactly this set. + `PluginsDirectory`; absent/null loads all discovered plugins, while an + explicit empty array loads none. Launcher-composed normal-empty and probe + sessions emit the empty array. - `LoginCommands: string[]` — ordered chat-typed strings. - Graphical host: `Character` selector may be ABSENT → character-select screen instead of auto-enter. diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs index ad48f6db..9f1f534a 100644 --- a/src/AcDream.Core/Plugins/LoadedPlugin.cs +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -7,17 +7,17 @@ namespace AcDream.Core.Plugins; /// Outcome of a plugin load attempt. /// On success, is the instantiated plugin, /// owns its assembly, and is null. -/// On failure, and are null, -/// describes what went wrong, and -/// weakly observes any collectible context -/// that was already released during rollback. +/// On failure, describes what went wrong. A partial +/// and/or may still be present; +/// the caller owns their cleanup. The loader never requests collectible unload +/// itself because the session must first roll back host registrations. /// public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, AssemblyLoadContext? LoadContext, - Exception? Error, - WeakReference? ReleasedLoadContext = null) + Exception? Error) { - public bool Success => Plugin is not null && Error is null; + public bool Success => + Plugin is not null && LoadContext is not null && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index 54042a51..1d729f2a 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -11,6 +11,8 @@ public static class PluginLoader /// implementing , instantiate it, and call its /// with the supplied host. Any failure /// is returned as a failed rather than thrown. + /// A returned partial plugin/context remains caller-owned; this method never + /// requests unload because the caller must close host registrations first. /// public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) { @@ -48,15 +50,12 @@ public static class PluginLoader if (pluginType is null) { - var released = new WeakReference(alc); - alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, - LoadContext: null, + LoadContext: alc, Error: new InvalidOperationException( - $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"), - ReleasedLoadContext: released); + $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); } instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; @@ -65,20 +64,15 @@ public static class PluginLoader } catch (Exception ex) { - // Initialize may have attached host callbacks before it failed. - // Give that partial instance the same best-effort cleanup chance - // as an Enable failure before releasing the collectible context. - try { instance?.Disable(); } - catch { } - WeakReference? released = alc is null ? null : new WeakReference(alc); - try { alc?.Unload(); } - catch { } + // The caller owns rollback for a partial instance/context. In + // particular, Initialize may already have attached host callbacks; + // the per-plugin host scope must remove those registrations before + // Disable or any collectible unload request can run. return new LoadedPlugin( manifest, - Plugin: null, - LoadContext: null, - Error: ex, - ReleasedLoadContext: released); + Plugin: instance, + LoadContext: alc, + Error: ex); } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index dfbbb3ef..1436fb6b 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -214,9 +214,11 @@ public sealed class PluginSession : IDisposable scope); if (!loaded.Success) { + // Initialize can register callbacks before it fails. The + // registration transaction closes before plugin cleanup + // and, critically, before any ALC Unloading notification. scope.Dispose(); - if (loaded.ReleasedLoadContext is { } released) - _releasedContexts.Add(released); + ReleaseFailedLoad(loaded); AddError( errors, id, @@ -304,6 +306,42 @@ public sealed class PluginSession : IDisposable } } + private void ReleaseFailedLoad(LoadedPlugin loaded) + { + if (loaded.Plugin is not null) + { + try + { + loaded.Plugin.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}", + error); + } + } + + if (loaded.LoadContext is null) + return; + + _releasedContexts.Add(new WeakReference(loaded.LoadContext)); + try + { + loaded.LoadContext.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after load failure failed: {loaded.Manifest.Id}", + error); + } + } + private void Report(PluginSessionStatus status) { if (_report is null) diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 61786b22..ee1667f1 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -4,13 +4,14 @@ namespace AcDream.Core.Plugins; /// /// Per-plugin host view that owns every registration made through the public -/// event/UI surfaces. Disposal is the host's rollback boundary: it removes +/// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes /// registrations even when plugin Initialize/Enable/Disable code throws. /// internal sealed class ScopedPluginHost : IPluginHost, IDisposable { private readonly IPluginHost _inner; private readonly ScopedEvents _events; + private readonly ScopedSelectionService _selection; private readonly ScopedUiRegistry _ui; private bool _disposed; @@ -18,6 +19,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _events = new ScopedEvents(inner.Events); + _selection = new ScopedSelectionService(inner.Selection); _ui = new ScopedUiRegistry(inner.Ui); } @@ -25,7 +27,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public IPluginLogger Log => _inner.Log; public IGameState State => _inner.State; public IEvents Events => _events; - public ISelectionService Selection => _inner.Selection; + public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; public void Dispose() @@ -34,9 +36,108 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable return; _disposed = true; _events.Dispose(); + _selection.Dispose(); _ui.Dispose(); } + private sealed class ScopedSelectionService(ISelectionService inner) + : ISelectionService, + IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public uint? SelectedObjectId => inner.SelectedObjectId; + public uint? PreviousObjectId => inner.PreviousObjectId; + + public event Action Changed + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.Changed += value; + } + catch + { + try { inner.Changed -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + try { inner.Changed -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedSelectionService)); + } + remove + { + if (value is null) + return; + inner.Changed -= value; + lock (_gate) + RemoveLast(value); + } + } + + public bool Select(uint objectId) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Select(objectId); + } + } + + public bool Clear() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Clear(); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.Changed -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable { private readonly object _gate = new(); diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 5eda1706..ca5b6d5a 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -52,8 +52,9 @@ internal sealed class HeadlessPluginHost public ISelectionService Selection => _runtime.ActionOwner.Selection; public IUiRegistry Ui => NoOpUiRegistry.Instance; - /// Test-only barrier after the borrowed replay snapshot is - /// captured and before delivery starts. + /// Test-only barrier invoked after the first replay item is + /// captured while Runtime's exact active-membership read lease is still + /// held. internal Action? ReplayCapturedForTest { get; set; } /// @@ -88,11 +89,12 @@ internal sealed class HeadlessPluginHost // Arm the pending queue before borrowing Runtime's snapshot. This // avoids a host-lock/Runtime-lock inversion while the identity // dedup below collapses any registration present in both views. - var visitor = new SnapshotVisitor(_runtime); + var visitor = new SnapshotVisitor( + _runtime, + ReplayCapturedForTest); _runtime.Entities.Visit(visitor); ReplayEntity[] replay = visitor.Items.ToArray(); - ReplayCapturedForTest?.Invoke(); foreach (ReplayEntity item in replay) { lock (_eventGate) @@ -234,16 +236,23 @@ internal sealed class HeadlessPluginHost catch { } } - private sealed class SnapshotVisitor(GameRuntime runtime) + private sealed class SnapshotVisitor( + GameRuntime runtime, + Action? captureBarrier = null) : IRuntimeEntityVisitor { + private Action? _captureBarrier = captureBarrier; + internal List Items { get; } = new(runtime.Entities.Count); - public void Visit(in RuntimeEntitySnapshot entity) => + public void Visit(in RuntimeEntitySnapshot entity) + { Items.Add(new ReplayEntity( entity.Identity, Convert(runtime, entity))); + Interlocked.Exchange(ref _captureBarrier, null)?.Invoke(); + } } private void RebuildLiveSnapshotLocked() diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index 0f013f53..1fc31f4d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -98,8 +98,10 @@ public sealed class SessionDescriptor public SessionCredentialDescriptor Credential { get; init; } = new(); - /// Omitted (never an empty array) when the character has no - /// configured plugin set. + /// Plugin allow-list. Omitted or JSON null means load all + /// discovered plugins (the developer flow); an explicit empty array means + /// load none. Launcher-composed normal-empty and probe sessions therefore + /// emit []. public List? Plugins { get; init; } /// Omitted (never an empty array) when the character has no diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index fe733332..98dbd9cb 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory public const uint LastLocalEntityId = 0x3FFF_FFFFu; private readonly InboundPhysicsStateController _inbound = new(); + private readonly object _activeGate = new(); private readonly Dictionary _activeByGuid = new(); private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord> _teardownByIncarnation = new(); @@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory _nextLocalEntityId = firstLocalEntityId; } - public int Count => _activeByGuid.Count; + public int Count + { + get + { + lock (_activeGate) + return _activeByGuid.Count; + } + } public int PendingTeardownCount => _teardownByIncarnation.Count; - public int ClaimedLocalIdCount => _byLocalId.Count; + public int ClaimedLocalIdCount + { + get + { + lock (_activeGate) + return _byLocalId.Count; + } + } public ulong SessionLifetimeVersion { get; private set; } + /// Update-thread-only borrowed collection. Cross-thread hosts use + /// through IRuntimeEntityView.Visit + /// so membership cannot change during enumeration. public IReadOnlyCollection ActiveRecords => _activeByGuid.Values; public IReadOnlyCollection TeardownRecords => _teardownByIncarnation.Values; @@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot) { - if (_activeByGuid.ContainsKey(snapshot.Guid)) + lock (_activeGate) { - throw new InvalidOperationException( - $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); - } + if (_activeByGuid.ContainsKey(snapshot.Guid)) + { + throw new InvalidOperationException( + $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); + } - var record = new RuntimeEntityRecord(snapshot); - _activeByGuid.Add(snapshot.Guid, record); - try - { - ClaimLocalId(record); - return record; - } - catch - { - _activeByGuid.Remove(snapshot.Guid); - throw; + var record = new RuntimeEntityRecord(snapshot); + _activeByGuid.Add(snapshot.Guid, record); + try + { + ClaimLocalId(record); + return record; + } + catch + { + _activeByGuid.Remove(snapshot.Guid); + throw; + } } } - public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => - _activeByGuid.Remove(guid, out record); + public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) + { + lock (_activeGate) + return _activeByGuid.Remove(guid, out record); + } public bool RemoveActive(RuntimeEntityRecord expected) { - if (!IsCurrent(expected)) - return false; - return _activeByGuid.Remove(expected.ServerGuid); + lock (_activeGate) + { + if (!_activeByGuid.TryGetValue( + expected.ServerGuid, + out RuntimeEntityRecord? current) + || !ReferenceEquals(current, expected)) + { + return false; + } + return _activeByGuid.Remove(expected.ServerGuid); + } } public void RetainTeardown(RuntimeEntityRecord record) @@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory public uint ClaimLocalId(RuntimeEntityRecord record) { - if (!IsKnown(record)) + lock (_activeGate) { - throw new InvalidOperationException( - "A local id can only be claimed for an active or retained incarnation."); + if (!IsKnown(record)) + { + throw new InvalidOperationException( + "A local id can only be claimed for an active or retained incarnation."); + } + + if (record.LocalEntityId is { } existing) + return existing; + + uint start = _nextLocalEntityId; + do + { + uint candidate = _nextLocalEntityId; + _nextLocalEntityId = candidate == LastLocalEntityId + ? FirstLocalEntityId + : candidate + 1u; + if (_byLocalId.ContainsKey(candidate)) + continue; + + _byLocalId.Add(candidate, record); + record.LocalEntityId = candidate; + return candidate; + } + while (_nextLocalEntityId != start); + + throw new InvalidOperationException("The live entity id namespace is exhausted."); } - - if (record.LocalEntityId is { } existing) - return existing; - - uint start = _nextLocalEntityId; - do - { - uint candidate = _nextLocalEntityId; - _nextLocalEntityId = candidate == LastLocalEntityId - ? FirstLocalEntityId - : candidate + 1u; - if (_byLocalId.ContainsKey(candidate)) - continue; - - _byLocalId.Add(candidate, record); - record.LocalEntityId = candidate; - return candidate; - } - while (_nextLocalEntityId != start); - - throw new InvalidOperationException("The live entity id namespace is exhausted."); } public bool ReleaseLocalId(RuntimeEntityRecord record) { - if (record.LocalEntityId is not { } localId) - return false; - if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained) - && ReferenceEquals(retained, record)) + lock (_activeGate) { - _byLocalId.Remove(localId); - } + if (record.LocalEntityId is not { } localId) + return false; + if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + _byLocalId.Remove(localId); + } - record.LocalEntityId = null; - return true; + record.LocalEntityId = null; + return true; + } } public ulong AdvanceLifetimeMutation(uint serverGuid) @@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory public bool CompleteSessionClearIfConverged() { - if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) - return false; + lock (_activeGate) + { + if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) + return false; - _byLocalId.Clear(); + _byLocalId.Clear(); + } ParentAttachments.Clear(); _inbound.Clear(); return true; } + /// + /// Enters the exact active-membership read boundary. Add/remove and local-id + /// membership commits serialize behind this allocation-free lease; record + /// ownership remains canonical here and no copied gameplay collection is + /// introduced. + /// + internal ActiveReadLease AcquireActiveRead() => new(_activeGate); + + internal readonly struct ActiveReadLease : IDisposable + { + private readonly object _gate; + + internal ActiveReadLease(object gate) + { + _gate = gate; + Monitor.Enter(gate); + } + + public void Dispose() => Monitor.Exit(_gate); + } + public void RefreshSnapshot( RuntimeEntityRecord record, WorldSession.EntitySpawn accepted, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs index 26f27e23..8ac2d909 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs @@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews uint serverGuid, out RuntimeEntitySnapshot entity) { + using RuntimeEntityDirectory.ActiveReadLease lease = + owner.AcquireActiveRead(); if (owner.TryGetActive( serverGuid, out RuntimeEntityRecord record)) @@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews public void Visit(IRuntimeEntityVisitor visitor) { ArgumentNullException.ThrowIfNull(visitor); + using RuntimeEntityDirectory.ActiveReadLease lease = + owner.AcquireActiveRead(); foreach (RuntimeEntityRecord record in owner.ActiveRecords) { RuntimeEntitySnapshot entity = Snapshot(record); diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index 8f4a2e10..97850c53 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -15,6 +15,8 @@ public sealed class GraphicalPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; private const string ThrowingId = "acdream.test.throwing-fixture"; + private const string InitializeThrowingId = + "acdream.test.initialize-throwing-fixture"; [Fact] public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() @@ -114,12 +116,13 @@ public sealed class GraphicalPluginSessionTests string.Empty); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); var events = new WorldEvents(); + var selection = new SelectionState(); var ui = new BufferedUiRegistry(); var host = new AppPluginHost( new CapturingLogger(), new WorldGameState(), events, - new SelectionState(), + selection, ui); using GraphicalPluginSession plugins = GraphicalPluginSession.Create( @@ -138,6 +141,65 @@ public sealed class GraphicalPluginSessionTests 2u, default, System.Numerics.Quaternion.Identity)); + Assert.True(((ISelectionService)selection).Select(7u)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + ["started", "pluginFailed"], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void InitializeFailureRollsBackEveryRegistrationBeforeUnload() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string pluginDirectory = InstallFixture( + paths.PluginsDirectory, + InitializeThrowingId, + "initialize-throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-during-initialize"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var events = new WorldEvents(); + var selection = new SelectionState(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + events, + selection, + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( + paths, + [InitializeThrowingId], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + plugins.Start(); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(ui.Drain()); + Assert.Equal(0, ui.RegistrationCount); + Assert.Equal( + "ui=True;events=True;selection=True", + File.ReadAllText(Path.Combine( + pluginDirectory, + "unload-observation"))); + events.FireEntitySpawned(new WorldEntitySnapshot( + 1u, + 2u, + default, + System.Numerics.Quaternion.Identity)); + Assert.True(((ISelectionService)selection).Select(9u)); Assert.False(File.Exists( Path.Combine(pluginDirectory, "unexpected-callback"))); Assert.Equal( diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 3deebb94..e35755a7 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -125,5 +125,7 @@ public class PluginLoaderTests Assert.False(loaded.Success); Assert.Contains("IAcDreamPlugin", loaded.Error!.Message); + Assert.NotNull(loaded.LoadContext); + loaded.LoadContext!.Unload(); } } diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index e2aa76ad..c2f332ef 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -125,6 +125,8 @@ public sealed class HeadlessPluginSessionTests _ = session.Start(); Assert.Equal(0, session.Plugins.LoadedCount); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + Assert.True(((ISelectionService)session.Runtime.ActionOwner.Selection) + .Select(7u)); Assert.False(File.Exists( Path.Combine(pluginDirectory, "unexpected-callback"))); Assert.Equal( @@ -203,9 +205,25 @@ public sealed class HeadlessPluginSessionTests Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); - _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); - releaseReplay.Set(); - await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); + using var registrationStarted = new ManualResetEventSlim(); + Task registration = Task.Run(() => + { + registrationStarted.Set(); + _ = session.Runtime.EntityObjects.RegisterEntity( + Spawn(0x50000002u, 2f)); + }); + Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10))); + try + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + Assert.False(registration.IsCompleted); + } + finally + { + releaseReplay.Set(); + } + await Task.WhenAll(subscribe, registration) + .WaitAsync(TimeSpan.FromSeconds(10)); host.Events.EntitySpawned -= handler; Assert.Equal([1_000_000u, 1_000_001u], observed); diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs index 5349fcd7..b294f697 100644 --- a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -1,4 +1,5 @@ using AcDream.Plugin.Abstractions; +using System.Runtime.Loader; namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; @@ -13,6 +14,7 @@ public sealed class HostPlugin : IAcDreamPlugin private IPluginHost? _host; private string? _assemblyDirectory; private bool _throwAfterRegistration; + private bool _throwDuringInitialize; private int _entitiesSeen; public void Initialize(IPluginHost host) @@ -22,17 +24,24 @@ public sealed class HostPlugin : IAcDreamPlugin typeof(HostPlugin).Assembly.Location); _throwAfterRegistration = File.Exists( Path.Combine(_assemblyDirectory!, "throw-after-register")); + _throwDuringInitialize = File.Exists( + Path.Combine(_assemblyDirectory!, "throw-during-initialize")); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); + if (_throwDuringInitialize) + { + RegisterHostCallbacks(host); + AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)! + .Unloading += OnUnloading; + throw new InvalidOperationException( + "fixture initialize failed after registering UI, entity, and selection callbacks"); + } } public void Enable() { IPluginHost host = _host ?? throw new InvalidOperationException("The fixture was not initialized."); - host.Ui.AddMarkupPanel( - Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), - this); - host.Events.EntitySpawned += OnEntitySpawned; + RegisterHostCallbacks(host); if (_throwAfterRegistration) { throw new InvalidOperationException( @@ -47,24 +56,75 @@ public sealed class HostPlugin : IAcDreamPlugin IPluginHost? host = _host; if (host is null) return; - if (_throwAfterRegistration) + if (_throwAfterRegistration || _throwDuringInitialize) { throw new InvalidOperationException( "fixture disable intentionally refuses cleanup"); } host.Events.EntitySpawned -= OnEntitySpawned; + host.Selection.Changed -= OnSelectionChanged; host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); _host = null; } + private void RegisterHostCallbacks(IPluginHost host) + { + host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), + this); + host.Events.EntitySpawned += OnEntitySpawned; + host.Selection.Changed += OnSelectionChanged; + } + private void OnEntitySpawned(WorldEntitySnapshot snapshot) { _entitiesSeen++; - if (_throwAfterRegistration && _assemblyDirectory is not null) + RecordUnexpectedCallback(snapshot.Id); + } + + private void OnSelectionChanged(SelectionChangedEvent change) => + RecordUnexpectedCallback(change.SelectedObjectId ?? 0u); + + private void RecordUnexpectedCallback(uint objectId) + { + if ((_throwAfterRegistration || _throwDuringInitialize) + && _assemblyDirectory is not null) { File.AppendAllText( Path.Combine(_assemblyDirectory, "unexpected-callback"), - $"{snapshot.Id}{Environment.NewLine}"); + $"{objectId}{Environment.NewLine}"); + } + } + + private void OnUnloading(AssemblyLoadContext context) + { + IPluginHost host = _host!; + bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"), + this)); + bool eventsClosed = Rejects(() => + { + host.Events.EntitySpawned += OnEntitySpawned; + }); + bool selectionClosed = Rejects(() => + { + host.Selection.Changed += OnSelectionChanged; + }); + File.WriteAllText( + Path.Combine(_assemblyDirectory!, "unload-observation"), + $"ui={uiClosed};events={eventsClosed};selection={selectionClosed}"); + } + + private static bool Rejects(Action action) + { + try + { + action(); + return false; + } + catch (ObjectDisposedException) + { + return true; } } }