From 95f4be94db100f5602a56c0a80d12ec3d113221b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:12:59 +0200 Subject: [PATCH] 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.