harden(runtime): close canonical entity object lifetime

Retain exact teardown receipts before terminal callbacks, preserve ordered re-entrant entity/object publication, publish committed facts across projection failures, and make direct disposal converge every canonical owner. Add a complete ownership ledger plus adversarial direct/graphical parity, callback, GUID reuse, reset, and resource-churn gates.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 07:13:42 +02:00
parent 84954c8b77
commit 119b7c1151
9 changed files with 992 additions and 95 deletions

View file

@ -393,6 +393,124 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Equal(1, lifetime.Events.SubscriberCount);
}
[Fact]
public void ReentrantCrossDomainCommit_PreservesSequenceForEveryObserver()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(6), static () => 12UL);
bool nested = false;
var mutating = new CallbackObserver(
onEntity: _ =>
{
if (nested)
return;
nested = true;
lifetime.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x71000001u,
Name = "nested",
});
});
var recording = new RecordingObserver();
using IDisposable first = lifetime.Events.Subscribe(mutating);
using IDisposable second = lifetime.Events.Subscribe(recording);
_ = lifetime.RegisterEntity(
Spawn(0x7000001Au, 1, "outer"));
Assert.Equal(
[
(RuntimeTraceKind.Entity, 1UL),
(RuntimeTraceKind.Inventory, 2UL),
],
recording.Order);
Assert.Equal(0, lifetime.Events.PendingDispatchCount);
Assert.False(lifetime.Events.IsDispatching);
}
[Fact]
public void ObjectTransactions_PublishCommittedBorrowedViewInExactOrder()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(7), static () => 13UL);
const uint itemId = 0x71000002u;
var observedViews =
new List<RuntimeInventoryItemSnapshot>();
var observer = new CallbackObserver(
onInventory: delta =>
{
if (delta.Change
is RuntimeInventoryChange.Removed
or RuntimeInventoryChange.Cleared)
{
return;
}
Assert.True(lifetime.InventoryView.TryGet(
delta.Item.ObjectId,
out RuntimeInventoryItemSnapshot current));
observedViews.Add(current);
});
var recording = new RecordingObserver();
using IDisposable first = lifetime.Events.Subscribe(observer);
using IDisposable second = lifetime.Events.Subscribe(recording);
lifetime.Objects.AddOrUpdate(new ClientObject
{
ObjectId = itemId,
Name = "transaction",
ContainerId = 0x50000001u,
ContainerSlot = 2,
StackSize = 3,
Value = 30,
});
Assert.True(lifetime.Objects.MoveItemOptimistic(
itemId,
0x50000002u,
newSlot: 0));
Assert.True(lifetime.Objects.RollbackMove(itemId));
Assert.True(lifetime.Objects.UpdateStackSize(
itemId,
stackSize: 2,
value: 20));
Assert.True(lifetime.Objects.RemoveLogicalGeneration(
itemId,
generation: 9));
lifetime.Objects.AddOrUpdate(new ClientObject
{
ObjectId = itemId,
Name = "replacement",
});
lifetime.ClearObjects();
Assert.Equal(
[
RuntimeInventoryChange.Added,
RuntimeInventoryChange.Moved,
RuntimeInventoryChange.Moved,
RuntimeInventoryChange.Updated,
RuntimeInventoryChange.Removed,
RuntimeInventoryChange.Added,
RuntimeInventoryChange.Cleared,
],
recording.Inventory
.Select(delta => delta.Change)
.ToArray());
Assert.Equal(
Enumerable.Range(1, 7).Select(value => (ulong)value),
recording.Inventory.Select(delta => delta.Stamp.Sequence));
Assert.Equal(
(ushort)9,
recording.Inventory[4].Item.Incarnation);
Assert.Equal(5, observedViews.Count);
Assert.Equal(0x50000002u, observedViews[1].ContainerId);
Assert.Equal(0x50000001u, observedViews[2].ContainerId);
Assert.Equal(2, observedViews[3].StackSize);
Assert.Equal("replacement", observedViews[4].Name);
Assert.Equal(0, lifetime.InventoryView.ObjectCount);
Assert.Equal(0, lifetime.Events.PendingDispatchCount);
}
[Fact]
public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval()
{
@ -530,6 +648,97 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Equal(0, lifetime.Entities.PendingTeardownCount);
}
[Fact]
public void GenerationReplacement_RetainsExactReceiptBeforeTerminalCallback()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000030u;
RuntimeEntityRecord prior =
lifetime.RegisterEntity(Spawn(guid, 1, "prior")).Canonical!;
uint priorLocalId = prior.LocalEntityId!.Value;
bool observedRetainedReceipt = false;
var observer = new CallbackObserver(
onEntity: delta =>
{
if (delta.Change is not RuntimeEntityChange.Deleted)
return;
observedRetainedReceipt = lifetime.Entities.TryGetTeardown(
guid,
1,
out RuntimeEntityRecord retained)
&& ReferenceEquals(prior, retained)
&& lifetime.Entities.TryGetByLocalId(
priorLocalId,
out RuntimeEntityRecord identity)
&& ReferenceEquals(prior, identity);
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRegistrationResult replacement =
lifetime.RegisterEntity(Spawn(guid, 2, "replacement"));
Assert.True(observedRetainedReceipt);
Assert.Equal((ushort)2, replacement.Canonical!.Incarnation);
Assert.Equal(0, lifetime.Entities.PendingTeardownCount);
Assert.Equal(1, lifetime.Entities.ClaimedLocalIdCount);
Assert.False(lifetime.Entities.TryGetByLocalId(
priorLocalId,
out _));
}
[Fact]
public void DeleteAndSessionClear_ExposeExactReceiptsUntilAcknowledged()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint deletedGuid = 0x70000031u;
const uint clearedGuid = 0x70000032u;
RuntimeEntityRecord deleted = lifetime.RegisterEntity(
Spawn(deletedGuid, 1, "deleted")).Canonical!;
RuntimeEntityRecord cleared = lifetime.RegisterEntity(
Spawn(clearedGuid, 1, "cleared")).Canonical!;
var retainedDuringCallbacks = new HashSet<uint>();
var observer = new CallbackObserver(
onEntity: delta =>
{
if (delta.Change
is RuntimeEntityChange.Deleted
or RuntimeEntityChange.Withdrawn
&& lifetime.Entities.TryGetTeardown(
delta.Entity.Identity.ServerGuid,
delta.Entity.Identity.Incarnation,
out _))
{
retainedDuringCallbacks.Add(
delta.Entity.Identity.ServerGuid);
}
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(deletedGuid, 1),
isLocalPlayer: false,
removeRetainedObject: false,
out RuntimeEntityDeleteAcceptance acceptance));
Assert.Contains(deletedGuid, retainedDuringCallbacks);
Assert.Equal(1, lifetime.Entities.PendingTeardownCount);
lifetime.CompleteAcceptedDelete(acceptance);
Assert.Null(lifetime.RetireCanonicalOnly(deleted));
IReadOnlyList<RuntimeEntityRecord> retired =
lifetime.BeginSessionClear();
Assert.Same(cleared, Assert.Single(retired));
Assert.Contains(clearedGuid, retainedDuringCallbacks);
Assert.Equal(1, lifetime.Entities.PendingTeardownCount);
Assert.Null(lifetime.RetireCanonicalOnly(cleared));
Assert.True(lifetime.CompleteSessionClearIfConverged());
Assert.Equal(
default(RuntimeEntityObjectOwnershipSnapshot) with
{
StreamSubscriberCount = 1,
},
lifetime.CaptureOwnership());
}
[Fact]
public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate()
{
@ -685,6 +894,274 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.False(lifetime.Entities.TryGetActive(guid, out _));
}
[Fact]
public void AcceptedVector_ProjectionFailureStillPublishesCommittedFact()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(21), static () => 90UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
const uint guid = 0x70000024u;
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(Spawn(guid, 1, "vector")).Canonical!;
var velocity = new System.Numerics.Vector3(3f, 4f, 5f);
Assert.Throws<InvalidOperationException>(() =>
lifetime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
velocity,
System.Numerics.Vector3.Zero,
InstanceSequence: 1,
VectorSequence: 2),
_ => throw new InvalidOperationException(
"projection failed after canonical commit"),
out _));
Assert.True(lifetime.Entities.IsCurrent(canonical));
Assert.Equal(velocity, canonical.Snapshot.Physics!.Value.Velocity);
Assert.Equal(
[
RuntimeEntityChange.Registered,
RuntimeEntityChange.Updated,
],
observer.Entities.Select(delta => delta.Change).ToArray());
Assert.Equal([1UL, 2UL], observer.Stamps
.Select(stamp => stamp.Sequence)
.ToArray());
}
[Fact]
public void ReentrantNewerVector_SupersedesOuterCommitPublication()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(22), static () => 91UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
const uint guid = 0x70000025u;
_ = lifetime.RegisterEntity(Spawn(guid, 1, "vector"));
var newest = new System.Numerics.Vector3(9f, 8f, 7f);
bool outerRemainedCurrent = lifetime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
new System.Numerics.Vector3(1f, 2f, 3f),
System.Numerics.Vector3.Zero,
InstanceSequence: 1,
VectorSequence: 2),
ignoredCanonical => Assert.True(lifetime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
newest,
System.Numerics.Vector3.Zero,
InstanceSequence: 1,
VectorSequence: 3),
acknowledgeProjection: null,
out _)),
out _);
Assert.False(outerRemainedCurrent);
Assert.True(lifetime.Entities.TryGetActive(
guid,
out RuntimeEntityRecord current));
Assert.Equal(newest, current.Snapshot.Physics!.Value.Velocity);
Assert.Equal(
[
RuntimeEntityChange.Registered,
RuntimeEntityChange.Updated,
],
observer.Entities.Select(delta => delta.Change).ToArray());
Assert.Equal(2UL, lifetime.Events.LastSequence);
}
[Fact]
public void ReentrantTimestampOnlyMotion_SupersedesOuterPublication()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(26), static () => 95UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
const uint guid = 0x7000002Bu;
_ = lifetime.RegisterEntity(Spawn(guid, 1, "motion"));
var newestMotion =
new CreateObject.ServerMotionState(0x3D, 0x12);
bool outerRemainedCurrent = lifetime.TryApplyMotion(
new WorldSession.EntityMotionUpdate(
guid,
new CreateObject.ServerMotionState(0x3D, 0x11),
InstanceSequence: 1,
MovementSequence: 2,
ServerControlSequence: 1,
IsAutonomous: true),
retainPayload: false,
ignoredCanonical => Assert.True(lifetime.TryApplyMotion(
new WorldSession.EntityMotionUpdate(
guid,
newestMotion,
InstanceSequence: 1,
MovementSequence: 3,
ServerControlSequence: 1,
IsAutonomous: true),
retainPayload: false,
acknowledgeProjection: null,
out _,
out _)),
out _,
out _);
Assert.False(outerRemainedCurrent);
Assert.True(lifetime.Entities.TryGetActive(
guid,
out RuntimeEntityRecord current));
Assert.NotEqual(
newestMotion,
current.Snapshot.MotionState);
Assert.Equal(
[
RuntimeEntityChange.Registered,
RuntimeEntityChange.Updated,
],
observer.Entities.Select(delta => delta.Change).ToArray());
Assert.Equal(2UL, lifetime.Events.LastSequence);
}
[Fact]
public void Rebucket_ProjectionFailureStillPublishesCommittedCell()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(23), static () => 92UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRecord canonical = lifetime.RegisterEntity(
Spawn(0x70000026u, 1, "rebucket")).Canonical!;
Assert.Throws<InvalidOperationException>(() =>
lifetime.CommitRebucket(
canonical,
0x02020022u,
0x0202FFFFu,
_ => throw new InvalidOperationException(
"projection failed after spatial commit")));
Assert.Equal(0x02020022u, canonical.FullCellId);
RuntimeEntityDelta committed = observer.Entities[^1];
Assert.Equal(RuntimeEntityChange.Rebucketed, committed.Change);
Assert.Equal(0x02020022u, committed.Entity.CellId);
Assert.Equal(2UL, committed.Stamp.Sequence);
}
[Fact]
public void ReentrantChildStateMutation_SupersedesOuterPublication()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(24), static () => 93UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRecord canonical = lifetime.RegisterEntity(
Spawn(0x70000027u, 1, "child")).Canonical!;
bool outerRemainedCurrent = lifetime.CommitChildNoDraw(
canonical,
noDraw: true,
_ => Assert.True(lifetime.CommitChildNoDraw(
canonical,
noDraw: false)));
Assert.False(outerRemainedCurrent);
Assert.Equal(
PhysicsStateFlags.None,
canonical.FinalPhysicsState & PhysicsStateFlags.NoDraw);
Assert.Equal(
[
RuntimeEntityChange.Registered,
RuntimeEntityChange.Updated,
],
observer.Entities.Select(delta => delta.Change).ToArray());
Assert.Equal(2UL, lifetime.Events.LastSequence);
}
[Fact]
public void RejectedUpdate_ConsumesNoSharedSequence()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(25), static () => 94UL);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
const uint guid = 0x70000028u;
_ = lifetime.RegisterEntity(Spawn(guid, 1, "stale"));
Assert.False(lifetime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
new System.Numerics.Vector3(1f, 0f, 0f),
System.Numerics.Vector3.Zero,
InstanceSequence: 1,
VectorSequence: 1),
acknowledgeProjection: null,
out _));
Assert.Single(observer.Entities);
Assert.Equal(1UL, lifetime.Events.LastSequence);
}
[Fact]
public void DisposeWithLiveDirectState_ConvergesCompleteOwnershipLedger()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000029u;
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(Spawn(guid, 1, "dispose")).Canonical!;
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
canonical.Snapshot,
replaceGeneration: false));
_ = lifetime.Events.Subscribe(new RecordingObserver());
lifetime.Dispose();
lifetime.Dispose();
Assert.Equal(
default(RuntimeEntityObjectOwnershipSnapshot),
lifetime.CaptureOwnership());
}
[Fact]
public void DisposeAfterAcceptedDelete_ConvergesUnfinishedReceiptAndRelations()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x7000002Au;
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(Spawn(guid, 1, "pending")).Canonical!;
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
canonical.Snapshot,
replaceGeneration: false));
lifetime.Entities.ParentAttachments.Enqueue(
new ParentEvent.Parsed(
ParentGuid: 0x7000F001u,
ChildGuid: guid,
ParentLocation: 1,
PlacementId: 2,
ParentInstanceSequence: 4,
ChildPositionSequence: 3));
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(guid, 1),
isLocalPlayer: false,
removeRetainedObject: true,
out _));
Assert.Equal(1, lifetime.Entities.PendingTeardownCount);
Assert.Equal(1, lifetime.Objects.ObjectCount);
lifetime.Dispose();
Assert.Equal(
default(RuntimeEntityObjectOwnershipSnapshot),
lifetime.CaptureOwnership());
}
private static RuntimeEntityRecord Accept(
RuntimeEntityObjectLifetime lifetime,
WorldSession.EntitySpawn spawn)