fix(plugins): close LA5 host lifecycle review
This commit is contained in:
parent
95f4be94db
commit
fbe9c8a288
25 changed files with 1043 additions and 120 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue