diff --git a/src/GameCult.Mesh/CultMeshBodyDemand.cs b/src/GameCult.Mesh/CultMeshBodyDemand.cs index 9c9e17da..2ab90617 100644 --- a/src/GameCult.Mesh/CultMeshBodyDemand.cs +++ b/src/GameCult.Mesh/CultMeshBodyDemand.cs @@ -357,6 +357,26 @@ namespace GameCult.Mesh } } + /// + /// Executes one synchronous capability commit only while the exact routed consumer + /// generation remains current. Demand transitions serialize against the callback, so + /// either this work linearizes before the transition or it is rejected without running. + /// + public bool TryExecuteAtGeneration(string bodyId, long generation, Action action) + { + if (string.IsNullOrWhiteSpace(bodyId)) throw new ArgumentException("Body identity is required.", nameof(bodyId)); + if (generation < 0) throw new ArgumentOutOfRangeException(nameof(generation)); + if (action == null) throw new ArgumentNullException(nameof(action)); + lock (_gate) + { + ThrowIfDisposed(); + var current = _bodyGenerations.TryGetValue(bodyId, out var value) ? value : 0; + if (current != generation) return false; + action(); + return true; + } + } + private string RoutedConsumerSignature(string bodyId) => string.Join( "\u001e", _demands.Values diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs index 0421aea7..131bcf24 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; @@ -347,6 +348,47 @@ public sealed class CultMeshBodyTransportTests tracker.Plan("eve:entity-soa:aetheria.daemon:pilot").Generation.Should().Be(5); } + [Test] + public async Task BodyDemand_GenerationGuardLinearizesCommitAgainstWithdrawal() + { + const string bodyId = "eve:entity-soa:aetheria.daemon:pilot"; + using var tracker = new CultMeshBodyDemandTracker(); + tracker.Observe(Demand("unity-local", "local", sameMachine: true)); + var activeGeneration = tracker.Plan(bodyId).Generation; + using var commitEntered = new ManualResetEventSlim(); + using var releaseCommit = new ManualResetEventSlim(); + var committed = false; + + var commit = Task.Run(() => tracker.TryExecuteAtGeneration( + bodyId, + activeGeneration, + () => + { + commitEntered.Set(); + releaseCommit.Wait(); + committed = true; + })); + commitEntered.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); + + var withdrawal = Task.Run(() => + tracker.Observe(Demand("unity-local", "local", sameMachine: true, active: false))); + await Task.Delay(50); + withdrawal.IsCompleted.Should().BeFalse( + "a withdrawal must serialize after an already-linearized synchronous commit"); + + releaseCommit.Set(); + (await commit).Should().BeTrue(); + await withdrawal; + committed.Should().BeTrue(); + tracker.Plan(bodyId).Generation.Should().BeGreaterThan(activeGeneration); + + var staleCallbackRan = false; + tracker.TryExecuteAtGeneration(bodyId, activeGeneration, () => staleCallbackRan = true) + .Should().BeFalse(); + staleCallbackRan.Should().BeFalse( + "work prepared for a withdrawn demand generation must be discarded before commit"); + } + [Test] public void BodyDemand_UnknownLocalityFailsTowardNetworkAndUnsupportedDemandCreatesNoRoute() {