using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; namespace GameCult.Mesh.Tests; [TestFixture] public sealed class CultMeshFrameBodyTests { [Test] public void CrossProcessPublisherOpensReadOnlyBytesAndProtectsLeasedSlot() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1, 2, 3 }, DateTimeOffset.UtcNow, out var first).Should().BeTrue(); var adapter = new CultMeshSharedMemoryBodyAdapter(); using var lease = adapter.OpenReadOnly(first, Request(first)); var bytes = new byte[3]; lease.CopyTo(0, bytes, 0, bytes.Length).Should().Be(3); bytes.Should().Equal(1, 2, 3); publisher.TryPublish(new byte[] { 4 }, DateTimeOffset.UtcNow, out var second).Should().BeTrue(); publisher.TryPublish(new byte[] { 5 }, DateTimeOffset.UtcNow, out var third).Should().BeTrue(); publisher.TryPublish(new byte[] { 6 }, DateTimeOffset.UtcNow, out var fourth).Should().BeTrue(); fourth.CapabilityToken.Should().NotBe(first.CapabilityToken); lease.ReadByte(0).Should().Be(1); } [Test] public void PublisherTelemetryDistinguishesDirectWritesFromCopyFallback() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1, 2, 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); publisher.TryAcquireWrite(out var direct).Should().BeTrue(); direct.Span[..3].Fill(7); direct.Commit(3, DateTimeOffset.UtcNow); var stats = publisher.Stats(); stats.PublishedFrames.Should().Be(2); stats.BlockedWrites.Should().Be(0); stats.UnavoidableCopyCount.Should().Be(1); } [Test] public void MappedCursorObservesNewGenerationsWithoutNewControlDescriptors() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1, 2, 3 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); cursor.TryAcquireLatest(out var first).Should().BeTrue(); using (first) { first.Descriptor.Sequence.Should().Be(bootstrap.Sequence); first.ReadByte(0).Should().Be(1); } cursor.TryAcquireLatest(out _).Should().BeFalse(); publisher.TryPublish(new byte[] { 9, 8, 7 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); cursor.TryAcquireLatest(out var second).Should().BeTrue(); using (second) { second.Descriptor.Sequence.Should().Be(bootstrap.Sequence + 1); second.Descriptor.ByteSize.Should().Be(3); second.ReadByte(0).Should().Be(9); } cursor.TryAcquireLatest(out _).Should().BeFalse(); } [Test] public void MappedCursorWaitsForNewControlContractWhenFrameLengthChanges() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); cursor.TryAcquireLatest(out var first).Should().BeTrue(); first.Dispose(); publisher.TryPublish(new byte[] { 2, 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); cursor.TryAcquireLatest(out _).Should().BeFalse(); } [Test] public void MappedCursorReaderLeaseProtectsItsSlotUntilDisposed() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); cursor.TryAcquireLatest(out var protectedRead).Should().BeTrue(); publisher.TryPublish(new byte[] { 2 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); publisher.TryPublish(new byte[] { 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); publisher.TryPublish(new byte[] { 4 }, DateTimeOffset.UtcNow, out var fourth).Should().BeTrue(); fourth.CapabilityToken.Should().NotBe(bootstrap.CapabilityToken); protectedRead.ReadByte(0).Should().Be(1); protectedRead.Dispose(); } [Test] public void MappedCursorIgnoresExpiredGenerations() { var publishedAt = DateTimeOffset.UtcNow; using var publisher = new CultMeshFrameBodyPublisher( "body", "tests.frame.v2", layoutVersion: 2, capacity: 64, producerEpoch: 9, slotByteLength: 64, leaseDuration: TimeSpan.FromMilliseconds(10)); publisher.TryPublish(new byte[] { 42 }, publishedAt, out var bootstrap).Should().BeTrue(); using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); cursor.TryAcquireLatest(publishedAt.AddMilliseconds(11), out _).Should().BeFalse(); } [TestCase("wrong", 2, 9)] [TestCase("tests.frame.v2", 1, 9)] [TestCase("tests.frame.v2", 2, 8)] public void CrossProcessOpenRejectsStaleSchemaLayoutOrEpoch(string schema, int layout, long epoch) { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 42 }, DateTimeOffset.UtcNow, out var descriptor).Should().BeTrue(); var request = Request(descriptor); request.SchemaId = schema; request.LayoutVersion = layout; request.ProducerEpoch = epoch; Action open = () => new CultMeshSharedMemoryBodyAdapter().OpenReadOnly(descriptor, request); open.Should().Throw(); } [Test] public void ReusedSlotRejectsItsStaleGenerationDescriptor() { using var publisher = Publisher(); publisher.TryPublish(new byte[] { 1 }, DateTimeOffset.UtcNow, out var stale).Should().BeTrue(); publisher.TryPublish(new byte[] { 2 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); publisher.TryPublish(new byte[] { 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); publisher.TryPublish(new byte[] { 4 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); Action open = () => new CultMeshSharedMemoryBodyAdapter().OpenReadOnly(stale, Request(stale)); open.Should().Throw().WithMessage("*stale*"); } [Test] public void CleanupRevokesNewOpensAndDisposedLeaseIsInvalid() { var publisher = Publisher(); publisher.TryPublish(new byte[] { 42 }, DateTimeOffset.UtcNow, out var descriptor).Should().BeTrue(); var adapter = new CultMeshSharedMemoryBodyAdapter(); var lease = adapter.OpenReadOnly(descriptor, Request(descriptor)); lease.Dispose(); Action readDisposed = () => lease.ReadByte(0); readDisposed.Should().Throw(); publisher.Dispose(); Action reopen = () => adapter.OpenReadOnly(descriptor, Request(descriptor)); reopen.Should().Throw(); } [Test] public void UnixPublisherOwnsAndDeletesItsFileBackedMap() { if (OperatingSystem.IsWindows()) Assert.Ignore("Windows uses a named memory map rather than a backing file."); var publisher = Publisher(); publisher.TryPublish(new byte[] { 42 }, DateTimeOffset.UtcNow, out var descriptor).Should().BeTrue(); var token = descriptor.CapabilityToken[..descriptor.CapabilityToken.LastIndexOf('.')]; var path = Path.Combine(Path.GetTempPath(), "cultmesh-map-" + token + ".map"); File.Exists(path).Should().BeTrue(); publisher.Dispose(); File.Exists(path).Should().BeFalse(); } [Test] public void PublishesOneAtomicSchemaVersionedGenerationPerCommit() { using var region = Region(); region.TryAcquireWrite(out var write).Should().BeTrue(); write.Sequence.Should().Be(0); write.Span[..4].Fill(7); var generation = region.Commit( write, 4, timestampNs: 12, metadata: new Dictionary { ["plane"] = "positions" }); generation.Descriptor.Sequence.Should().Be(0); generation.Descriptor.SchemaId.Should().Be("tests.frame.v2"); generation.Descriptor.LayoutVersion.Should().Be(2); generation.Descriptor.Capacity.Should().Be(64); generation.Descriptor.ProducerEpoch.Should().Be(9); generation.Descriptor.Synchronization.Should().Be(CultMeshBodySynchronization.TripleBuffer); generation.Descriptor.AccessMode.Should().Be(CultMeshBodyAccessMode.ReadOnly); region.Stats().PublishedFrames.Should().Be(1); Action duplicate = () => region.Commit(write, 4, timestampNs: 13); duplicate.Should().Throw().WithMessage("*stale or was already committed*"); region.Stats().PublishedFrames.Should().Be(1); } [Test] public void RefusesASecondOutstandingWriteAndRecoversAfterCommit() { using var region = Region(); region.TryAcquireWrite(out var first).Should().BeTrue(); first.Sequence.Should().Be(0); region.TryAcquireWrite(out _).Should().BeFalse(); first.Span[0] = 10; region.Commit(first, 1, timestampNs: 1).Descriptor.Sequence.Should().Be(0); region.TryAcquireWrite(out var second).Should().BeTrue(); second.Sequence.Should().Be(1); second.Span[0] = 11; region.Commit(second, 1, timestampNs: 2).Descriptor.Sequence.Should().Be(1); region.TryAcquireWrite(out var third).Should().BeTrue(); third.Sequence.Should().Be(2); region.Stats().BlockedWrites.Should().Be(1); } [Test] public void RefusesWritesWhenEverySlotHasAReaderLease() { using var region = Region(); var reads = new CultMeshFrameRegionReadLease[3]; for (var sequence = 0; sequence < 3; sequence++) { region.TryAcquireWrite(out var write).Should().BeTrue(); write.Span[0] = (byte)sequence; region.Commit(write, 1, sequence); region.TryAcquireLatestRead(Request(sequence), out reads[sequence]).Should().BeTrue(); } region.TryAcquireWrite(out _).Should().BeFalse(); region.Stats().BlockedWrites.Should().Be(1); reads[1].Dispose(); region.TryAcquireWrite(out var available).Should().BeTrue(); available.SlotIndex.Should().Be(1); reads[0].Dispose(); reads[2].Dispose(); } [TestCase("wrong", "tests.frame.v2", 2, 9, 64, 0)] [TestCase("body", "tests.frame.v1", 2, 9, 64, 0)] [TestCase("body", "tests.frame.v2", 1, 9, 64, 0)] [TestCase("body", "tests.frame.v2", 2, 8, 64, 0)] [TestCase("body", "tests.frame.v2", 2, 9, 63, 0)] [TestCase("body", "tests.frame.v2", 2, 9, 64, 1)] public void RejectsMismatchedGenerationBeforeExposingBytes( string bodyId, string schemaId, int layoutVersion, long epoch, int capacity, long sequence) { using var region = Region(); region.TryAcquireWrite(out var write).Should().BeTrue(); write.Span[0] = 42; region.Commit(write, 1, 0); var request = new CultMeshBodyValidationRequest { BodyId = bodyId, SchemaId = schemaId, LayoutVersion = layoutVersion, ProducerEpoch = epoch, Capacity = capacity, Sequence = sequence, AccessMode = CultMeshBodyAccessMode.ReadOnly }; Action read = () => region.TryAcquireLatestRead(request, out _); read.Should().Throw(); } [Test] public void ReadLeaseIsReadOnlyAndProtectsItsBytesAcrossLaterCommits() { using var region = Region(); region.TryAcquireWrite(out var first).Should().BeTrue(); first.Span[..4].Fill(11); region.Commit(first, 4, 0); region.TryAcquireLatestRead(Request(0), out var protectedRead).Should().BeTrue(); for (var value = 12; value <= 13; value++) { region.TryAcquireWrite(out var write).Should().BeTrue(); write.Span[..4].Fill((byte)value); region.Commit(write, 4, value); } protectedRead.Span.ToArray().Should().OnlyContain(value => value == 11); protectedRead.Dispose(); } [Test] public async Task ConcurrentReadersNeverObserveTornBytesOrMetadata() { using var region = Region(); var failures = new ConcurrentQueue(); for (var sequence = 0; sequence < 200; sequence++) { CultMeshFrameRegionWriteLease write; while (!region.TryAcquireWrite(out write)) await Task.Yield(); var marker = (byte)(sequence % 251 + 1); write.Span[..64].Fill(marker); region.Commit( write, 64, sequence, metadata: new Dictionary { ["marker"] = marker.ToString() }); var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() => { if (!region.TryAcquireLatestRead(Request(), out var read)) return; using (read) { var expected = byte.Parse(read.Generation.Metadata["marker"]); if (read.Span.ToArray().Any(value => value != expected)) failures.Enqueue($"Torn generation {read.Generation.Descriptor.Sequence}"); } })); await Task.WhenAll(readers); } failures.Should().BeEmpty(); region.Stats().PublishedFrames.Should().Be(200); region.Stats().LatestSequence.Should().Be(199); } private static CultMeshFrameRegion Region() => new("body", "tests.frame.v2", layoutVersion: 2, capacity: 64, producerEpoch: 9, slotByteLength: 64); private static CultMeshFrameBodyPublisher Publisher() => new("body", "tests.frame.v2", layoutVersion: 2, capacity: 64, producerEpoch: 9, slotByteLength: 64); private static CultMeshBodyValidationRequest Request(CultMeshBodyDescriptor descriptor) => new() { BodyId = descriptor.BodyId, SchemaId = descriptor.SchemaId, LayoutVersion = descriptor.LayoutVersion, ProducerEpoch = descriptor.ProducerEpoch, Capacity = descriptor.Capacity, Sequence = descriptor.Sequence, AccessMode = CultMeshBodyAccessMode.ReadOnly }; private static CultMeshBodyValidationRequest Request(long? sequence = null) => new() { BodyId = "body", SchemaId = "tests.frame.v2", LayoutVersion = 2, ProducerEpoch = 9, Capacity = 64, Sequence = sequence, AccessMode = CultMeshBodyAccessMode.ReadOnly }; }