fix(plugins): close LA5 ownership races

This commit is contained in:
Erik 2026-08-14 19:28:14 +02:00
parent fbe9c8a288
commit f820eb258d
14 changed files with 467 additions and 107 deletions

View file

@ -155,7 +155,11 @@ Field rules:
for gui/guiSelect/probe. for gui/guiSelect/probe.
- `credential`: always `{ "provider": "standardInput", "reference": - `credential`: always `{ "provider": "standardInput", "reference":
"session" }` for launcher-composed configs. "session" }` for launcher-composed configs.
- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, - `plugins`: absent/null means load all discovered plugins (preserving the
developer flow); explicit `[]` means load none. Launcher-composed
normal-empty and probe sessions emit `[]` so they cannot load arbitrary
machine-local plugins.
- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
omitted-when-unset (never null, never `[]` for empty). Absent omitted-when-unset (never null, never `[]` for empty). Absent
`loginCommandDelayMs` means 500. `loginCommandDelayMs` means 500.
@ -297,7 +301,9 @@ are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is
genuinely App-only. Headless has zero plugin hosting today (confirmed). genuinely App-only. Headless has zero plugin hosting today (confirmed).
1. Session-config `Plugins` allow-list filters the discovery result on BOTH 1. Session-config `Plugins` allow-list filters the discovery result on BOTH
hosts (absent list = load all, preserving today's dev behavior). hosts (absent/null list = load all, preserving today's dev behavior;
explicit `[]` = load none). Launcher-composed normal-empty and probe
sessions emit `[]`.
2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned 2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned
`State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new
capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect

View file

@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow.
`HeadlessConfiguration` shape extended with: `HeadlessConfiguration` shape extended with:
- `Plugins: string[]` — plugin names to load from the standard - `Plugins: string[]` — plugin names to load from the standard
`PluginsDirectory`; hosts load exactly this set. `PluginsDirectory`; absent/null loads all discovered plugins, while an
explicit empty array loads none. Launcher-composed normal-empty and probe
sessions emit the empty array.
- `LoginCommands: string[]` — ordered chat-typed strings. - `LoginCommands: string[]` — ordered chat-typed strings.
- Graphical host: `Character` selector may be ABSENT → character-select - Graphical host: `Character` selector may be ABSENT → character-select
screen instead of auto-enter. screen instead of auto-enter.

View file

@ -7,17 +7,17 @@ namespace AcDream.Core.Plugins;
/// Outcome of a plugin load attempt. /// Outcome of a plugin load attempt.
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/> /// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
/// owns its assembly, and <see cref="Error"/> is null.</para> /// owns its assembly, and <see cref="Error"/> is null.</para>
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null, /// <para>On failure, <see cref="Error"/> describes what went wrong. A partial
/// <see cref="Error"/> describes what went wrong, and /// <see cref="Plugin"/> and/or <see cref="LoadContext"/> may still be present;
/// <see cref="ReleasedLoadContext"/> weakly observes any collectible context /// the caller owns their cleanup. The loader never requests collectible unload
/// that was already released during rollback.</para> /// itself because the session must first roll back host registrations.</para>
/// </summary> /// </summary>
public sealed record LoadedPlugin( public sealed record LoadedPlugin(
PluginManifest Manifest, PluginManifest Manifest,
IAcDreamPlugin? Plugin, IAcDreamPlugin? Plugin,
AssemblyLoadContext? LoadContext, AssemblyLoadContext? LoadContext,
Exception? Error, Exception? Error)
WeakReference? ReleasedLoadContext = null)
{ {
public bool Success => Plugin is not null && Error is null; public bool Success =>
Plugin is not null && LoadContext is not null && Error is null;
} }

View file

@ -11,6 +11,8 @@ public static class PluginLoader
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its /// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure /// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown. /// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
/// A returned partial plugin/context remains caller-owned; this method never
/// requests unload because the caller must close host registrations first.
/// </summary> /// </summary>
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
{ {
@ -48,15 +50,12 @@ public static class PluginLoader
if (pluginType is null) if (pluginType is null)
{ {
var released = new WeakReference(alc);
alc.Unload();
return new LoadedPlugin( return new LoadedPlugin(
manifest, manifest,
Plugin: null, Plugin: null,
LoadContext: null, LoadContext: alc,
Error: new InvalidOperationException( Error: new InvalidOperationException(
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"), $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
ReleasedLoadContext: released);
} }
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
@ -65,20 +64,15 @@ public static class PluginLoader
} }
catch (Exception ex) catch (Exception ex)
{ {
// Initialize may have attached host callbacks before it failed. // The caller owns rollback for a partial instance/context. In
// Give that partial instance the same best-effort cleanup chance // particular, Initialize may already have attached host callbacks;
// as an Enable failure before releasing the collectible context. // the per-plugin host scope must remove those registrations before
try { instance?.Disable(); } // Disable or any collectible unload request can run.
catch { }
WeakReference? released = alc is null ? null : new WeakReference(alc);
try { alc?.Unload(); }
catch { }
return new LoadedPlugin( return new LoadedPlugin(
manifest, manifest,
Plugin: null, Plugin: instance,
LoadContext: null, LoadContext: alc,
Error: ex, Error: ex);
ReleasedLoadContext: released);
} }
} }
} }

View file

@ -214,9 +214,11 @@ public sealed class PluginSession : IDisposable
scope); scope);
if (!loaded.Success) if (!loaded.Success)
{ {
// Initialize can register callbacks before it fails. The
// registration transaction closes before plugin cleanup
// and, critically, before any ALC Unloading notification.
scope.Dispose(); scope.Dispose();
if (loaded.ReleasedLoadContext is { } released) ReleaseFailedLoad(loaded);
_releasedContexts.Add(released);
AddError( AddError(
errors, errors,
id, id,
@ -304,6 +306,42 @@ public sealed class PluginSession : IDisposable
} }
} }
private void ReleaseFailedLoad(LoadedPlugin loaded)
{
if (loaded.Plugin is not null)
{
try
{
loaded.Plugin.Disable();
}
catch (Exception error)
{
SafeLog(
static (log, message, exception) =>
log.Error(message, exception),
$"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}",
error);
}
}
if (loaded.LoadContext is null)
return;
_releasedContexts.Add(new WeakReference(loaded.LoadContext));
try
{
loaded.LoadContext.Unload();
}
catch (Exception error)
{
SafeLog(
static (log, message, exception) =>
log.Error(message, exception),
$"plugin unload after load failure failed: {loaded.Manifest.Id}",
error);
}
}
private void Report(PluginSessionStatus status) private void Report(PluginSessionStatus status)
{ {
if (_report is null) if (_report is null)

View file

@ -4,13 +4,14 @@ namespace AcDream.Core.Plugins;
/// <summary> /// <summary>
/// Per-plugin host view that owns every registration made through the public /// Per-plugin host view that owns every registration made through the public
/// event/UI surfaces. Disposal is the host's rollback boundary: it removes /// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes
/// registrations even when plugin Initialize/Enable/Disable code throws. /// registrations even when plugin Initialize/Enable/Disable code throws.
/// </summary> /// </summary>
internal sealed class ScopedPluginHost : IPluginHost, IDisposable internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{ {
private readonly IPluginHost _inner; private readonly IPluginHost _inner;
private readonly ScopedEvents _events; private readonly ScopedEvents _events;
private readonly ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui; private readonly ScopedUiRegistry _ui;
private bool _disposed; private bool _disposed;
@ -18,6 +19,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_events = new ScopedEvents(inner.Events); _events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(inner.Ui); _ui = new ScopedUiRegistry(inner.Ui);
} }
@ -25,7 +27,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public IPluginLogger Log => _inner.Log; public IPluginLogger Log => _inner.Log;
public IGameState State => _inner.State; public IGameState State => _inner.State;
public IEvents Events => _events; public IEvents Events => _events;
public ISelectionService Selection => _inner.Selection; public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui; public IUiRegistry Ui => _ui;
public void Dispose() public void Dispose()
@ -34,9 +36,108 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
return; return;
_disposed = true; _disposed = true;
_events.Dispose(); _events.Dispose();
_selection.Dispose();
_ui.Dispose(); _ui.Dispose();
} }
private sealed class ScopedSelectionService(ISelectionService inner)
: ISelectionService,
IDisposable
{
private readonly object _gate = new();
private readonly List<Action<SelectionChangedEvent>> _registrations = [];
private bool _disposed;
public uint? SelectedObjectId => inner.SelectedObjectId;
public uint? PreviousObjectId => inner.PreviousObjectId;
public event Action<SelectionChangedEvent> Changed
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.Changed += value;
}
catch
{
try { inner.Changed -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(value);
return;
}
}
try { inner.Changed -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedSelectionService));
}
remove
{
if (value is null)
return;
inner.Changed -= value;
lock (_gate)
RemoveLast(value);
}
}
public bool Select(uint objectId)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return inner.Select(objectId);
}
}
public bool Clear()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return inner.Clear();
}
}
public void Dispose()
{
Action<SelectionChangedEvent>[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { inner.Changed -= registrations[index]; }
catch { }
}
}
private void RemoveLast(Action<SelectionChangedEvent> handler)
{
for (int index = _registrations.Count - 1; index >= 0; index--)
{
if (_registrations[index] != handler)
continue;
_registrations.RemoveAt(index);
return;
}
}
}
private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable
{ {
private readonly object _gate = new(); private readonly object _gate = new();

View file

@ -52,8 +52,9 @@ internal sealed class HeadlessPluginHost
public ISelectionService Selection => _runtime.ActionOwner.Selection; public ISelectionService Selection => _runtime.ActionOwner.Selection;
public IUiRegistry Ui => NoOpUiRegistry.Instance; public IUiRegistry Ui => NoOpUiRegistry.Instance;
/// <summary>Test-only barrier after the borrowed replay snapshot is /// <summary>Test-only barrier invoked after the first replay item is
/// captured and before delivery starts.</summary> /// captured while Runtime's exact active-membership read lease is still
/// held.</summary>
internal Action? ReplayCapturedForTest { get; set; } internal Action? ReplayCapturedForTest { get; set; }
/// <summary> /// <summary>
@ -88,11 +89,12 @@ internal sealed class HeadlessPluginHost
// Arm the pending queue before borrowing Runtime's snapshot. This // Arm the pending queue before borrowing Runtime's snapshot. This
// avoids a host-lock/Runtime-lock inversion while the identity // avoids a host-lock/Runtime-lock inversion while the identity
// dedup below collapses any registration present in both views. // dedup below collapses any registration present in both views.
var visitor = new SnapshotVisitor(_runtime); var visitor = new SnapshotVisitor(
_runtime,
ReplayCapturedForTest);
_runtime.Entities.Visit(visitor); _runtime.Entities.Visit(visitor);
ReplayEntity[] replay = visitor.Items.ToArray(); ReplayEntity[] replay = visitor.Items.ToArray();
ReplayCapturedForTest?.Invoke();
foreach (ReplayEntity item in replay) foreach (ReplayEntity item in replay)
{ {
lock (_eventGate) lock (_eventGate)
@ -234,16 +236,23 @@ internal sealed class HeadlessPluginHost
catch { } catch { }
} }
private sealed class SnapshotVisitor(GameRuntime runtime) private sealed class SnapshotVisitor(
GameRuntime runtime,
Action? captureBarrier = null)
: IRuntimeEntityVisitor : IRuntimeEntityVisitor
{ {
private Action? _captureBarrier = captureBarrier;
internal List<ReplayEntity> Items { get; } = internal List<ReplayEntity> Items { get; } =
new(runtime.Entities.Count); new(runtime.Entities.Count);
public void Visit(in RuntimeEntitySnapshot entity) => public void Visit(in RuntimeEntitySnapshot entity)
{
Items.Add(new ReplayEntity( Items.Add(new ReplayEntity(
entity.Identity, entity.Identity,
Convert(runtime, entity))); Convert(runtime, entity)));
Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
}
} }
private void RebuildLiveSnapshotLocked() private void RebuildLiveSnapshotLocked()

View file

@ -98,8 +98,10 @@ public sealed class SessionDescriptor
public SessionCredentialDescriptor Credential { get; init; } = new(); public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Omitted (never an empty array) when the character has no /// <summary>Plugin allow-list. Omitted or JSON <c>null</c> means load all
/// configured plugin set.</summary> /// discovered plugins (the developer flow); an explicit empty array means
/// load none. Launcher-composed normal-empty and probe sessions therefore
/// emit <c>[]</c>.</summary>
public List<string>? Plugins { get; init; } public List<string>? Plugins { get; init; }
/// <summary>Omitted (never an empty array) when the character has no /// <summary>Omitted (never an empty array) when the character has no

View file

@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory
public const uint LastLocalEntityId = 0x3FFF_FFFFu; public const uint LastLocalEntityId = 0x3FFF_FFFFu;
private readonly InboundPhysicsStateController _inbound = new(); private readonly InboundPhysicsStateController _inbound = new();
private readonly object _activeGate = new();
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new(); private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord> private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
_teardownByIncarnation = new(); _teardownByIncarnation = new();
@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory
_nextLocalEntityId = firstLocalEntityId; _nextLocalEntityId = firstLocalEntityId;
} }
public int Count => _activeByGuid.Count; public int Count
{
get
{
lock (_activeGate)
return _activeByGuid.Count;
}
}
public int PendingTeardownCount => _teardownByIncarnation.Count; public int PendingTeardownCount => _teardownByIncarnation.Count;
public int ClaimedLocalIdCount => _byLocalId.Count; public int ClaimedLocalIdCount
{
get
{
lock (_activeGate)
return _byLocalId.Count;
}
}
public ulong SessionLifetimeVersion { get; private set; } public ulong SessionLifetimeVersion { get; private set; }
/// <summary>Update-thread-only borrowed collection. Cross-thread hosts use
/// <see cref="AcquireActiveRead"/> through <c>IRuntimeEntityView.Visit</c>
/// so membership cannot change during enumeration.</summary>
public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values; public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values;
public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords => public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords =>
_teardownByIncarnation.Values; _teardownByIncarnation.Values;
@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot) public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
{ {
if (_activeByGuid.ContainsKey(snapshot.Guid)) lock (_activeGate)
{ {
throw new InvalidOperationException( if (_activeByGuid.ContainsKey(snapshot.Guid))
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); {
} throw new InvalidOperationException(
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
}
var record = new RuntimeEntityRecord(snapshot); var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record); _activeByGuid.Add(snapshot.Guid, record);
try try
{ {
ClaimLocalId(record); ClaimLocalId(record);
return record; return record;
} }
catch catch
{ {
_activeByGuid.Remove(snapshot.Guid); _activeByGuid.Remove(snapshot.Guid);
throw; throw;
}
} }
} }
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => public bool RemoveActive(uint guid, out RuntimeEntityRecord? record)
_activeByGuid.Remove(guid, out record); {
lock (_activeGate)
return _activeByGuid.Remove(guid, out record);
}
public bool RemoveActive(RuntimeEntityRecord expected) public bool RemoveActive(RuntimeEntityRecord expected)
{ {
if (!IsCurrent(expected)) lock (_activeGate)
return false; {
return _activeByGuid.Remove(expected.ServerGuid); if (!_activeByGuid.TryGetValue(
expected.ServerGuid,
out RuntimeEntityRecord? current)
|| !ReferenceEquals(current, expected))
{
return false;
}
return _activeByGuid.Remove(expected.ServerGuid);
}
} }
public void RetainTeardown(RuntimeEntityRecord record) public void RetainTeardown(RuntimeEntityRecord record)
@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory
public uint ClaimLocalId(RuntimeEntityRecord record) public uint ClaimLocalId(RuntimeEntityRecord record)
{ {
if (!IsKnown(record)) lock (_activeGate)
{ {
throw new InvalidOperationException( if (!IsKnown(record))
"A local id can only be claimed for an active or retained incarnation."); {
throw new InvalidOperationException(
"A local id can only be claimed for an active or retained incarnation.");
}
if (record.LocalEntityId is { } existing)
return existing;
uint start = _nextLocalEntityId;
do
{
uint candidate = _nextLocalEntityId;
_nextLocalEntityId = candidate == LastLocalEntityId
? FirstLocalEntityId
: candidate + 1u;
if (_byLocalId.ContainsKey(candidate))
continue;
_byLocalId.Add(candidate, record);
record.LocalEntityId = candidate;
return candidate;
}
while (_nextLocalEntityId != start);
throw new InvalidOperationException("The live entity id namespace is exhausted.");
} }
if (record.LocalEntityId is { } existing)
return existing;
uint start = _nextLocalEntityId;
do
{
uint candidate = _nextLocalEntityId;
_nextLocalEntityId = candidate == LastLocalEntityId
? FirstLocalEntityId
: candidate + 1u;
if (_byLocalId.ContainsKey(candidate))
continue;
_byLocalId.Add(candidate, record);
record.LocalEntityId = candidate;
return candidate;
}
while (_nextLocalEntityId != start);
throw new InvalidOperationException("The live entity id namespace is exhausted.");
} }
public bool ReleaseLocalId(RuntimeEntityRecord record) public bool ReleaseLocalId(RuntimeEntityRecord record)
{ {
if (record.LocalEntityId is not { } localId) lock (_activeGate)
return false;
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
{ {
_byLocalId.Remove(localId); if (record.LocalEntityId is not { } localId)
} return false;
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
{
_byLocalId.Remove(localId);
}
record.LocalEntityId = null; record.LocalEntityId = null;
return true; return true;
}
} }
public ulong AdvanceLifetimeMutation(uint serverGuid) public ulong AdvanceLifetimeMutation(uint serverGuid)
@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory
public bool CompleteSessionClearIfConverged() public bool CompleteSessionClearIfConverged()
{ {
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) lock (_activeGate)
return false; {
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
return false;
_byLocalId.Clear(); _byLocalId.Clear();
}
ParentAttachments.Clear(); ParentAttachments.Clear();
_inbound.Clear(); _inbound.Clear();
return true; return true;
} }
/// <summary>
/// Enters the exact active-membership read boundary. Add/remove and local-id
/// membership commits serialize behind this allocation-free lease; record
/// ownership remains canonical here and no copied gameplay collection is
/// introduced.
/// </summary>
internal ActiveReadLease AcquireActiveRead() => new(_activeGate);
internal readonly struct ActiveReadLease : IDisposable
{
private readonly object _gate;
internal ActiveReadLease(object gate)
{
_gate = gate;
Monitor.Enter(gate);
}
public void Dispose() => Monitor.Exit(_gate);
}
public void RefreshSnapshot( public void RefreshSnapshot(
RuntimeEntityRecord record, RuntimeEntityRecord record,
WorldSession.EntitySpawn accepted, WorldSession.EntitySpawn accepted,

View file

@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews
uint serverGuid, uint serverGuid,
out RuntimeEntitySnapshot entity) out RuntimeEntitySnapshot entity)
{ {
using RuntimeEntityDirectory.ActiveReadLease lease =
owner.AcquireActiveRead();
if (owner.TryGetActive( if (owner.TryGetActive(
serverGuid, serverGuid,
out RuntimeEntityRecord record)) out RuntimeEntityRecord record))
@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews
public void Visit(IRuntimeEntityVisitor visitor) public void Visit(IRuntimeEntityVisitor visitor)
{ {
ArgumentNullException.ThrowIfNull(visitor); ArgumentNullException.ThrowIfNull(visitor);
using RuntimeEntityDirectory.ActiveReadLease lease =
owner.AcquireActiveRead();
foreach (RuntimeEntityRecord record in owner.ActiveRecords) foreach (RuntimeEntityRecord record in owner.ActiveRecords)
{ {
RuntimeEntitySnapshot entity = Snapshot(record); RuntimeEntitySnapshot entity = Snapshot(record);

View file

@ -15,6 +15,8 @@ public sealed class GraphicalPluginSessionTests
{ {
private const string FixtureId = "acdream.test.host-fixture"; private const string FixtureId = "acdream.test.host-fixture";
private const string ThrowingId = "acdream.test.throwing-fixture"; private const string ThrowingId = "acdream.test.throwing-fixture";
private const string InitializeThrowingId =
"acdream.test.initialize-throwing-fixture";
[Fact] [Fact]
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
@ -114,12 +116,13 @@ public sealed class GraphicalPluginSessionTests
string.Empty); string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl"); string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var events = new WorldEvents(); var events = new WorldEvents();
var selection = new SelectionState();
var ui = new BufferedUiRegistry(); var ui = new BufferedUiRegistry();
var host = new AppPluginHost( var host = new AppPluginHost(
new CapturingLogger(), new CapturingLogger(),
new WorldGameState(), new WorldGameState(),
events, events,
new SelectionState(), selection,
ui); ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create( using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
@ -138,6 +141,65 @@ public sealed class GraphicalPluginSessionTests
2u, 2u,
default, default,
System.Numerics.Quaternion.Identity)); System.Numerics.Quaternion.Identity));
Assert.True(((ISelectionService)selection).Select(7u));
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);
}
[Fact]
public void InitializeFailureRollsBackEveryRegistrationBeforeUnload()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string pluginDirectory = InstallFixture(
paths.PluginsDirectory,
InitializeThrowingId,
"initialize-throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-during-initialize"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var events = new WorldEvents();
var selection = new SelectionState();
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(
new CapturingLogger(),
new WorldGameState(),
events,
selection,
ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
[InitializeThrowingId],
"gui-session",
host,
new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(ui.Drain());
Assert.Equal(0, ui.RegistrationCount);
Assert.Equal(
"ui=True;events=True;selection=True",
File.ReadAllText(Path.Combine(
pluginDirectory,
"unload-observation")));
events.FireEntitySpawned(new WorldEntitySnapshot(
1u,
2u,
default,
System.Numerics.Quaternion.Identity));
Assert.True(((ISelectionService)selection).Select(9u));
Assert.False(File.Exists( Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback"))); Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal( Assert.Equal(

View file

@ -125,5 +125,7 @@ public class PluginLoaderTests
Assert.False(loaded.Success); Assert.False(loaded.Success);
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message); Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
Assert.NotNull(loaded.LoadContext);
loaded.LoadContext!.Unload();
} }
} }

View file

@ -125,6 +125,8 @@ public sealed class HeadlessPluginSessionTests
_ = session.Start(); _ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount); Assert.Equal(0, session.Plugins.LoadedCount);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.True(((ISelectionService)session.Runtime.ActionOwner.Selection)
.Select(7u));
Assert.False(File.Exists( Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback"))); Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal( Assert.Equal(
@ -203,9 +205,25 @@ public sealed class HeadlessPluginSessionTests
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); using var registrationStarted = new ManualResetEventSlim();
releaseReplay.Set(); Task registration = Task.Run(() =>
await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); {
registrationStarted.Set();
_ = session.Runtime.EntityObjects.RegisterEntity(
Spawn(0x50000002u, 2f));
});
Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10)));
try
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
Assert.False(registration.IsCompleted);
}
finally
{
releaseReplay.Set();
}
await Task.WhenAll(subscribe, registration)
.WaitAsync(TimeSpan.FromSeconds(10));
host.Events.EntitySpawned -= handler; host.Events.EntitySpawned -= handler;
Assert.Equal([1_000_000u, 1_000_001u], observed); Assert.Equal([1_000_000u, 1_000_001u], observed);

View file

@ -1,4 +1,5 @@
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
using System.Runtime.Loader;
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
@ -13,6 +14,7 @@ public sealed class HostPlugin : IAcDreamPlugin
private IPluginHost? _host; private IPluginHost? _host;
private string? _assemblyDirectory; private string? _assemblyDirectory;
private bool _throwAfterRegistration; private bool _throwAfterRegistration;
private bool _throwDuringInitialize;
private int _entitiesSeen; private int _entitiesSeen;
public void Initialize(IPluginHost host) public void Initialize(IPluginHost host)
@ -22,17 +24,24 @@ public sealed class HostPlugin : IAcDreamPlugin
typeof(HostPlugin).Assembly.Location); typeof(HostPlugin).Assembly.Location);
_throwAfterRegistration = File.Exists( _throwAfterRegistration = File.Exists(
Path.Combine(_assemblyDirectory!, "throw-after-register")); Path.Combine(_assemblyDirectory!, "throw-after-register"));
_throwDuringInitialize = File.Exists(
Path.Combine(_assemblyDirectory!, "throw-during-initialize"));
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
if (_throwDuringInitialize)
{
RegisterHostCallbacks(host);
AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)!
.Unloading += OnUnloading;
throw new InvalidOperationException(
"fixture initialize failed after registering UI, entity, and selection callbacks");
}
} }
public void Enable() public void Enable()
{ {
IPluginHost host = _host IPluginHost host = _host
?? throw new InvalidOperationException("The fixture was not initialized."); ?? throw new InvalidOperationException("The fixture was not initialized.");
host.Ui.AddMarkupPanel( RegisterHostCallbacks(host);
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
this);
host.Events.EntitySpawned += OnEntitySpawned;
if (_throwAfterRegistration) if (_throwAfterRegistration)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@ -47,24 +56,75 @@ public sealed class HostPlugin : IAcDreamPlugin
IPluginHost? host = _host; IPluginHost? host = _host;
if (host is null) if (host is null)
return; return;
if (_throwAfterRegistration) if (_throwAfterRegistration || _throwDuringInitialize)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"fixture disable intentionally refuses cleanup"); "fixture disable intentionally refuses cleanup");
} }
host.Events.EntitySpawned -= OnEntitySpawned; host.Events.EntitySpawned -= OnEntitySpawned;
host.Selection.Changed -= OnSelectionChanged;
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
_host = null; _host = null;
} }
private void RegisterHostCallbacks(IPluginHost host)
{
host.Ui.AddMarkupPanel(
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
this);
host.Events.EntitySpawned += OnEntitySpawned;
host.Selection.Changed += OnSelectionChanged;
}
private void OnEntitySpawned(WorldEntitySnapshot snapshot) private void OnEntitySpawned(WorldEntitySnapshot snapshot)
{ {
_entitiesSeen++; _entitiesSeen++;
if (_throwAfterRegistration && _assemblyDirectory is not null) RecordUnexpectedCallback(snapshot.Id);
}
private void OnSelectionChanged(SelectionChangedEvent change) =>
RecordUnexpectedCallback(change.SelectedObjectId ?? 0u);
private void RecordUnexpectedCallback(uint objectId)
{
if ((_throwAfterRegistration || _throwDuringInitialize)
&& _assemblyDirectory is not null)
{ {
File.AppendAllText( File.AppendAllText(
Path.Combine(_assemblyDirectory, "unexpected-callback"), Path.Combine(_assemblyDirectory, "unexpected-callback"),
$"{snapshot.Id}{Environment.NewLine}"); $"{objectId}{Environment.NewLine}");
}
}
private void OnUnloading(AssemblyLoadContext context)
{
IPluginHost host = _host!;
bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel(
Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"),
this));
bool eventsClosed = Rejects(() =>
{
host.Events.EntitySpawned += OnEntitySpawned;
});
bool selectionClosed = Rejects(() =>
{
host.Selection.Changed += OnSelectionChanged;
});
File.WriteAllText(
Path.Combine(_assemblyDirectory!, "unload-observation"),
$"ui={uiClosed};events={eventsClosed};selection={selectionClosed}");
}
private static bool Rejects(Action action)
{
try
{
action();
return false;
}
catch (ObjectDisposedException)
{
return true;
} }
} }
} }