fix(plugins): close LA5 host lifecycle review

This commit is contained in:
Erik 2026-08-14 19:05:13 +02:00
parent 95f4be94db
commit fbe9c8a288
25 changed files with 1043 additions and 120 deletions

View file

@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies(
Action<string> Log,
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
/// configured.</summary>
SessionStatusWriter StatusWriter,
string SessionId);
Action<string> Log);
/// <summary>
/// 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);

View file

@ -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.
/// </summary>
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> _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<long, Registration> _registrations = [];
private long _nextRegistrationId;
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
=> _ = RegisterMarkupPanel(markupPath, binding);
/// <summary>Return + clear all buffered registrations.</summary>
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);
}
/// <summary>Returns each not-yet-drained active registration once.</summary>
public IReadOnlyList<Pending> Drain()
{
var copy = _pending.ToArray();
_pending.Clear();
return copy;
lock (_gate)
{
var pending = new List<Pending>(_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);
}
}

View file

@ -14,10 +14,24 @@ namespace AcDream.App.Plugins;
internal sealed class GraphicalPluginSession : IDisposable
{
private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private readonly string _sessionId;
private readonly SessionStatusWriter _statusWriter;
private bool _started;
private GraphicalPluginSession(PluginSession plugins)
private GraphicalPluginSession(
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? 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<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences();
internal static GraphicalPluginSession Start(
internal static GraphicalPluginSession Create(
ApplicationPathSet paths,
IReadOnlyList<string>? 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();

View file

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

View file

@ -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);
}
/// <summary>
/// Transfers the graphical plugin lifetime into the window shutdown graph
/// and starts it before retained UI construction drains registrations.
/// </summary>
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,

View file

@ -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()),

View file

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

View file

@ -7,14 +7,17 @@ namespace AcDream.Core.Plugins;
/// Outcome of a plugin load attempt.
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
/// owns its assembly, and <see cref="Error"/> is null.</para>
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and
/// <see cref="Error"/> describes what went wrong.</para>
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null,
/// <see cref="Error"/> describes what went wrong, and
/// <see cref="ReleasedLoadContext"/> weakly observes any collectible context
/// that was already released during rollback.</para>
/// </summary>
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;
}

View file

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

View file

@ -27,7 +27,8 @@ public sealed class PluginSession : IDisposable
{
private readonly IPluginHost _host;
private readonly Action<PluginSessionStatus>? _report;
private readonly List<LoadedPlugin> _loaded = [];
private readonly List<ActivePlugin> _loaded = [];
private readonly List<WeakReference> _releasedContexts = [];
private bool _started;
private bool _disposed;
@ -42,7 +43,7 @@ public sealed class PluginSession : IDisposable
public int LoadedCount => _loaded.Count;
public IReadOnlyList<string> LoadedPluginIds =>
_loaded.Select(static plugin => plugin.Manifest.Id).ToArray();
_loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<WeakReference> 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);
}

View file

@ -0,0 +1,171 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>
/// 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.
/// </summary>
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<Action<WorldEntitySnapshot>> _registrations = [];
private bool _disposed;
public event Action<WorldEntitySnapshot> 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<WorldEntitySnapshot>[] 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<WorldEntitySnapshot> 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<IDisposable> _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 { }
}
}
}
}

View file

@ -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;

View file

@ -19,9 +19,23 @@ internal sealed class HeadlessPluginHost
private readonly GameRuntime _runtime;
private readonly IDisposable _eventSubscription;
private readonly object _eventGate = new();
private Action<WorldEntitySnapshot>? _entitySpawned;
private readonly List<Subscription> _subscriptions = [];
private Subscription[] _liveSnapshot = [];
private bool _disposed;
private readonly record struct ReplayEntity(
RuntimeEntityIdentity Identity,
WorldEntitySnapshot Snapshot);
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
{
internal Action<WorldEntitySnapshot> Handler { get; } = handler;
internal Queue<ReplayEntity> Pending { get; } = new();
internal HashSet<RuntimeEntityIdentity> 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;
/// <summary>Test-only barrier after the borrowed replay snapshot is
/// captured and before delivery starts.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <summary>
/// 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<WorldEntitySnapshot>? 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<WorldEntitySnapshot> handler
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
{
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<WorldEntitySnapshot> Snapshots { get; } =
internal List<ReplayEntity> 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();
}
}

View file

@ -14,24 +14,31 @@ internal sealed class HeadlessPluginSession : IDisposable
{
private readonly HeadlessPluginHost _host;
private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private int _disposeStage;
private bool _started;
private bool _disposed;
private HeadlessPluginSession(
HeadlessPluginHost host,
PluginSession plugins)
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList)
{
_host = host;
_plugins = plugins;
_roots = roots;
_allowList = allowList;
}
internal int LoadedCount => _plugins.LoadedCount;
internal IPluginHost Host => _host;
internal HeadlessPluginHost Host => _host;
internal IReadOnlyList<WeakReference> 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()

View file

@ -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 <c>mode: "probe"</c>,
/// no <c>character</c> selector, and no <c>policy</c> — the host
/// reports the account's character roster over the status stream and
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c>
/// 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
/// <c>plugins</c> allow-list so a plugin installed on the machine cannot
/// run merely because the probe has no character-level plugin settings.
/// </summary>
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,

View file

@ -15,12 +15,24 @@ public interface IUiRegistry
void AddMarkupPanel(string markupPath, object binding);
}
/// <summary>
/// Host-infrastructure extension used to give each plugin a removable UI
/// registration lifetime. Plugins continue to call
/// <see cref="IUiRegistry.AddMarkupPanel"/>; 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.
/// </summary>
public interface IScopedUiRegistry : IUiRegistry
{
IDisposable RegisterMarkupPanel(string markupPath, object binding);
}
/// <summary>
/// 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.
/// </summary>
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()
{
}
}
}