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

@ -7,17 +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,
/// <see cref="Error"/> describes what went wrong, and
/// <see cref="ReleasedLoadContext"/> weakly observes any collectible context
/// that was already released during rollback.</para>
/// <para>On failure, <see cref="Error"/> describes what went wrong. A partial
/// <see cref="Plugin"/> and/or <see cref="LoadContext"/> may still be present;
/// the caller owns their cleanup. The loader never requests collectible unload
/// itself because the session must first roll back host registrations.</para>
/// </summary>
public sealed record LoadedPlugin(
PluginManifest Manifest,
IAcDreamPlugin? Plugin,
AssemblyLoadContext? LoadContext,
Exception? Error,
WeakReference? ReleasedLoadContext = null)
Exception? Error)
{
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
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
/// 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>
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
{
@ -48,15 +50,12 @@ public static class PluginLoader
if (pluginType is null)
{
var released = new WeakReference(alc);
alc.Unload();
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: null,
LoadContext: alc,
Error: new InvalidOperationException(
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"),
ReleasedLoadContext: released);
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
}
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
@ -65,20 +64,15 @@ public static class PluginLoader
}
catch (Exception ex)
{
// Initialize may have attached host callbacks before it failed.
// Give that partial instance the same best-effort cleanup chance
// 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 { }
// The caller owns rollback for a partial instance/context. In
// particular, Initialize may already have attached host callbacks;
// the per-plugin host scope must remove those registrations before
// Disable or any collectible unload request can run.
return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: null,
Error: ex,
ReleasedLoadContext: released);
Plugin: instance,
LoadContext: alc,
Error: ex);
}
}
}

View file

@ -214,9 +214,11 @@ public sealed class PluginSession : IDisposable
scope);
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();
if (loaded.ReleasedLoadContext is { } released)
_releasedContexts.Add(released);
ReleaseFailedLoad(loaded);
AddError(
errors,
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)
{
if (_report is null)

View file

@ -4,13 +4,14 @@ 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
/// event/selection/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 ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui;
private bool _disposed;
@ -18,6 +19,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(inner.Ui);
}
@ -25,7 +27,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public IPluginLogger Log => _inner.Log;
public IGameState State => _inner.State;
public IEvents Events => _events;
public ISelectionService Selection => _inner.Selection;
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public void Dispose()
@ -34,9 +36,108 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
return;
_disposed = true;
_events.Dispose();
_selection.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 readonly object _gate = new();

View file

@ -52,8 +52,9 @@ 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>
/// <summary>Test-only barrier invoked after the first replay item is
/// captured while Runtime's exact active-membership read lease is still
/// held.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <summary>
@ -88,11 +89,12 @@ internal sealed class HeadlessPluginHost
// 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);
var visitor = new SnapshotVisitor(
_runtime,
ReplayCapturedForTest);
_runtime.Entities.Visit(visitor);
ReplayEntity[] replay = visitor.Items.ToArray();
ReplayCapturedForTest?.Invoke();
foreach (ReplayEntity item in replay)
{
lock (_eventGate)
@ -234,16 +236,23 @@ internal sealed class HeadlessPluginHost
catch { }
}
private sealed class SnapshotVisitor(GameRuntime runtime)
private sealed class SnapshotVisitor(
GameRuntime runtime,
Action? captureBarrier = null)
: IRuntimeEntityVisitor
{
private Action? _captureBarrier = captureBarrier;
internal List<ReplayEntity> Items { get; } =
new(runtime.Entities.Count);
public void Visit(in RuntimeEntitySnapshot entity) =>
public void Visit(in RuntimeEntitySnapshot entity)
{
Items.Add(new ReplayEntity(
entity.Identity,
Convert(runtime, entity)));
Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
}
}
private void RebuildLiveSnapshotLocked()

View file

@ -98,8 +98,10 @@ public sealed class SessionDescriptor
public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Omitted (never an empty array) when the character has no
/// configured plugin set.</summary>
/// <summary>Plugin allow-list. Omitted or JSON <c>null</c> means load all
/// 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; }
/// <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;
private readonly InboundPhysicsStateController _inbound = new();
private readonly object _activeGate = new();
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
_teardownByIncarnation = new();
@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory
_nextLocalEntityId = firstLocalEntityId;
}
public int Count => _activeByGuid.Count;
public int Count
{
get
{
lock (_activeGate)
return _activeByGuid.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; }
/// <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> TeardownRecords =>
_teardownByIncarnation.Values;
@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
{
if (_activeByGuid.ContainsKey(snapshot.Guid))
lock (_activeGate)
{
throw new InvalidOperationException(
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
}
if (_activeByGuid.ContainsKey(snapshot.Guid))
{
throw new InvalidOperationException(
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
}
var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record);
try
{
ClaimLocalId(record);
return record;
}
catch
{
_activeByGuid.Remove(snapshot.Guid);
throw;
var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record);
try
{
ClaimLocalId(record);
return record;
}
catch
{
_activeByGuid.Remove(snapshot.Guid);
throw;
}
}
}
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
_activeByGuid.Remove(guid, out record);
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record)
{
lock (_activeGate)
return _activeByGuid.Remove(guid, out record);
}
public bool RemoveActive(RuntimeEntityRecord expected)
{
if (!IsCurrent(expected))
return false;
return _activeByGuid.Remove(expected.ServerGuid);
lock (_activeGate)
{
if (!_activeByGuid.TryGetValue(
expected.ServerGuid,
out RuntimeEntityRecord? current)
|| !ReferenceEquals(current, expected))
{
return false;
}
return _activeByGuid.Remove(expected.ServerGuid);
}
}
public void RetainTeardown(RuntimeEntityRecord record)
@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory
public uint ClaimLocalId(RuntimeEntityRecord record)
{
if (!IsKnown(record))
lock (_activeGate)
{
throw new InvalidOperationException(
"A local id can only be claimed for an active or retained incarnation.");
if (!IsKnown(record))
{
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)
{
if (record.LocalEntityId is not { } localId)
return false;
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
lock (_activeGate)
{
_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;
return true;
record.LocalEntityId = null;
return true;
}
}
public ulong AdvanceLifetimeMutation(uint serverGuid)
@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory
public bool CompleteSessionClearIfConverged()
{
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
return false;
lock (_activeGate)
{
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
return false;
_byLocalId.Clear();
_byLocalId.Clear();
}
ParentAttachments.Clear();
_inbound.Clear();
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(
RuntimeEntityRecord record,
WorldSession.EntitySpawn accepted,

View file

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