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

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