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

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