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)); }