CultLib/tests/GameCult.Networking.Tests/NetworkingTests.cs

7594 lines
352 KiB
C#
Raw Normal View History

#nullable enable
using System;
using System.Collections.Concurrent;
2026-05-19 10:44:00 +01:00
using System.Collections.Generic;
2026-05-09 13:32:30 +01:00
using System.IO;
using System.Linq;
2026-06-14 23:32:24 +01:00
using System.Net;
using System.Net.Sockets;
using System.Reflection;
2026-04-18 06:04:39 +01:00
using System.Text;
2026-06-17 05:16:46 +01:00
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
2026-05-19 19:08:48 +01:00
using GameCult.Mesh;
2026-04-18 06:04:39 +01:00
using NUnit.Framework;
2026-05-19 10:44:00 +01:00
using R3;
2026-04-18 06:04:39 +01:00
namespace GameCult.Networking.Tests
{
public class NetworkingTests
{
private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development();
private static readonly ClientSecurityOptions DevelopmentClientSecurity = ClientSecurityOptions.Development();
2026-06-14 23:32:24 +01:00
private static Socket BindUdpSocket()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
socket.ReceiveTimeout = 20;
return socket;
}
private static void PumpRudpHandshake(
CultNetRudpSocketTransportConnection client,
CultNetRudpSocketTransportConnection server)
{
for (var attempt = 0; attempt < 20; attempt++)
{
server.ReceiveOnce();
client.ReceiveOnce();
server.ReceiveOnce();
if (client.Connected && server.Connected)
{
return;
}
Task.Delay(5).GetAwaiter().GetResult();
}
Assert.Fail("RUDP socket handshake did not complete.");
}
private static CultNetTransportFrame ReceiveRudpFrame(CultNetRudpSocketTransportConnection transport)
{
for (var attempt = 0; attempt < 20; attempt++)
{
var frame = transport.ReceiveOnce();
if (frame != null)
{
return frame;
}
Task.Delay(5).GetAwaiter().GetResult();
}
Assert.Fail("RUDP socket frame was not delivered.");
throw new InvalidOperationException("Unreachable.");
}
2026-06-15 23:38:42 +01:00
private static TMessage ReceiveRudpSchemaMessage<TMessage>(CultNetRudpSocketTransportConnection transport)
where TMessage : class, ICultNetSchemaMessage
{
for (var attempt = 0; attempt < 20; attempt++)
{
var message = transport.ReceiveSchemaMessageOnce<TMessage>();
if (message != null)
{
return message;
}
Task.Delay(5).GetAwaiter().GetResult();
}
Assert.Fail($"RUDP schema message {typeof(TMessage).Name} was not delivered.");
throw new InvalidOperationException("Unreachable.");
}
private static async Task WaitUntilAsync(Func<bool> predicate, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (predicate())
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(5));
}
Assert.Fail("Condition was not satisfied before timeout.");
}
private static async Task<T> AwaitWithTimeout<T>(Task<T> task, TimeSpan timeout)
{
var completed = await Task.WhenAny(task, Task.Delay(timeout));
if (completed != task)
{
Assert.Fail("Task did not complete before timeout.");
}
return await task;
}
private static async Task AwaitWithTimeout(Task task, TimeSpan timeout)
{
var completed = await Task.WhenAny(task, Task.Delay(timeout));
if (completed != task)
{
Assert.Fail("Task did not complete before timeout.");
}
await task;
}
2026-04-18 06:04:39 +01:00
[Test]
public void EncryptDecrypt_Roundtrip()
{
var plaintext = Encoding.UTF8.GetBytes("test");
var nonce = Secret.NewNonce;
var encrypted = Secret.EncryptBytes(plaintext, nonce, DevelopmentClientSecurity);
var decrypted = Secret.DecryptBytes(encrypted, nonce, DevelopmentClientSecurity);
2026-04-18 06:04:39 +01:00
Assert.That(plaintext, Is.EqualTo(decrypted));
}
[Test]
public void SessionToken_Validates_AndRejectsTampering()
{
var userId = Guid.NewGuid();
var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), DevelopmentServerSecurity);
Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out var expiresAt), Is.True);
Assert.That(parsedUserId, Is.EqualTo(userId));
Assert.That(expiresAt, Is.GreaterThan(DateTimeOffset.UtcNow));
var tamperedToken = $"{token}tampered";
Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False);
}
[Test]
public void SessionToken_Validates_SessionVersion()
{
var userId = Guid.NewGuid();
var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), 42, DevelopmentServerSecurity);
Assert.That(
Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out _, out var sessionVersion),
Is.True);
Assert.That(parsedUserId, Is.EqualTo(userId));
Assert.That(sessionVersion, Is.EqualTo(42));
}
[Test]
public void SessionToken_RejectsExpiredTokens()
{
var token = Secret.CreateSessionToken(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(-1), DevelopmentServerSecurity);
Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out _, out _), Is.False);
}
[Test]
public void SecurityOptions_FromEnvironment_Rejects_MissingSecrets()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
Assert.That(
() => ServerSecurityOptions.FromEnvironment(),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("Server security configuration is not configured"));
}
[Test]
public void SecurityOptions_FromEnvironment_Rejects_PartialConfiguration()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, "connection-key"),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
Assert.That(
() => ServerSecurityOptions.FromEnvironment(),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable));
}
[Test]
public void ServerSecurityOptions_FromEnvironment_Can_OptInto_DevelopmentDefaults()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
var options = ServerSecurityOptions.FromEnvironment(allowDevelopmentDefaults: true);
Assert.That(options.IsDevelopment, Is.True);
Assert.That(options.ConnectionKey, Is.Not.Empty);
}
[Test]
public void MessageSerialization_RoundTrips_KnownMessageUnion()
{
var client = new Client(DevelopmentClientSecurity);
var message = new LoginMessage
{
Nonce = Secret.NewNonce,
Auth = Encoding.UTF8.GetBytes("auth"),
Password = Encoding.UTF8.GetBytes("password")
};
var serializationType = client.GetType().Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var payload = (byte[])serialize.Invoke(null, [message])!;
var roundTrip = (LoginMessage)deserialize.Invoke(null, [payload])!;
Assert.That(roundTrip.Auth, Is.EqualTo(message.Auth));
Assert.That(roundTrip.Password, Is.EqualTo(message.Password));
Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce));
}
[Test]
public void MessageSerialization_RoundTrips_SchemaCatalogMessages()
{
var client = new Client(DevelopmentClientSecurity);
var message = new SchemaCatalogResponseMessage
{
MessageId = "catalog-1",
Schemas =
[
new SchemaDescriptorMessage
{
SchemaId = "https://example.test/contracts/example.schema.json",
Kind = "shared_contract",
SchemaVersion = "example.contract.v0",
Title = "Example Contract",
WireContracts = ["cultnet.schema.v0", "gamecult.networking.v0"],
ContentHash = "deadbeef",
SchemaJson = "{\"type\":\"object\"}"
}
]
};
var serializationType = client.GetType().Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var payload = (byte[])serialize.Invoke(null, [message])!;
var roundTrip = (SchemaCatalogResponseMessage)deserialize.Invoke(null, [payload])!;
Assert.That(roundTrip.MessageId, Is.EqualTo("catalog-1"));
Assert.That(roundTrip.Schemas, Has.Length.EqualTo(1));
Assert.That(roundTrip.Schemas[0].SchemaId, Is.EqualTo("https://example.test/contracts/example.schema.json"));
Assert.That(roundTrip.Schemas[0].WireContracts, Is.EqualTo(["cultnet.schema.v0", "gamecult.networking.v0"]));
Assert.That(roundTrip.Schemas[0].SchemaJson, Is.EqualTo("{\"type\":\"object\"}"));
}
[Test]
public void MessageSerialization_Rejects_InvalidPayload()
{
var client = new Client(DevelopmentClientSecurity);
var serializationType = client.GetType().Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
Assert.That(
() => deserialize.Invoke(null, [new byte[] { 0xC1 }]),
Throws.TypeOf<TargetInvocationException>()
.With.InnerException.InstanceOf<MessagePack.MessagePackSerializationException>());
}
2026-06-14 21:20:52 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_HelloTransportProfile()
{
var message = new CultNetHelloMessage
{
RuntimeId = "csharp-test",
RuntimeKind = "dotnet",
TransportProfiles =
[
new CultNetTransportProfile
{
RuntimeId = "csharp-test",
Transports =
[
new CultNetTransportDescriptor
{
TransportId = "test-pipe",
Protocol = "tcp_framed",
WireContracts = [CultNetWireContracts.SchemaV0],
Channels =
[
new CultNetTransportChannel
{
ChannelId = "schema",
Delivery = "reliable",
Ordering = "ordered"
}
]
}
]
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetHelloMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.TransportProfiles, Is.Not.Null);
Assert.That(roundTrip.TransportProfiles![0].Transports[0].Protocol, Is.EqualTo("tcp_framed"));
Assert.That(roundTrip.TransportProfiles[0].Transports[0].Channels[0].Ordering, Is.EqualTo("ordered"));
}
2026-06-14 21:59:09 +01:00
[Test]
public async Task TcpFramedTransportConnection_CarriesSchemaPayloadsWithStats()
{
var payload = Encoding.UTF8.GetBytes("cultnet-payload");
var profile = CultNetTransportProfiles.CreateTcpFramed(
"csharp-transport",
new TcpFramedTransportProfileOptions
{
TransportId = "test-tcp"
});
using var stream = new MemoryStream();
var sender = new TcpFramedTransportConnection(stream, profile);
await sender.SendAsync("schema", payload);
Assert.That(sender.Stats.FramesSent, Is.EqualTo(1));
Assert.That(sender.Stats.BytesSent, Is.EqualTo(payload.Length + 4));
Assert.That(
Assert.ThrowsAsync<InvalidOperationException>(
async () => await sender.SendAsync("unreliable", Array.Empty<byte>())),
Is.Not.Null);
stream.Position = 0;
var receiver = new TcpFramedTransportConnection(stream, profile);
var frame = await receiver.ReceiveAsync();
Assert.That(frame.ChannelId, Is.EqualTo("schema"));
Assert.That(frame.Payload, Is.EqualTo(payload));
Assert.That(receiver.Stats.FramesReceived, Is.EqualTo(1));
Assert.That(receiver.Stats.BytesReceived, Is.EqualTo(frame.Payload.Length + 4));
Assert.That(receiver.Profile.Transports[0].Protocol, Is.EqualTo("tcp_framed"));
}
[Test]
public void RudpPacketCodec_UsesDeterministicReliableOrderedFixture()
{
var encoded = CultNetRudpPacketCodec.Encode(new CultNetRudpPacket
{
PacketType = CultNetRudpPacketType.Data,
ConnectionId = 0x01020304,
Sequence = 0x0000002a,
Ack = 0x00000029,
AckMask = 0x80000001,
ChannelId = "schema",
Reliable = true,
Ordered = true,
FragmentId = 7,
FragmentIndex = 1,
FragmentCount = 3,
Payload = Encoding.UTF8.GetBytes("hello")
});
Assert.That(
Convert.ToHexString(encoded).ToLowerInvariant(),
Is.EqualTo("434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f"));
var decoded = CultNetRudpPacketCodec.Decode(encoded);
Assert.That(decoded.PacketType, Is.EqualTo(CultNetRudpPacketType.Data));
Assert.That(decoded.ConnectionId, Is.EqualTo(0x01020304));
Assert.That(decoded.Sequence, Is.EqualTo(0x0000002a));
Assert.That(decoded.Ack, Is.EqualTo(0x00000029));
Assert.That(decoded.AckMask, Is.EqualTo(0x80000001));
Assert.That(decoded.ChannelId, Is.EqualTo("schema"));
Assert.That(decoded.Reliable, Is.True);
Assert.That(decoded.Ordered, Is.True);
Assert.That(decoded.Sequenced, Is.False);
Assert.That(decoded.FragmentId, Is.EqualTo(7));
Assert.That(decoded.FragmentIndex, Is.EqualTo(1));
Assert.That(decoded.FragmentCount, Is.EqualTo(3));
Assert.That(Encoding.UTF8.GetString(decoded.Payload), Is.EqualTo("hello"));
}
[Test]
public void RudpTransportProfile_AdvertisesStateAndRealtimeChannels()
{
var profile = CultNetTransportProfiles.CreateRudp(
"csharp-rudp",
new RudpTransportProfileOptions
{
TransportId = "public-rudp",
Host = "127.0.0.1",
Port = 7777,
MaxPayloadBytes = 1200,
MaxFragmentBytes = 1000,
MaxPendingReliablePackets = 64
});
Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp"));
Assert.That(profile.Transports[0].ReconnectPolicy?.SchemaVersion, Is.EqualTo("cultnet.reconnect_policy.v0"));
Assert.That(profile.Transports[0].ReconnectPolicy?.BaseDelayMs, Is.EqualTo(1_000));
Assert.That(
profile.Transports[0].Channels.Select(channel => $"{channel.ChannelId}:{channel.Delivery}:{channel.Ordering}").ToArray(),
Is.EqualTo(new[]
{
"schema:reliable:ordered",
"latest:unreliable:sequenced",
"realtime:unreliable:unordered"
}));
Assert.That(
profile.Transports[0].Channels.Select(channel => channel.MaxPendingReliablePackets).ToArray(),
Is.EqualTo(new int?[] { 64, 64, 64 }));
}
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_OperationEnvelope()
{
var request = new CultNetOperationRequestMessage
{
MessageId = "plugin-42",
ServiceId = "sai.vn",
Operation = "project",
PayloadSchema = "gamecult.eve.plugin_abi.request.v1",
Payload = "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=",
SourceRuntimeId = "eve-unity"
};
var payload = CultNetSchemaMessageSerialization.Serialize(request);
var roundTrip = (CultNetOperationRequestMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo(request.MessageId));
Assert.That(roundTrip.ServiceId, Is.EqualTo("sai.vn"));
Assert.That(roundTrip.PayloadSchema, Is.EqualTo("gamecult.eve.plugin_abi.request.v1"));
}
[Test]
public void LiteNetLibTransportProfile_AdvertisesLegacyAndSchemaChannels()
{
var policy = CultNetReconnectPolicies.CreateDefault("client-policy", maxAttempts: 4);
var profile = CultNetTransportProfiles.CreateLiteNetLib(
"csharp-litenetlib",
new LiteNetLibTransportProfileOptions
{
TransportId = "game-server",
Host = "127.0.0.1",
Port = 3075,
MaxPayloadBytes = 65_507,
ReconnectPolicy = policy
});
var transport = profile.Transports.Single();
Assert.That(transport.Protocol, Is.EqualTo("litenetlib"));
Assert.That(transport.TransportId, Is.EqualTo("game-server"));
Assert.That(transport.Host, Is.EqualTo("127.0.0.1"));
Assert.That(transport.Port, Is.EqualTo(3075));
Assert.That(transport.WireContracts, Is.EqualTo(new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }));
Assert.That(transport.ReconnectPolicy, Is.SameAs(policy));
Assert.That(
transport.Channels.Select(channel => $"{channel.ChannelId}:{channel.Delivery}:{channel.Ordering}:{channel.MaxPayloadBytes}").ToArray(),
Is.EqualTo(new[]
{
"schema:reliable:ordered:65507",
"legacy:reliable:ordered:65507"
}));
}
[Test]
public void LiteNetLibClientAndServer_ExposeTransportProfiles()
{
var policy = CultNetReconnectPolicies.CreateDefault("client-profile", maxAttempts: 3);
using var client = new Client(DevelopmentClientSecurity, policy);
using var server = new Server(new CultCache(), DevelopmentServerSecurity);
var clientTransport = client.TransportProfile.Transports.Single();
Assert.That(clientTransport.Protocol, Is.EqualTo("litenetlib"));
Assert.That(clientTransport.TransportId, Is.EqualTo("litenetlib-client"));
Assert.That(clientTransport.Host, Is.EqualTo("localhost"));
Assert.That(clientTransport.ReconnectPolicy, Is.SameAs(policy));
Assert.That(clientTransport.WireContracts, Is.EqualTo(new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }));
var serverTransport = server.TransportProfile.Transports.Single();
Assert.That(serverTransport.Protocol, Is.EqualTo("litenetlib"));
Assert.That(serverTransport.TransportId, Is.EqualTo("litenetlib-server"));
Assert.That(serverTransport.Host, Is.EqualTo("0.0.0.0"));
Assert.That(serverTransport.Port, Is.EqualTo(3075));
Assert.That(serverTransport.Channels.Select(channel => channel.ChannelId).ToArray(), Is.EqualTo(new[] { "schema", "legacy" }));
}
[Test]
public void LiteNetLibTransportConnection_DecodesSchemaAndLegacyChannels()
{
var schemaPayload = CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage
{
RuntimeId = "csharp",
RuntimeKind = "csharp",
DisplayName = "C#",
SupportedMessageVersions = [CultNetSchemaVersions.Hello]
});
var schemaFrame = LiteNetLibTransportConnection.Decode(schemaPayload);
Assert.That(schemaFrame.ChannelId, Is.EqualTo("schema"));
Assert.That(
LiteNetLibTransportConnection.DecodeSchema(schemaFrame),
Is.TypeOf<CultNetHelloMessage>());
var serializationType = typeof(Client).Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var legacyPayload = (byte[])serialize.Invoke(null, [new ErrorMessage { Error = "Nope" }])!;
var legacyFrame = LiteNetLibTransportConnection.Decode(legacyPayload);
Assert.That(legacyFrame.ChannelId, Is.EqualTo("legacy"));
Assert.That(
LiteNetLibTransportConnection.DecodeLegacy(legacyFrame),
Is.TypeOf<ErrorMessage>());
Assert.That(
() => LiteNetLibTransportConnection.DecodeSchema(legacyFrame),
Throws.InvalidOperationException);
Assert.That(
() => LiteNetLibTransportConnection.DecodeLegacy(schemaFrame),
Throws.InvalidOperationException);
}
[Test]
public void LiteNetLibTransportConnection_TracksInboundFrames()
{
var payload = CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage
{
RuntimeId = "csharp",
RuntimeKind = "csharp",
DisplayName = "C#",
SupportedMessageVersions = [CultNetSchemaVersions.Hello]
});
var connection = new LiteNetLibTransportConnection(CultNetTransportProfiles.CreateLiteNetLib("stats-test"));
var frame = connection.Receive(payload);
Assert.That(frame.ChannelId, Is.EqualTo("schema"));
Assert.That(frame.Payload, Is.EqualTo(payload));
Assert.That(connection.Stats.FramesReceived, Is.EqualTo(1));
Assert.That(connection.Stats.BytesReceived, Is.EqualTo(payload.Length));
}
2026-06-16 03:07:31 +01:00
[Test]
public void ReconnectPolicy_ExposesPortableDelayContract()
{
var policy = CultNetReconnectPolicies.CreateDefault("rudp-default", maxAttempts: 8);
Assert.That(policy.SchemaVersion, Is.EqualTo("cultnet.reconnect_policy.v0"));
Assert.That(policy.PolicyId, Is.EqualTo("rudp-default"));
Assert.That(policy.MaxAttempts, Is.EqualTo(8));
Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 1), Is.EqualTo(1_000));
Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 3, 17), Is.EqualTo(4_017));
Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 9, 999), Is.EqualTo(30_250));
Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 0, -5), Is.EqualTo(1_000));
var json = MessagePack.MessagePackSerializer.ConvertToJson(
MessagePack.MessagePackSerializer.Serialize(policy, CultNetSchemaMessageSerialization.Options));
Assert.That(json, Does.Contain("\"schemaVersion\""));
Assert.That(json, Does.Contain("\"policyId\""));
Assert.That(json, Does.Contain("\"maxAttempts\""));
}
2026-06-16 05:29:45 +01:00
[Test]
public void ReconnectController_SchedulesAttemptsAndReset()
{
var controller = new CultNetReconnectController(
CultNetReconnectPolicies.CreateDefault(maxAttempts: 2));
var first = controller.RecordFailure(10_000);
Assert.That(first.Attempt, Is.EqualTo(1));
Assert.That(first.ShouldRetry, Is.True);
Assert.That(first.DelayMs, Is.EqualTo(1_000));
Assert.That(first.NextAttemptAtMs, Is.EqualTo(11_000));
Assert.That(first.Exhausted, Is.False);
Assert.That(controller.CanAttempt(10_999), Is.False);
Assert.That(controller.CanAttempt(11_000), Is.True);
var second = controller.RecordFailure(11_000, 17);
Assert.That(second.Attempt, Is.EqualTo(2));
Assert.That(second.DelayMs, Is.EqualTo(2_017));
Assert.That(second.NextAttemptAtMs, Is.EqualTo(13_017));
Assert.That(second.ShouldRetry, Is.True);
var exhausted = controller.RecordFailure(13_017);
Assert.That(exhausted.Attempt, Is.EqualTo(2));
Assert.That(exhausted.ShouldRetry, Is.False);
Assert.That(exhausted.DelayMs, Is.EqualTo(0));
Assert.That(exhausted.NextAttemptAtMs, Is.Null);
Assert.That(exhausted.Exhausted, Is.True);
Assert.That(controller.CanAttempt(99_000), Is.False);
controller.Reset();
Assert.That(controller.Attempt, Is.EqualTo(0));
Assert.That(controller.NextAttemptAtMs, Is.Null);
Assert.That(controller.Exhausted, Is.False);
Assert.That(controller.CanAttempt(99_000), Is.True);
}
2026-06-16 06:54:42 +01:00
[Test]
public void RudpReconnectLoop_ConsumesSharedController()
{
using var serverSocket = BindUdpSocket();
var remoteEndPoint = serverSocket.LocalEndPoint!;
var openedLocalPorts = new List<int>();
const uint connectionId = 0x22334455;
using var loop = new CultNetRudpReconnectLoop(
() =>
{
var socket = BindUdpSocket();
openedLocalPorts.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
return new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-reconnect",
Socket = socket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = remoteEndPoint,
ConnectionId = connectionId
});
},
CultNetReconnectPolicies.CreateDefault(maxAttempts: 2),
Encoding.UTF8.GetBytes("join"));
var first = loop.Start();
Assert.That(first.Stats.BytesSent, Is.GreaterThan(0));
Assert.That(loop.Transport, Is.SameAs(first));
Assert.That(openedLocalPorts, Has.Count.EqualTo(1));
var decision = loop.HandleClosed(10_000, 17);
Assert.That(decision, Is.Not.Null);
Assert.That(decision!.Attempt, Is.EqualTo(1));
Assert.That(decision.ShouldRetry, Is.True);
Assert.That(decision.DelayMs, Is.EqualTo(1_017));
Assert.That(decision.NextAttemptAtMs, Is.EqualTo(11_017));
Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(1));
Assert.That(loop.ReconnectController.NextAttemptAtMs, Is.EqualTo(11_017));
Assert.That(loop.Transport, Is.Null);
Assert.That(loop.ReconnectIfDue(11_016), Is.False);
Assert.That(openedLocalPorts, Has.Count.EqualTo(1));
Assert.That(loop.ReconnectIfDue(11_017), Is.True);
Assert.That(openedLocalPorts, Has.Count.EqualTo(2));
Assert.That(loop.Transport, Is.Not.Null);
loop.MarkConnected();
Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(0));
loop.Stop();
Assert.That(loop.Transport, Is.Null);
Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(0));
}
2026-06-14 22:35:40 +01:00
[Test]
public void RudpSession_HandshakeAcksReliableConnectAndAcceptPackets()
{
var client = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 0x0a0b0c0d,
InitialSequence = 1,
ResendDelayMs = 50
});
var server = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 0x0a0b0c0d,
InitialSequence = 100,
ResendDelayMs = 50
});
var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join"));
Assert.That(connect.PacketType, Is.EqualTo(CultNetRudpPacketType.Connect));
Assert.That(connect.Sequence, Is.EqualTo(1));
Assert.That(client.PendingReliableSequences, Is.EqualTo(new uint[] { 1 }));
var accept = server.AcceptConnect(connect, 10, Encoding.UTF8.GetBytes("ok"));
Assert.That(accept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept));
Assert.That(accept.Ack, Is.EqualTo(1));
Assert.That(server.Connected, Is.True);
Assert.That(server.PendingReliableSequences, Is.EqualTo(new uint[] { 100 }));
client.Receive(accept, 20);
Assert.That(client.Connected, Is.True);
Assert.That(client.PendingReliableSequences, Is.Empty);
var ack = client.CreateAck();
2026-07-10 06:37:10 +01:00
Assert.That(ack.Sequence, Is.Zero);
2026-06-14 22:35:40 +01:00
Assert.That(ack.Ack, Is.EqualTo(100));
server.Receive(ack, 30);
Assert.That(server.PendingReliableSequences, Is.Empty);
var firstData = client.Send(
"schema",
Encoding.UTF8.GetBytes("after-ack"),
new CultNetRudpSendOptions { Reliable = true, Ordered = true });
2026-07-10 06:37:10 +01:00
Assert.That(firstData.Sequence, Is.EqualTo(2));
2026-06-14 22:35:40 +01:00
}
[Test]
public void RudpSession_ComputesAckMasksAndClearsPendingReliablePackets()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 7,
InitialSequence = 10,
ResendDelayMs = 100
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 7,
InitialSequence = 200,
ResendDelayMs = 100
});
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
2026-06-14 22:35:40 +01:00
var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var third = sender.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
Assert.That(sender.PendingReliableSequences, Is.EqualTo(new uint[] { 11, 12, 13 }));
2026-06-14 22:35:40 +01:00
receiver.Receive(first);
receiver.Receive(third);
var ackWithGap = receiver.CreateAck();
Assert.That(ackWithGap.Ack, Is.EqualTo(13));
Assert.That(ackWithGap.AckMask, Is.EqualTo(0b110u));
2026-06-14 22:35:40 +01:00
sender.Receive(ackWithGap);
Assert.That(sender.PendingReliableSequences, Is.EqualTo(new uint[] { 12 }));
2026-06-14 22:35:40 +01:00
receiver.Receive(second);
var fullAck = receiver.CreateAck();
Assert.That(fullAck.Ack, Is.EqualTo(13));
Assert.That(fullAck.AckMask, Is.EqualTo(0b111u));
2026-06-14 22:35:40 +01:00
sender.Receive(fullAck);
Assert.That(sender.PendingReliableSequences, Is.Empty);
}
[Test]
public void RudpSession_SchedulesReliableResendsUntilAcked()
{
var session = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 99,
InitialSequence = 1,
ResendDelayMs = 100
});
session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 99, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0);
2026-06-14 22:35:40 +01:00
var sent = session.Send("schema", Encoding.UTF8.GetBytes("payload"), new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 10 });
Assert.That(session.DueResends(90), Is.Empty);
Assert.That(session.DueResends(110).Select(packet => packet.Sequence).ToArray(), Is.EqualTo(new[] { sent.Sequence }));
Assert.That(session.DueResends(150), Is.Empty);
session.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 99, Sequence = 51, Ack = sent.Sequence, ChannelId = "control" });
Assert.That(session.DueResends(250), Is.Empty);
}
[Test]
public void RudpSession_ExplicitlyAcknowledgesRetransmitsOlderThanTheReceiveMask()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 991,
InitialSequence = 1,
ResendDelayMs = 10
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 991,
InitialSequence = 500
});
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
var oldPacket = sender.Send(
"snapshot",
new byte[] { 1 },
new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 0 });
receiver.Receive(oldPacket);
foreach (var sequence in Enumerable.Range(3, 40).Select(value => (uint)value))
{
receiver.Receive(new CultNetRudpPacket
{
PacketType = CultNetRudpPacketType.Data,
ConnectionId = 991,
Sequence = sequence,
ChannelId = "activity",
Reliable = true,
Ordered = false,
Payload = new[] { (byte)sequence }
});
}
sender.Receive(receiver.CreateAck());
Assert.That(sender.PendingReliableSequences.First(), Is.EqualTo(2u));
var oldRetransmit = sender.DueResends(10).Single(packet => packet.Sequence == 2);
receiver.Receive(oldRetransmit);
var exactAck = receiver.CreateAckForReceived(oldRetransmit.Sequence);
Assert.That(exactAck.Ack, Is.EqualTo(oldRetransmit.Sequence));
Assert.That(exactAck.AckMask, Is.Zero);
sender.Receive(exactAck);
Assert.That(sender.PendingReliableSequences, Does.Not.Contain(2u));
}
[Test]
public async Task RudpSession_ResendsAndAcknowledgementsCanRunConcurrently()
{
var session = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 100,
InitialSequence = 1,
ResendDelayMs = 1
});
session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 100, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0);
var packets = Enumerable.Range(0, CultNetRudpSession.ReliableSendWindowPackets)
.Select(index => session.Send("schema", Encoding.UTF8.GetBytes(index.ToString()),
new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 0 }))
.ToArray();
await Task.WhenAll(
Task.Run(() =>
{
for (var now = 1; now <= 1000; now++)
_ = session.DueResends(now);
}),
Task.Run(() =>
{
foreach (var packet in packets)
{
session.Receive(new CultNetRudpPacket
{
PacketType = CultNetRudpPacketType.Ack,
ConnectionId = 100,
Sequence = 5001,
Ack = packet.Sequence,
ChannelId = "control"
});
}
}));
Assert.That(session.PendingReliableSequences, Is.Empty);
}
2026-06-15 03:26:40 +01:00
[Test]
public void RudpSession_PingsAndDetectsReceiveTimeout()
{
var client = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 101,
InitialSequence = 1
});
var server = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 101,
InitialSequence = 100
});
var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join"));
var accept = server.AcceptConnect(connect, 10);
client.Receive(accept, 20);
var ping = client.CreatePing(Encoding.UTF8.GetBytes("pulse"));
var pingResult = server.Receive(ping, 30);
Assert.That(pingResult.Reply?.PacketType, Is.EqualTo(CultNetRudpPacketType.Pong));
Assert.That(pingResult.Reply?.Payload, Is.EqualTo(Encoding.UTF8.GetBytes("pulse")));
var pongResult = client.Receive(pingResult.Reply!, 40);
Assert.That(pongResult.Pong, Is.True);
Assert.That(pongResult.PongPayload, Is.EqualTo(Encoding.UTF8.GetBytes("pulse")));
Assert.That(client.CheckTimeout(90, 50), Is.False);
Assert.That(client.CheckTimeout(91, 50), Is.True);
Assert.That(client.Connected, Is.False);
}
[Test]
public void RudpSession_RepeatedConnectAcknowledgesWhatItCarries()
{
var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 1 });
var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 100 });
var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join"));
var accept = server.AcceptConnect(connect, 0);
var repeat = CultNetRudpPacketCodec.Decode(CultNetRudpPacketCodec.Encode(connect));
repeat.Ack = accept.Sequence;
var reply = server.AcceptConnect(repeat, 1);
Assert.That(reply.PacketType, Is.EqualTo(CultNetRudpPacketType.Ack));
Assert.That(server.OutstandingReliablePacketCount, Is.EqualTo(0));
}
[Test]
public void RudpSession_LossyPacketsCannotCreateReliableOrderedGaps()
{
var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 198, InitialSequence = 1 });
var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 198, InitialSequence = 100 });
var connect = client.CreateConnect(0);
var accept = server.AcceptConnect(connect, 1);
client.Receive(accept, 2);
var ping = client.CreatePing(Encoding.UTF8.GetBytes("pulse"));
var pong = server.Receive(ping, 3).Reply!;
var realtime = server.Send("realtime", Encoding.UTF8.GetBytes("discarded realtime"));
var latest = server.Send(
"latest",
Encoding.UTF8.GetBytes("discarded latest state"),
new CultNetRudpSendOptions { Sequenced = true });
var schema = server.Send(
"schema",
Encoding.UTF8.GetBytes("committed response"),
new CultNetRudpSendOptions { Reliable = true, Ordered = true });
Assert.That(ping.Sequence, Is.Zero);
Assert.That(pong.Sequence, Is.Zero);
Assert.That(realtime.Sequence, Is.Zero);
Assert.That(latest.Sequence, Is.EqualTo(1));
Assert.That(schema.Sequence, Is.EqualTo(accept.Sequence + 1));
Assert.That(
client.Receive(schema, 4).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "committed response" }));
}
[Test]
public void RudpSession_UnreliableSequencedDeliveryIsScopedToItsChannel()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 197, InitialSequence = 50 });
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 197, InitialSequence = 100 });
var connect = sender.CreateConnect(0);
var accept = receiver.AcceptConnect(connect, 1);
sender.Receive(accept, 2);
var older = sender.Send("latest", Encoding.UTF8.GetBytes("older"), new CultNetRudpSendOptions { Sequenced = true });
var newer = sender.Send("latest", Encoding.UTF8.GetBytes("newer"), new CultNetRudpSendOptions { Sequenced = true });
Assert.That(older.Sequence, Is.EqualTo(1));
Assert.That(newer.Sequence, Is.EqualTo(2));
Assert.That(
receiver.Receive(newer, 3).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "newer" }));
Assert.That(receiver.Receive(older, 4).Delivered, Is.Empty);
}
[Test]
public void RudpSession_RejectsUnreliableOrderedDelivery()
{
var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 196 });
session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 196, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0);
var error = Assert.Throws<InvalidOperationException>(() =>
session.Send(
"schema",
Encoding.UTF8.GetBytes("cannot order what will not retransmit"),
new CultNetRudpSendOptions { Ordered = true }));
Assert.That(error!.Message, Does.Contain("ordered delivery requires reliability"));
}
[Test]
public void RudpSession_BoundsPendingReliablePacketsBeforeEnqueue()
{
var session = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 102,
InitialSequence = 1,
MaxPendingReliablePackets = 2
});
session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 102, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0);
session.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
session.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var error = Assert.Throws<InvalidOperationException>(() =>
session.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }));
Assert.That(error!.Message, Does.Contain("reliable send queue is full"));
Assert.That(session.PendingReliableSequences, Is.EqualTo(new uint[] { 2, 3 }));
var fragmented = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 103,
InitialSequence = 1,
MaxPendingReliablePackets = 3
});
fragmented.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 900 }).AcceptConnect(fragmented.CreateConnect(0), 0), 0);
error = Assert.Throws<InvalidOperationException>(() =>
fragmented.SendMany(
"schema",
Encoding.UTF8.GetBytes("fragment-me"),
new CultNetRudpSendOptions { Reliable = true, Ordered = true },
maxFragmentBytes: 3));
Assert.That(error!.Message, Does.Contain("reliable send queue is full"));
Assert.That(fragmented.PendingReliableSequences, Is.Empty);
}
2026-06-14 22:35:40 +01:00
[Test]
public void RudpSession_SuppressesDuplicatesAndDeliversReliableOrderedPayloadsInSequence()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 123,
InitialSequence = 1
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 123,
InitialSequence = 100
});
var accept = receiver.AcceptConnect(sender.CreateConnect(0), 0);
sender.Receive(accept);
2026-06-14 22:35:40 +01:00
var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var third = sender.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
Assert.That(
receiver.Receive(first).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "first" }));
Assert.That(receiver.Receive(third).Delivered, Is.Empty);
Assert.That(receiver.Receive(first).Delivered, Is.Empty);
Assert.That(
receiver.Receive(second).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "second", "third" }));
}
[Test]
public void RudpSession_SkipsControlPacketsWhileOrderingSchemaPayloads()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 124,
InitialSequence = 1
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 124,
InitialSequence = 100
});
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var control = sender.CreateAck();
var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
Assert.That(
receiver.Receive(first).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "first" }));
Assert.That(receiver.Receive(control).Delivered, Is.Empty);
Assert.That(
receiver.Receive(second).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(),
Is.EqualTo(new[] { "second" }));
}
2026-06-15 02:26:34 +01:00
[Test]
public void RudpSession_FragmentsAndReassemblesReliableOrderedPayloads()
{
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 456,
InitialSequence = 1
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 456,
InitialSequence = 100
});
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
2026-06-15 02:26:34 +01:00
var packets = sender.SendMany(
"schema",
Encoding.UTF8.GetBytes("fragment-me-please"),
new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 10 },
maxFragmentBytes: 5).ToArray();
Assert.That(packets, Has.Length.EqualTo(4));
Assert.That(packets.Select(packet => packet.FragmentCount).ToArray(), Is.EqualTo(new ushort[] { 4, 4, 4, 4 }));
Assert.That(packets.Select(packet => packet.FragmentIndex).ToArray(), Is.EqualTo(new ushort[] { 0, 1, 2, 3 }));
Assert.That(packets.All(packet => packet.FragmentId == packets[0].FragmentId), Is.True);
Assert.That(receiver.Receive(packets[0]).Delivered, Is.Empty);
Assert.That(receiver.Receive(packets[1]).Delivered, Is.Empty);
Assert.That(receiver.Receive(packets[2]).Delivered, Is.Empty);
var delivered = receiver.Receive(packets[3]).Delivered.ToArray();
Assert.That(delivered, Has.Length.EqualTo(1));
Assert.That(Encoding.UTF8.GetString(delivered[0].Payload), Is.EqualTo("fragment-me-please"));
Assert.That(delivered[0].Sequence, Is.EqualTo(packets[0].Sequence));
}
[Test]
public void RudpSession_AdvancesLargeFragmentSetsThroughBoundedReliableWindow()
{
const uint connectionId = 457;
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25
});
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25
});
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
var fragmentCount = CultNetRudpSession.ReliableSendWindowPackets + 17;
var payload = Enumerable.Range(0, fragmentCount * 8).Select(index => (byte)(index % 251)).ToArray();
var wire = new Queue<CultNetRudpPacket>(sender.SendMany(
"schema",
payload,
new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 1 },
maxFragmentBytes: 8));
Assert.That(wire, Has.Count.EqualTo(CultNetRudpSession.ReliableSendWindowPackets));
Assert.That(sender.PendingReliableSequences, Has.Count.EqualTo(wire.Count));
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(17));
var delivered = new List<CultNetRudpDeliveredFrame>();
while (wire.Count > 0)
{
var admitted = wire.Count;
for (var index = 0; index < admitted; index++)
delivered.AddRange(receiver.Receive(wire.Dequeue(), 2).Delivered);
var acknowledged = sender.Receive(receiver.CreateAck(), 3);
foreach (var ready in acknowledged.ReadyToSend)
wire.Enqueue(ready);
}
Assert.That(sender.OutstandingReliablePacketCount, Is.Zero);
Assert.That(delivered, Has.Count.EqualTo(1));
Assert.That(delivered[0].Payload, Is.EqualTo(payload));
}
// The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023
// above the lowest unacked one and the payload above that sequence stays within 4 MiB.
private const int Mib = 1024 * 1024;
private static CultNetRudpSession FlowSession(uint initialSequence) =>
new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 0x464c4f57,
InitialSequence = initialSequence,
ResendDelayMs = 25
});
// A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's
// Connect, and the sender's Connect is acknowledged, so nothing is pending on the sender.
private static (CultNetRudpSession Sender, CultNetRudpSession Receiver) ConnectedFlowPair(uint senderInitialSequence, uint receiverInitialSequence)
{
var sender = FlowSession(senderInitialSequence);
var receiver = FlowSession(receiverInitialSequence);
sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0);
return (sender, receiver);
}
private static CultNetRudpSession ConnectedFlowSession(uint initialSequence) => ConnectedFlowPair(initialSequence, 900).Sender;
private static IReadOnlyList<CultNetRudpPacket> FlowSend(CultNetRudpSession session, int payloadBytes) =>
session.SendMany("state", new byte[payloadBytes], new CultNetRudpSendOptions { Reliable = true });
private static CultNetRudpPacket FlowAck(uint sequence) =>
new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 0x464c4f57, Ack = sequence, ChannelId = "control" };
private static CultNetRudpPacket LoseGAndFillTheSpan(CultNetRudpSession session)
{
var g = FlowSend(session, 1).Single();
for (uint offset = 1; offset <= 1023; offset++)
{
Assert.That(FlowSend(session, 1).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset }));
session.Receive(FlowAck(g.Sequence + offset), 1);
}
return g;
}
[Test]
public void RudpSession_ALostPacketHoldsTheSender1023SequencesAheadAndIsStillDelivered()
{
var (sender, receiver) = ConnectedFlowPair(1, 100);
var g = FlowSend(sender, 1).Single();
var admittedAfterG = 0;
for (var index = 0; index < 4200; index++)
{
foreach (var packet in FlowSend(sender, 1))
{
admittedAfterG++;
receiver.Receive(packet, 1);
sender.Receive(receiver.CreateAck(), 1);
}
}
Assert.That(admittedAfterG, Is.EqualTo(1023));
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(4200 - 1023));
var retransmit = sender.DueResends(1000).Single(packet => packet.Sequence == g.Sequence);
var delivered = receiver.Receive(retransmit, 1000).Delivered;
Assert.That(delivered.Select(frame => frame.Sequence), Is.EqualTo(new[] { g.Sequence }));
var promoted = sender.Receive(receiver.CreateAckForReceived(g.Sequence), 1001).ReadyToSend;
Assert.That(promoted.First().Sequence, Is.EqualTo(g.Sequence + 1024));
}
[Test]
public void RudpSession_ADirectSendPastTheSpanIsRefusedWithoutConsumingASequence()
{
var sender = ConnectedFlowSession(1);
var g = LoseGAndFillTheSpan(sender);
Assert.Throws<InvalidOperationException>(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true }));
Assert.That(sender.QueuedReliablePacketCount, Is.Zero);
sender.Receive(FlowAck(g.Sequence), 2);
var next = sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true });
Assert.That(next.Sequence, Is.EqualTo(g.Sequence + 1024));
}
[Test]
public void RudpSession_PayloadAboveTheLowestUnackedSequenceIsBoundedBy4MiB()
{
var sender = ConnectedFlowSession(1);
var g = FlowSend(sender, Mib).Single();
// The lowest unacked packet's own bytes do not count: four more MiB fit above it.
for (var index = 0; index < 4; index++)
Assert.That(FlowSend(sender, Mib), Has.Count.EqualTo(1));
Assert.That(FlowSend(sender, Mib), Is.Empty);
Assert.That(FlowSend(sender, Mib), Is.Empty);
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(2));
// Acking g moves the floor up one packet: exactly one MiB more fits.
var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend;
Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 }));
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1));
}
[Test]
public void RudpSession_APacketLargerThanTheWindowGoesOutAloneAndWaitsBehindAnythingUnacked()
{
Assert.That(FlowSend(ConnectedFlowSession(1), 5 * Mib), Has.Count.EqualTo(1));
var sender = ConnectedFlowSession(1);
var g = FlowSend(sender, 1).Single();
Assert.That(FlowSend(sender, 5 * Mib), Is.Empty);
var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend;
Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 1 }));
}
[Test]
public void RudpSession_ASmallPacketDoesNotOvertakeAQueuedLargeOne()
{
var sender = ConnectedFlowSession(1);
var g = FlowSend(sender, 1).Single();
Assert.That(FlowSend(sender, 3 * Mib), Has.Count.EqualTo(1));
Assert.That(FlowSend(sender, 2 * Mib), Is.Empty);
// A direct send refuses instead of queueing behind it, and takes no sequence.
Assert.Throws<InvalidOperationException>(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true }));
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1));
// One byte would fit above g, but the 2 MiB packet is ahead of it.
Assert.That(FlowSend(sender, 1), Is.Empty);
var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend;
Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 2, g.Sequence + 3 }));
}
[Test]
public void RudpSession_AcknowledgedBytesAboveALostPacketStillCountAgainst4MiB()
{
var sender = ConnectedFlowSession(1);
var g = FlowSend(sender, 1).Single();
// g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it
// behind the gap, so it keeps counting.
for (uint offset = 1; offset <= 4; offset++)
{
Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset }));
sender.Receive(FlowAck(g.Sequence + offset), 1);
}
Assert.That(FlowSend(sender, Mib), Is.Empty);
Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1));
Assert.Throws<InvalidOperationException>(() => sender.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true }));
// g arrives: nothing is held behind a gap any more, and the queue drains.
var promoted = sender.Receive(FlowAck(g.Sequence), 2).ReadyToSend;
Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 }));
// The bytes above the old gap no longer count: another MiB fits.
Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 }));
}
[Test]
public void RudpSession_AcknowledgedBytesCountAgainstTheLowestUnackedSequenceAsItAdvances()
{
var sender = ConnectedFlowSession(1);
var g = FlowSend(sender, 1).Single();
var h = FlowSend(sender, 1).Single();
for (var index = 0; index < 3; index++)
FlowSend(sender, Mib);
// g and the 3 MiB above h are acknowledged; h is lost.
foreach (uint offset in new uint[] { 2, 3, 4, 0 })
sender.Receive(FlowAck(g.Sequence + offset), 1);
Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 }));
// 4 MiB sit above h, acknowledged or not: one more byte does not fit.
Assert.That(FlowSend(sender, 1), Is.Empty);
// h arrives, the floor moves to g + 5, and the byte fits.
var promoted = sender.Receive(FlowAck(h.Sequence), 2).ReadyToSend;
Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 }));
}
// A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of
// the new generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost
// packet belong to no receiver's hold any more and must not count.
[Test]
public void RudpSession_ANewGenerationOnTheServerDoesNotInheritAcknowledgedBytes()
{
var server = FlowSession(500);
var accept = server.AcceptConnect(FlowSession(1).CreateConnect(0), 0);
server.Receive(FlowAck(accept.Sequence), 0);
var g = FlowSend(server, 1).Single();
for (uint offset = 1; offset <= 4; offset++)
{
Assert.That(FlowSend(server, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset }));
server.Receive(FlowAck(g.Sequence + offset), 1);
}
Assert.That(FlowSend(server, Mib), Is.Empty);
var nextAccept = server.AcceptConnect(FlowSession(200_000).CreateConnect(2), 2);
Assert.That(nextAccept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept));
// The Accept is unacknowledged and the only thing pending: 1 MiB fits above it.
var sent = server.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true });
Assert.That(sent.Sequence, Is.EqualTo(nextAccept.Sequence + 1));
}
2026-06-14 23:32:24 +01:00
[Test]
public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames()
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203040;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25
});
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
Assert.That(client.Connected, Is.True);
Assert.That(server.Connected, Is.True);
client.Send("schema", Encoding.UTF8.GetBytes("client-state"));
var serverFrame = ReceiveRudpFrame(server);
Assert.That(serverFrame.ChannelId, Is.EqualTo("schema"));
Assert.That(Encoding.UTF8.GetString(serverFrame.Payload), Is.EqualTo("client-state"));
server.Send("schema", Encoding.UTF8.GetBytes("server-state"));
var clientFrame = ReceiveRudpFrame(client);
Assert.That(clientFrame.ChannelId, Is.EqualTo("schema"));
Assert.That(Encoding.UTF8.GetString(clientFrame.Payload), Is.EqualTo("server-state"));
Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
Assert.That(client.Stats.FramesSent, Is.EqualTo(1));
Assert.That(server.Stats.FramesReceived, Is.EqualTo(1));
2026-06-15 02:26:34 +01:00
}
[Test]
public void RudpSocketTransport_DropsStraysAndKeepsServingItsPeer()
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
using var strayHost = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
var clientEndPoint = clientSocket.LocalEndPoint!;
const uint connectionId = 0x10203050;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId
});
var foreign = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x0BADF10E });
var strays = new[]
{
Encoding.UTF8.GetBytes("not a rudp packet"),
new byte[] { 1 },
CultNetRudpPacketCodec.Encode(foreign.CreateConnect(0, Encoding.UTF8.GetBytes("foreign")))
};
// A stray that arrives before the peer must not become the peer.
foreach (var stray in strays)
strayHost.SendTo(stray, serverEndPoint);
for (var index = 0; index < strays.Length; index++)
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(strays.Length));
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
// A moved flow answers from the peer's own address with someone else's session.
foreach (var stray in strays)
{
serverSocket.SendTo(stray, clientEndPoint);
clientSocket.SendTo(stray, serverEndPoint);
}
for (var index = 0; index < strays.Length; index++)
{
Assert.That(client.ReceiveOnce(), Is.Null);
Assert.That(server.ReceiveOnce(), Is.Null);
}
Assert.That(client.Stats.PacketsDropped, Is.EqualTo(strays.Length));
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2 * strays.Length));
client.Send("schema", Encoding.UTF8.GetBytes("still here"));
Assert.That(Encoding.UTF8.GetString(ReceiveRudpFrame(server).Payload), Is.EqualTo("still here"));
server.Send("schema", Encoding.UTF8.GetBytes("server still here"));
Assert.That(Encoding.UTF8.GetString(ReceiveRudpFrame(client).Payload), Is.EqualTo("server still here"));
}
/// <summary>Whether the packet's ack field and mask cover the sequence.</summary>
private static bool Acknowledges(CultNetRudpPacket packet, uint sequence)
{
if (packet.Ack == sequence)
return true;
return packet.Ack > sequence
&& packet.Ack - sequence <= 32
&& (packet.AckMask & (1u << (int)(packet.Ack - sequence - 1))) != 0;
}
private static List<CultNetRudpPacket> DrainPackets(Socket socket)
{
var packets = new List<CultNetRudpPacket>();
var buffer = new byte[65535];
EndPoint from = new IPEndPoint(IPAddress.Any, 0);
while (socket.Poll(20_000, SelectMode.SelectRead))
{
var received = socket.ReceiveFrom(buffer, ref from);
packets.Add(CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray()));
}
return packets;
}
/// <summary>A reliable frame no session accepts: a fragment set with no fragment id.</summary>
private static CultNetRudpPacket PoisonedFrame(CultNetRudpSession session)
{
var packet = session.Send("schema", Encoding.UTF8.GetBytes("poison"), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
packet.FragmentCount = 2;
packet.FragmentId = 0;
return packet;
}
[Test]
public void RudpPacketCodec_TreatsADeclaredLengthPastInt32AsMalformed()
{
var wire = CultNetRudpPacketCodec.Encode(new CultNetRudpPacket
{
PacketType = CultNetRudpPacketType.Ack,
ConnectionId = 0x10203054,
ChannelId = string.Empty
});
Assert.That(wire, Has.Length.EqualTo(36));
wire[30] = 0x80;
wire[31] = 0;
wire[32] = 0;
wire[33] = 0;
Assert.Throws<InvalidOperationException>(() => CultNetRudpPacketCodec.Decode(wire));
using var connectionSocket = BindUdpSocket();
using var listenerSocket = BindUdpSocket();
using var senderSocket = BindUdpSocket();
using var connection = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-connection",
Socket = connectionSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = 0x10203054
});
using var listener = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = listenerSocket,
ConnectionId = 0x10203054
});
senderSocket.SendTo(wire, connectionSocket.LocalEndPoint!);
senderSocket.SendTo(wire, listenerSocket.LocalEndPoint!);
Assert.That(connection.ReceiveOnce(), Is.Null);
Assert.That(listener.ReceiveOnce(), Is.Null);
Assert.That(connection.Stats.PacketsDropped, Is.EqualTo(1));
Assert.That(listener.Stats.PacketsDropped, Is.EqualTo(1));
}
[Test]
public void RudpServerMode_IsClaimedOnlyByAConnect()
{
using var serverSocket = BindUdpSocket();
using var orphanSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
const uint connectionId = 0x10203055;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverSocket.LocalEndPoint!,
ConnectionId = connectionId
});
var orphan = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
// A right-id packet that is not a Connect must claim nothing.
orphanSocket.SendTo(CultNetRudpPacketCodec.Encode(orphan.CreateAck()), serverSocket.LocalEndPoint!);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1));
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
Assert.That(client.Connected, Is.True);
Assert.That(server.Connected, Is.True);
}
[Test]
public void RudpSocketTransport_EndsTheSessionThatRefusesAReliableFrame()
{
using var serverSocket = BindUdpSocket();
using var peerSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203056;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId
});
var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint);
server.ReceiveOnce();
peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
var poison = PoisonedFrame(peer);
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1));
Assert.That(server.Connected, Is.False);
Assert.That(server.DisconnectReason, Is.Not.Null);
// The peer is told, and the goodbye does not acknowledge the refused frame.
var goodbye = DrainPackets(peerSocket).Single();
Assert.That(goodbye.PacketType, Is.EqualTo(CultNetRudpPacketType.Disconnect));
Assert.That(peer.Receive(goodbye, 1).Disconnected, Is.True);
Assert.That(Acknowledges(goodbye, poison.Sequence), Is.False, "the goodbye acknowledged the refused frame");
// A retransmit is dropped, not acknowledged.
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2));
Assert.That(DrainPackets(peerSocket), Is.Empty);
// A Connect claims the endpoint again.
var again = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(again.CreateConnect(0)), serverEndPoint);
server.ReceiveOnce();
Assert.That(DrainPackets(peerSocket).Select(p => p.PacketType), Is.EqualTo(new[] { CultNetRudpPacketType.Accept }));
}
[Test]
public void RudpSocketTransportServer_EndsThePeerWhoseReliableFrameItRefuses()
{
using var serverSocket = BindUdpSocket();
using var peerSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203057;
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = serverSocket,
ConnectionId = connectionId
});
var ended = new List<CultNetRudpSocketServerPeer>();
server.PeerDisconnected += ended.Add;
var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint);
server.ReceiveOnce();
peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
Assert.That(server.Peers, Has.Count.EqualTo(1));
var poison = PoisonedFrame(peer);
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1));
Assert.That(server.Peers, Is.Empty);
Assert.That(ended, Has.Count.EqualTo(1));
var goodbye = DrainPackets(peerSocket).Single();
Assert.That(goodbye.PacketType, Is.EqualTo(CultNetRudpPacketType.Disconnect));
Assert.That(peer.Receive(goodbye, 1).Disconnected, Is.True);
Assert.That(Acknowledges(goodbye, poison.Sequence), Is.False, "the goodbye acknowledged the refused frame");
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2));
Assert.That(DrainPackets(peerSocket), Is.Empty);
}
[Test]
public void RudpFlushReliable_FailsForAWriteTheEndedSessionForgot()
{
using var serverSocket = BindUdpSocket();
using var peerSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203058;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId
});
var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint);
server.ReceiveOnce();
peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
server.Send("schema", Encoding.UTF8.GetBytes("never acknowledged"));
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(PoisonedFrame(peer)), serverEndPoint);
Assert.That(server.ReceiveOnce(), Is.Null);
Assert.That(server.DisconnectReason, Is.Not.Null);
Assert.Throws<InvalidOperationException>(() => server.FlushReliable(TimeSpan.FromMilliseconds(200)));
}
[Test]
public void RudpServerMode_OwesOneReliableAcceptHoweverManyConnectsRepeat()
{
using var serverSocket = BindUdpSocket();
using var peerSocket = BindUdpSocket();
const uint connectionId = 0x10203059;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId
});
var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0);
for (var attempt = 0; attempt < 20; attempt++)
{
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!);
server.ReceiveOnce();
}
var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable);
Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1));
}
[Test]
public void RudpListener_OwesOneReliableAcceptPerGenerationAndReportsAReplacedPeer()
{
using var serverSocket = BindUdpSocket();
using var peerSocket = BindUdpSocket();
const uint connectionId = 0x1020305a;
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = serverSocket,
ConnectionId = connectionId
});
var ended = new List<CultNetRudpSocketServerPeer>();
server.PeerDisconnected += ended.Add;
var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("join"));
for (var attempt = 0; attempt < 20; attempt++)
{
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!);
server.ReceiveOnce();
}
var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable);
Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1));
Assert.That(server.Peers, Has.Count.EqualTo(1));
Assert.That(ended, Is.Empty);
// A different payload from the same endpoint is a new generation, and the old one is reported.
var other = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("rejoin"));
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(other), serverSocket.LocalEndPoint!);
server.ReceiveOnce();
Assert.That(server.Peers, Has.Count.EqualTo(1));
Assert.That(ended, Has.Count.EqualTo(1));
}
[Test]
public void RudpConfiguration_RejectsLimitsThatCannotAdmitAConnect()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, InitialSequence = uint.MaxValue }));
Assert.Throws<ArgumentOutOfRangeException>(() =>
new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, MaxPendingReliablePackets = 0 }));
Assert.DoesNotThrow(() =>
new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, InitialSequence = uint.MaxValue - 1, MaxPendingReliablePackets = 1 }));
using var socket = BindUdpSocket();
Assert.Throws<ArgumentOutOfRangeException>(() =>
new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = socket,
ConnectionId = 1,
InitialSequence = uint.MaxValue
}));
Assert.Throws<ArgumentOutOfRangeException>(() =>
new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = socket,
ConnectionId = 1,
MaxPendingReliablePackets = 0
}));
}
2026-06-15 23:38:42 +01:00
[Test]
public void RudpSocketTransport_CarriesCultNetSchemaMessages()
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203043;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-schema-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-schema-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25
});
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
client.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage
{
MessageId = "schema-request",
IncludeSchemaJson = true,
Kinds = ["wire_message"]
});
var request = ReceiveRudpSchemaMessage<CultNetSchemaCatalogRequestMessage>(server);
Assert.That(request.MessageId, Is.EqualTo("schema-request"));
Assert.That(request.IncludeSchemaJson, Is.True);
Assert.That(request.Kinds, Is.EqualTo(new[] { "wire_message" }));
server.SendSchemaMessage(new CultNetHelloMessage
{
RuntimeId = "csharp-rudp-schema-server",
RuntimeKind = "csharp",
DisplayName = "C# RUDP Schema Server",
SupportsSchemaCatalog = true,
TransportProfiles = [server.Profile]
});
var hello = ReceiveRudpSchemaMessage<CultNetHelloMessage>(client);
Assert.That(hello.RuntimeId, Is.EqualTo("csharp-rudp-schema-server"));
Assert.That(hello.SupportsSchemaCatalog, Is.True);
Assert.That(hello.TransportProfiles, Is.Not.Null);
var profile = hello.TransportProfiles!.Single();
Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp"));
}
2026-06-17 14:28:18 +01:00
[Test]
public void RudpSocketTransportServer_DemuxesMultiplePeers()
{
using var serverSocket = BindUdpSocket();
using var firstClientSocket = BindUdpSocket();
using var secondClientSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203045;
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = serverSocket,
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
AcceptPayload = Encoding.UTF8.GetBytes("accepted")
});
using var firstClient = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-listener-client-a",
Socket = firstClientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
using var secondClient = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-listener-client-b",
Socket = secondClientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 10,
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
firstClient.Connect("join-a");
secondClient.Connect("join-b");
for (var attempt = 0; attempt < 40 && (!firstClient.Connected || !secondClient.Connected || server.Peers.Count != 2); attempt++)
{
server.ReceiveOnce();
firstClient.ReceiveOnce();
secondClient.ReceiveOnce();
server.PollResends();
firstClient.PollResends();
secondClient.PollResends();
Thread.Sleep(5);
}
Assert.That(firstClient.Connected, Is.True);
Assert.That(secondClient.Connected, Is.True);
Assert.That(server.Peers, Has.Count.EqualTo(2));
firstClient.SendSchema("first-payload");
secondClient.SendSchema("second-payload");
CultNetRudpSocketServerFrame? firstFrame = null;
CultNetRudpSocketServerFrame? secondFrame = null;
for (var attempt = 0; attempt < 40 && (firstFrame == null || secondFrame == null); attempt++)
{
var frame = server.ReceiveOnce();
if (frame != null)
{
var payload = Encoding.UTF8.GetString(frame.Frame.Payload);
if (payload == "first-payload")
{
firstFrame = frame;
}
else if (payload == "second-payload")
{
secondFrame = frame;
}
}
firstClient.ReceiveOnce();
secondClient.ReceiveOnce();
server.PollResends();
firstClient.PollResends();
secondClient.PollResends();
Thread.Sleep(5);
}
Assert.That(firstFrame, Is.Not.Null);
Assert.That(secondFrame, Is.Not.Null);
Assert.That(firstFrame!.Peer.RemoteEndPoint, Is.Not.EqualTo(secondFrame!.Peer.RemoteEndPoint));
Assert.That(firstFrame.Frame.ChannelId, Is.EqualTo("schema"));
Assert.That(secondFrame.Frame.ChannelId, Is.EqualTo("schema"));
server.SendSchema(firstFrame.Peer, "reply-a");
server.SendSchema(secondFrame.Peer, "reply-b");
var firstReply = firstClient.ReceiveSchema(TimeSpan.FromSeconds(1));
var secondReply = secondClient.ReceiveSchema(TimeSpan.FromSeconds(1));
Assert.That(firstReply, Is.Not.Null);
Assert.That(secondReply, Is.Not.Null);
Assert.That(Encoding.UTF8.GetString(firstReply!), Is.EqualTo("reply-a"));
Assert.That(Encoding.UTF8.GetString(secondReply!), Is.EqualTo("reply-b"));
Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
Assert.That(server.Stats.FramesReceived, Is.EqualTo(2));
Assert.That(server.Stats.FramesSent, Is.EqualTo(2));
}
2026-06-17 14:48:53 +01:00
[Test]
public async Task RudpCultNetSchemaServer_Dispatches_SchemaMessages()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-schema-host",
Socket = BindUdpSocket(),
ConnectionId = 0x43554c54,
InitialSequence = 100,
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
2026-07-14 03:26:43 +01:00
var controlProgressObserved = 0;
2026-06-17 14:48:53 +01:00
server.OnCultNet<CultNetSchemaCatalogRequestMessage>((request, peer) =>
{
peer.SendCultNet(new CultNetSchemaCatalogResponseMessage
{
MessageId = request.MessageId,
Schemas =
[
new CultNetSchemaDescriptor
{
SchemaId = "rudp.schema.host",
Kind = "wire_message",
SchemaVersion = "rudp.schema.host.v0",
WireContracts = ["cultnet.schema.v0"]
}
]
});
serverDone.TrySetResult();
});
var serverThread = new Thread(() =>
{
try
{
while (!serverDone.Task.IsCompleted)
{
2026-07-14 03:26:43 +01:00
var poll = server.PollAvailableAsync(64).GetAwaiter().GetResult();
if (poll.TransportItemsConsumed > poll.MessagesDispatched)
Interlocked.Exchange(ref controlProgressObserved, 1);
if (poll.TransportItemsConsumed == 0)
Thread.Sleep(5);
2026-06-17 14:48:53 +01:00
}
}
catch (Exception error)
{
serverDone.TrySetException(error);
}
})
{
IsBackground = true
};
serverThread.Start();
using var client = CultNetSchemaClients.CreateRudp(runtimeId: "csharp-rudp-schema-host-client");
var responseCompletion = new TaskCompletionSource<CultNetSchemaCatalogResponseMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(response => responseCompletion.TrySetResult(response));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetSchemaCatalogRequestMessage
{
MessageId = "rudp-schema-host-test",
IncludeSchemaJson = false
});
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2));
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
serverThread.Join();
2026-06-17 14:48:53 +01:00
Assert.That(response.MessageId, Is.EqualTo("rudp-schema-host-test"));
Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.host"));
Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
2026-07-14 03:26:43 +01:00
Assert.That(Volatile.Read(ref controlProgressObserved), Is.EqualTo(1),
"connection and ACK traffic must count as transport progress without impersonating an application message");
}
[Test]
public void RudpCultNetSchemaServer_RejectsNonPositiveDrainBound()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
Socket = BindUdpSocket()
});
Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => server.PollAvailableAsync(0));
2026-06-17 14:48:53 +01:00
}
[Test]
public async Task RudpCultNetSchemaServer_AcceptsSequentialShortLivedClients()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-sequential-host",
Socket = BindUdpSocket(),
MaxFragmentBytes = 1024
});
server.OnCultNet<CultNetSchemaCatalogRequestMessage>((request, peer) =>
peer.SendCultNet(new CultNetSchemaCatalogResponseMessage
{
MessageId = request.MessageId,
Schemas = Array.Empty<CultNetSchemaDescriptor>()
}));
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
async Task FetchOnce(string messageId)
{
using var client = CultNetSchemaClients.CreateRudp(runtimeId: $"client-{messageId}");
var completion = new TaskCompletionSource<CultNetSchemaCatalogResponseMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(response => completion.TrySetResult(response));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetSchemaCatalogRequestMessage { MessageId = messageId });
var response = await AwaitWithTimeout(completion.Task, TimeSpan.FromSeconds(2));
Assert.That(response.MessageId, Is.EqualTo(messageId));
}
await FetchOnce("first");
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
await FetchOnce("second");
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made
// while a batch is being staged is its own write, not part of the batch.
[Test]
public void CultNetPutInsideCommitPublishesOnlyAfterCommit()
{
var path = Path.Combine(Path.GetTempPath(), $"cultnet-commit-{Guid.NewGuid():N}.cc");
try
{
var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
var database = new CultNetDatabase(cache);
var published = new List<CultRecordKey>();
using var subscription = database.Watch<NetworkSchemaNote>().Subscribe(change => published.Add(change.Key));
var batched = new CultRecordKey("tests:commit:batched");
var put = new CultRecordKey("tests:commit:put");
Assert.That(cache.Commit(batch =>
{
batch.Upsert(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "batched" }, new CultRecordHandle<NetworkSchemaNote>(batched));
database.PutAsync(put, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "put" }).GetAwaiter().GetResult();
Assert.That(published, Is.EqualTo(new[] { put }), "a record staged in the batch was published before the store committed it");
Assert.That(cache.Get(batched), Is.Null);
}), Is.True);
Assert.That(published, Is.EqualTo(new[] { put, batched }), "the batch publishes when the store commits it");
Assert.That(cache.Get<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
Assert.That(reopened.Get<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
Assert.That(reopened.Get<NetworkSchemaNote>(put)?.Text, Is.EqualTo("put"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
if (File.Exists(path + ".lock")) File.Delete(path + ".lock");
}
}
[Test]
public async Task DatabaseSubscriptionServer_StreamsRecordChangesOverRudp()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changed.TrySetResult(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
const string recordKey = "tests:subscription:note";
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-note",
SubscriptionId = "note",
RecordKeys = [recordKey],
IncludeSnapshot = true
});
await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.SubscriptionId, Is.EqualTo("note"));
Assert.That(update.ChangeKind, Is.EqualTo("added"));
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-authorized-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
database,
authorizeRequest: (request, _) => request.ConsumerRuntimeId == "allowed-runtime",
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
authorizeRecord: (_, _, recordKey, _) => recordKey.StartsWith("tests:public:", StringComparison.Ordinal));
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "public-initial"
});
await database.PutAsync(new CultRecordKey("tests:private:initial"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "private-initial"
});
using var client = CultNetSchemaClients.CreateRudp("database-authorized-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changed.TrySetResult(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-authorized",
SubscriptionId = "authorized",
ConsumerRuntimeId = "allowed-runtime",
IncludeSnapshot = true
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
Assert.That(snapshot.Documents.Select(document => document.RecordKey),
Is.EqualTo(new[] { "tests:public:initial" }));
await database.PutAsync(new CultRecordKey("tests:private:live"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "private-live"
});
await database.PutAsync(new CultRecordKey("tests:public:live"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "public-live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live"));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
2026-08-19 13:55:03 +02:00
[Test]
public async Task DatabaseSubscriptionServer_ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges()
{
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
const string schemaId = "tests.networking_note";
const string recordKeyOne = "tests:authority:one";
const string recordKeyTwo = "tests:authority:two";
2026-08-19 13:55:03 +02:00
const string bodyId = "tests:authority:body";
var authorized = true;
var sourceCache = new CultCache();
var sourceDatabase = new CultNetDatabase(sourceCache);
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
await sourceDatabase.PutAsync(new CultRecordKey(recordKeyOne), new NetworkSchemaNote
2026-08-19 13:55:03 +02:00
{
Schema = "tests.networking_note.v1",
Text = "initial"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-reconciled-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
sourceDatabase,
authorizeRequest: (_, _) => authorized,
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
authorizeRecord: (_, _, _, _) => authorized);
2026-08-19 13:55:03 +02:00
using var bodyDemand = new CultMeshBodyDemandTracker(subscriptions);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
2026-08-19 13:55:03 +02:00
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-reconciled-subscription-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
var changes = new ConcurrentQueue<CultNetReplicatedDocumentChange>();
client.Changed += changes.Enqueue;
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
await AwaitWithTimeout(
client.SubscribeAsync(
"authority",
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
schemaIds: [schemaId],
2026-08-19 13:55:03 +02:00
consumerRuntimeId: "authority-client",
bodyIds: [bodyId],
supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]),
TimeSpan.FromSeconds(2));
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
Assert.That(targetCache.Get(new CultRecordKey(recordKeyOne)), Is.Not.Null);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: PublishDemand runs after the snapshot is sent, on the server thread, while
// SubscribeAsync's completion is driven by the client's receipt of that same snapshot on a
// different thread - asserting immediately races the two. Wait for the deterministic
// condition (the demand actually being published) instead of assuming order-of-completion
// across a network round trip.
await WaitUntilAsync(() => bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2));
2026-08-19 13:55:03 +02:00
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True);
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
// Drive the record's identity change through the selection itself
// (a real rename: the old key's row is deleted, a new key's row is
// put), not through a projection delegate, then force the full
// reconcile diff that D6 gives the subscription server.
await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "renamed"
});
await sourceDatabase.DeleteAsync<NetworkSchemaNote>(new CultRecordKey(recordKeyOne));
2026-08-19 13:55:03 +02:00
subscriptions.Reconcile();
await WaitUntilAsync(
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
() => targetCache.Get(new CultRecordKey(recordKeyOne)) == null &&
targetCache.Get(new CultRecordKey(recordKeyTwo)) != null,
2026-08-19 13:55:03 +02:00
TimeSpan.FromSeconds(2));
Assert.That(changes.Any(change => change.ChangeKind == "removed" &&
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
change.RecordKey == recordKeyOne), Is.True);
2026-08-19 13:55:03 +02:00
Assert.That(changes.Any(change => change.ChangeKind == "added" &&
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
change.RecordKey == recordKeyTwo), Is.True);
2026-08-19 13:55:03 +02:00
authorized = false;
subscriptions.Reconcile();
await WaitUntilAsync(
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
() => targetCache.Get(new CultRecordKey(recordKeyTwo)) == null,
2026-08-19 13:55:03 +02:00
TimeSpan.FromSeconds(2));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: same race as the HasConsumers=true assertion above, the other direction.
await WaitUntilAsync(() => !bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2));
2026-08-19 13:55:03 +02:00
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False);
var changeCountAfterRevocation = changes.Count;
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote
2026-08-19 13:55:03 +02:00
{
Schema = "tests.networking_note.v1",
Text = "must-not-escape"
});
await Task.Delay(100);
Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation));
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
2026-08-19 13:55:03 +02:00
}
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
// Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md,
// Self's rulings 2026-09-22): flip authorizeRecord's answer per key while the whole request
// stays authorized (so DemandActive never toggles), and prove both halves of the diff -
// removing a now-unauthorized key and the add loop picking up a newly-authorized one.
[Test]
public async Task DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority()
{
const string schemaId = "tests.networking_note";
const string recordKeyA = "tests:record-authority:a";
const string recordKeyB = "tests:record-authority:b";
var authorizedKeys = new HashSet<string>(StringComparer.Ordinal) { recordKeyA };
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey(recordKeyA), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "a"
});
await database.PutAsync(new CultRecordKey(recordKeyB), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "b"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-record-authority-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
database,
authorizeRequest: (_, _) => true,
authorizeRecord: (_, _, recordKey, _) => authorizedKeys.Contains(recordKey));
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp("database-record-authority-subscription-client");
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changes.Enqueue(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-record-authority",
SubscriptionId = "record-authority",
ConsumerRuntimeId = "record-authority-client",
SchemaIds = new[] { schemaId },
IncludeSnapshot = true
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKeyA }));
authorizedKeys.Clear();
authorizedKeys.Add(recordKeyB);
subscriptions.Reconcile();
// SendRemoval carries the removed key on the message's own RecordKey field (no
// Document); SendUpsert carries the full record under Document.
await WaitUntilAsync(
() => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA) &&
changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB),
TimeSpan.FromSeconds(2));
Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA), Is.True);
Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), Is.True);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// R-P (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): one id reaches the
// authorizer, and it is the wire id, on both the snapshot and the live path. The binding below
// overrides NetworkSchemaNote's wire schema id away from its descriptor's own id
// ("tests.networking_note"); the authorizer allowlists only the wire id. Before the fix, the
// live fast path (CreateMatchedRecord) passed descriptor.SchemaId instead of the wire id, so a
// row delivered by the snapshot was refused on its first live change.
[Test]
public async Task DatabaseSubscriptionServer_AuthorizesSnapshotAndFirstLiveChangeByTheSameWireId()
{
const string wireSchemaId = "tests.networking_note.wire-alias";
const string recordKey = "tests:wire-id:note";
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry, schemaId: wireSchemaId));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry });
await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "initial"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-wire-id-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(
server,
database,
authorizeRequest: (_, _) => true,
authorizeRecord: (_, _, _, schemaId) => schemaId == wireSchemaId);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp("database-wire-id-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changed.TrySetResult(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-wire-id",
SubscriptionId = "wire-id",
RecordKeys = new[] { recordKey },
IncludeSnapshot = true
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKey }));
await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "updated"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// R-T(a) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): a removed change is
// evaluated without reading a document - it matches on the selection's keys and schemas alone,
// and a field predicate counts as unknown rather than excluding the row or throwing
// TryGetIndexValue(null). A subscriber holding the row under a selection with a field predicate
// must still receive the removal.
[Test]
public async Task DatabaseSubscriptionServer_DeliversARemovalUnderAFieldPredicateSelectionWithNoException()
{
const string recordKey = "v1:field-predicate-removal:sword";
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey(recordKey), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
var schemaId = cache.Registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-field-predicate-removal-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp("database-field-predicate-removal-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawV1Message>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changes.Enqueue(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeV1Message
{
MessageId = "subscribe-field-predicate-removal",
SubscriptionId = "field-predicate-removal",
IncludeSnapshot = true,
Selection = new CultNetSelection
{
Schemas = new[] { schemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } }
}
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
Assert.That(snapshot.Matched, Is.EqualTo(1u));
// Before R-T(a), evaluating this delete against the field predicate threw
// TryGetIndexValue(null) on the server's poll thread instead of delivering a removal.
await database.DeleteAsync<CultNetSelectionEvaluatorTests.SelLeafA>(new CultRecordKey(recordKey));
await WaitUntilAsync(
() => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey),
TimeSpan.FromSeconds(2));
Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), Is.True);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
CultNet selection cut1 fix batch, second Hands pass: mutation entries and S9 - Adds mutation entries for R-A, R-B, R-C, R-E, R-F, R-G, R-H, R-J (scripts/mutate-cultnet-selection-networking.entries.ps1), each a revert and a loosening where the rule supports one, targeting CultNetSelectionEvaluator.cs, CultNetSelection.cs, CultNetDatabaseServer.cs and CultNetDatabaseSubscriptionServer.cs. - Adds S9 (docs/cultnet-selection-cut.md section 10): a new RUDP round-trip test proving a hop-bearing v1 subscription reconciles the whole matched set when the citer (not the page row itself) changes, plus its own mutation entry (NET-S9-HopFastPath-Revert) reverting D6's Watch routing. - Adds tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs: regression coverage for R-B (edge anchor/order), R-C (code-point order in both the row tiebreak and the cursor position compare), R-E (alias matcher; door refusal), R-F (Select validates unconditionally), R-H (digest collision), R-J (numeric any_of canonical rendering) - none of these rules had a test that could fail independently of the fix-batch regression tests already in place. - Fixes a real gap R-E named but the first fix-batch pass never wrote: CultNetSelection.Validate now refuses a cites.target.schemaId that matches no declared schema, at the door, instead of MatchesCitation silently treating an unresolved target as "not found" and answering an empty page. Resolved through CultNetSchemaAliasMatching, the reference rule, against every declared schema - not only the selection's own Schemas-filtered reachable set, which a second entry (NET-RE-DoorTargetReachableOnly-Loosening) proves: a cites target is a separate structural reference, not itself constrained by Schemas. Coordinator-assigned item; the shared parity fixture's refuses_unmatched_cites_target_schema vector (Rust side, another Hands) is not yet in selection-vectors.rs-written.json, so SelectionParityVectorTests.SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference does not yet exercise it - it will once that vector lands, unmodified by this commit. - NetworkingTests.cs gains CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields (R-A's loosening) and DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges (S9 / R-A's subscribe-listener revert). Discrepancy: R-A's snapshot-request v1 listener registration (CultNetDatabaseServer's _server.OnCultNet(_snapshotHandlerV1)) has no mutation entry. CultNetDatabaseServer pairs only with Server (the LiteNetLib/TCP-secured production construction site), and no existing test in this suite - before or after this pass - drives Server's real client/listener dispatch for CultNetDatabaseServer; every test calls CreateSelectionResponse/CreateSnapshotResponse directly. That revert is recorded as not yet reached in the entries file, not swapped for an easier mutant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:11:01 +02:00
// R-A (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): CultNetDatabaseServer
// answers a v1 snapshot request against the selection's own Fields predicate, not a selection
// quietly lowered to v0's two allowlists. CultNetDatabaseServer pairs with Server (the
// LiteNetLib/TCP-secured production construction site, per section 1), not RudpCultNetSchemaServer
// - matching CultNetDatabaseServer_Creates_Filtered_SnapshotResponse above, this drives
// CreateSelectionResponse directly rather than a real client/server round trip.
[Test]
public async Task CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
await database.PutAsync(new CultRecordKey("v1:armor"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "shield",
Kind = "armor",
Mass = 3
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "v1-snapshot",
Selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } },
Projection = CultNetSelectionProjections.Document
}
});
Assert.That(response.Matched, Is.EqualTo(1u));
Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { "v1:weapon" }));
}
// S9 (docs/cultnet-selection-cut.md section 10) / D6: a hop-bearing subscription reconciles the
// whole matched set on every change, not only the changed row. "target" does not itself change
// here - a new citer is put that cites it - so only the reconcile path (not the single-row fast
// path, which would evaluate the citer's own row against a Selection carrying no hop and answer
// null, since the citer's schema is excluded by this selection's Schemas filter) can produce the
// add this test waits for.
[Test]
public async Task DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("target"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "target",
Kind = "k",
Mass = 1
});
var targetSchemaId = cache.Registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-v1-hop-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp("database-v1-hop-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawV1Message>(
TaskCreationOptions.RunContinuationsAsynchronously);
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changes.Enqueue(message));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeV1Message
{
MessageId = "subscribe-v1-hop",
SubscriptionId = "hop",
IncludeSnapshot = true,
Selection = new CultNetSelection
{
Schemas = new[] { targetSchemaId },
Cited = new CultNetIncoming { Role = "Design", Exists = true }
}
});
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
// "target" is not yet cited by anything - the hop-bearing selection matches nothing yet.
Assert.That(snapshot.Matched, Is.EqualTo(0u));
// The citer is a different schema entirely, excluded by this selection's own Schemas
// filter - the change that must cause "target" to be reported is not to "target" itself.
await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter
{
Name = "citer",
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
});
await WaitUntilAsync(
() => changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"),
TimeSpan.FromSeconds(2));
Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), Is.True);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription()
{
var database = new CultNetDatabase(new CultCache());
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-body-demand-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var active = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
var withdrawn = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.DemandChanged += demand =>
{
if (demand.Active) active.TrySetResult(demand);
else withdrawn.TrySetResult(demand);
};
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-body-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-body",
SubscriptionId = "world-body",
RecordKeys = ["world:entities", "mesh:body:world:latest"],
ConsumerRuntimeId = "eve-unity",
BodyIds = ["world"],
SupportedBodyTransports = ["SharedMemory", "Network"],
IncludeSnapshot = false
});
var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
Assert.That(observed.ConsumerRuntimeId, Is.EqualTo("eve-unity"));
Assert.That(observed.SubscriptionId, Is.EqualTo("world-body"));
Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:entities", "mesh:body:world:latest" }));
Assert.That(observed.SchemaIds, Is.Empty);
Assert.That(observed.BodyIds, Is.EqualTo(new[] { "world" }));
Assert.That(observed.SupportedBodyTransports, Is.EqualTo(new[] { "SharedMemory", "Network" }));
Assert.That(observed.SameMachine, Is.True);
client.SendCultNet(new CultNetDatabaseUnsubscribeMessage
{
MessageId = "unsubscribe-body",
SubscriptionId = "world-body"
});
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity"));
Assert.That(removed.Active, Is.False);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// A load's delivery reaches a subscription observer that needs the lifecycle gate while a subscribe's demand
// handler, holding that gate, writes the same cache. Nothing is disposed on failure: disposal would hang too.
[Test]
public async Task SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock()
{
var path = Path.Combine(Path.GetTempPath(), $"cultlib-demand-{Guid.NewGuid():N}.cc");
using (var seed = new CultCache())
{
seed.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await seed.UpsertAsync(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "loaded" },
new CultRecordHandle<NetworkSchemaNote>(new CultRecordKey("demand:loaded")));
seed.FlushAllBackingStores();
}
var cache = new CultCache();
var delivering = new ManualResetEventSlim();
// Subscribed before the database, so it runs before the database's own observer of the same change.
using var loadDelivering = cache.Watch<object>().Subscribe(_ => delivering.Set());
var database = new CultNetDatabase(cache);
var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "demand-write-server",
Socket = BindUdpSocket()
});
var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var written = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.DemandChanged += demand =>
{
if (!demand.Active) return;
_ = Task.Run(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)));
if (!delivering.Wait(TimeSpan.FromSeconds(5)))
{
written.TrySetException(new TimeoutException("the load never started delivering"));
return;
}
cache.UpsertAsync(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "demanded" },
new CultRecordHandle<NetworkSchemaNote>(new CultRecordKey("demand:written"))).GetAwaiter().GetResult();
written.TrySetResult(true);
};
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("demand-write-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-demand-write",
SubscriptionId = "demand-write",
RecordKeys = ["demand:other"],
IncludeSnapshot = false
});
var finished = await Task.WhenAny(written.Task, Task.Delay(TimeSpan.FromSeconds(10))) == written.Task;
cancellation.Cancel();
Assert.That(finished, Is.True, "the demand handler's cache write deadlocked with the load's delivery");
await written.Task;
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
// Cancel/Join before disposing anything the poll thread touches (server), so a failed
// assertion above cannot race the thread against a disposed socket (R-K).
cancellation.Cancel();
serverThread.Join();
}
subscriptions.Dispose();
server.Dispose();
database.Dispose();
cache.Dispose();
File.Delete(path);
}
[Test]
public async Task DatabaseSubscriptionServer_ClassifiesLoopbackTcpDemandAsSameMachine()
{
var database = new CultNetDatabase(new CultCache());
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var active = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.DemandChanged += demand =>
{
if (demand.Active) active.TrySetResult(demand);
};
using var client = new TcpFramedCultNetSchemaClient();
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-tcp-body",
SubscriptionId = "world-body",
ConsumerRuntimeId = "eve-unity",
BodyIds = ["world"],
SupportedBodyTransports = ["SharedMemory", "Network"],
IncludeSnapshot = false
});
var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
Assert.That(observed.SameMachine, Is.True);
Assert.That(observed.BodyIds, Is.EqualTo(new[] { "world" }));
}
[Test]
public async Task DatabaseSubscriptionServer_ProjectsExactReactiveStateDemandWithoutBodyDemand()
{
var database = new CultNetDatabase(new CultCache());
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-reactive-demand-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var active = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.DemandChanged += demand =>
{
if (demand.Active) active.TrySetResult(demand);
};
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-reactive-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-reactive",
SubscriptionId = "fog-field",
RecordKeys = ["world:field:fog"],
SchemaIds = ["gamecult.fields.splats.v1"],
ConsumerRuntimeId = "eve-unity",
IncludeSnapshot = false
});
var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" }));
Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" }));
Assert.That(observed.BodyIds, Is.Empty);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscriptionServer_WithdrawsDemandWhenPeerDisconnects()
{
var database = new CultNetDatabase(new CultCache());
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-disconnect-demand-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var active = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
var withdrawn = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.DemandChanged += demand =>
{
if (demand.Active) active.TrySetResult(demand);
else withdrawn.TrySetResult(demand);
};
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-disconnect-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-disconnect-body",
SubscriptionId = "disconnect-world-body",
ConsumerRuntimeId = "eve-unity",
BodyIds = ["world"],
SupportedBodyTransports = ["SharedMemory"],
IncludeSnapshot = false
});
await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
client.Dispose();
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body"));
Assert.That(removed.Active, Is.False);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscriptionClient_ReplicatesInitialAndLiveTypedDocumentsOverRudp()
{
var sourceCache = new CultCache();
var sourceDatabase = new CultNetDatabase(sourceCache);
const string recordKey = "tests:subscription-client:note";
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "initial"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-subscription-client-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-subscription-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
var initial = await AwaitWithTimeout(
client.SubscribeAsync("notes", recordKeys: new[] { recordKey }),
TimeSpan.FromSeconds(2));
Assert.That(initial.OfType<NetworkSchemaNote>().Single().Text, Is.EqualTo("initial"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.TypeOf<NetworkSchemaNote>());
var changed = new TaskCompletionSource<CultNetReplicatedDocumentChange>(TaskCreationOptions.RunContinuationsAsynchronously);
client.Changed += change => changed.TrySetResult(change);
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.SubscriptionId, Is.EqualTo("notes"));
Assert.That(update.Document, Is.TypeOf<NetworkSchemaNote>());
Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live"));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscriptionClient_LiveDeliveryDecodesWithoutWritingReplicaState()
{
var sourceCache = new CultCache();
var sourceDatabase = new CultNetDatabase(sourceCache);
const string recordKey = "tests:subscription-client:live-note";
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "initial"
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-live-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-live-subscription-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
var initial = await AwaitWithTimeout(
client.SubscribeAsync(
"live-notes",
recordKeys: new[] { recordKey },
deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live),
TimeSpan.FromSeconds(2));
Assert.That(initial.OfType<NetworkSchemaNote>().Single().Text, Is.EqualTo("initial"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
var changed = new TaskCompletionSource<CultNetReplicatedDocumentChange>(TaskCreationOptions.RunContinuationsAsynchronously);
client.Changed += change => changed.TrySetResult(change);
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
2026-07-19 09:23:39 +01:00
[Test]
public async Task DatabaseSubscriptionClient_LiveValueOwnsExactFilteringCurrentValueAndUnsubscribe()
{
var sourceCache = new CultCache();
var sourceDatabase = new CultNetDatabase(sourceCache);
const string recordKey = "tests:subscription-client:reactive-note";
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-live-value-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
2026-07-19 09:23:39 +01:00
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-live-value-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
using var value = await AwaitWithTimeout(
client.SubscribeLiveValueAsync<NetworkSchemaNote>("reactive-note", recordKey),
TimeSpan.FromSeconds(2));
Assert.That(value.HasValue, Is.False);
2026-07-19 09:23:39 +01:00
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
var changed = new TaskCompletionSource<NetworkSchemaNote>(TaskCreationOptions.RunContinuationsAsynchronously);
value.Changed += document => changed.TrySetResult(document);
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "initial"
2026-07-19 09:23:39 +01:00
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.Text, Is.EqualTo("initial"));
Assert.That(value.Current.Text, Is.EqualTo("initial"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
var updated = new TaskCompletionSource<NetworkSchemaNote>(TaskCreationOptions.RunContinuationsAsynchronously);
value.Changed += document => updated.TrySetResult(document);
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "updated"
});
update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2));
2026-07-19 09:23:39 +01:00
Assert.That(update.Text, Is.EqualTo("updated"));
Assert.That(value.Current.Text, Is.EqualTo("updated"));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
2026-07-19 09:23:39 +01:00
}
[Test]
public async Task DatabaseSubscriptionClient_ReceivesProviderMaterializedValueFromExactDemand()
{
var sourceDatabase = new CultNetDatabase(new CultCache());
const string recordKey = "tests:subscription-client:demanded-note";
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-demanded-value-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
subscriptions.DemandChanged += demand =>
{
if (!demand.Active || !demand.RecordKeys.Contains(recordKey, StringComparer.Ordinal))
return;
sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "materialized-on-demand"
}).GetAwaiter().GetResult();
};
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry));
var transport = CultNetSchemaClients.CreateRudp("database-demanded-value-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
using var value = await AwaitWithTimeout(
client.SubscribeLiveValueAsync<NetworkSchemaNote>("demanded-note", recordKey),
TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2));
Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding()
{
const string recordKey = "tests:subscription-client:wire-note";
const string wireSchema = "tests.wire_note.v1";
var sourceCache = new CultCache();
var sourceDocuments = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(sourceCache.Registry, wireSchema));
using var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceDocuments
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-wire-schema-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(targetCache.Registry, wireSchema));
var transport = CultNetSchemaClients.CreateRudp("database-wire-schema-subscription-client");
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
await AwaitWithTimeout(
client.SubscribeAsync("wire-notes", recordKeys: [recordKey], schemaIds: [wireSchema]),
TimeSpan.FromSeconds(2));
var changed = new TaskCompletionSource<CultNetReplicatedDocumentChange>(TaskCreationOptions.RunContinuationsAsynchronously);
client.Changed += change => changed.TrySetResult(change);
await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "wire-live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
Assert.That(update.SchemaId, Is.EqualTo(wireSchema));
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live"));
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public void DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias()
{
var cache = new CultCache();
var documents = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = documents
});
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "database-schema-alias-subscription-server",
Socket = BindUdpSocket()
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var change = new CultNetDatabaseChange<NetworkSchemaNote>(
CultNetDatabaseChangeKind.Added,
new CultRecordKey("tests:subscription-client:alias-note"),
descriptor.SchemaId,
database.Shards[0],
new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias-live" },
previousDocument: null);
var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod(
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
"CreateMatchedRecord",
BindingFlags.Instance | BindingFlags.NonPublic)!;
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
var requestType = typeof(CultNetDatabaseSubscriptionServer).GetNestedType(
"SubscriptionRequest", BindingFlags.NonPublic)!;
var requestCtor = requestType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single();
var request = requestCtor.Invoke(new object?[]
{
new CultNetSelection
{
Schemas = ["tests.networking_note.v1"],
Keys = ["tests:subscription-client:alias-note"]
},
"alias-notes",
"alias-notes",
true,
null,
null,
null,
false
});
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[]
{
change,
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
request,
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
null
});
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
Assert.That(record, Is.Not.Null);
Assert.That(record!.SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(record.SchemaName, Is.EqualTo(descriptor.SchemaName));
Assert.That(record.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion));
Assert.That(record.SchemaContentHash, Is.EqualTo(descriptor.ContentHash));
}
[Test]
public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-large-snapshot-host",
Socket = BindUdpSocket(),
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 1024
});
var responseCompletion = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
var serverFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
server.OnCultNet<CultNetSnapshotRequestMessage>((request, peer) => peer.SendCultNet(
new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
Documents =
[
new CultNetRawDocumentRecord
{
SchemaId = "test.large.snapshot.v1",
RecordKey = "test:large:snapshot",
PayloadEncoding = "messagepack",
Payload = Enumerable.Range(0, 128 * 1024)
.Select(index => (byte)(index % 251)).ToArray()
}
]
}));
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
try
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}
catch (Exception error) { serverFailure.TrySetResult(error); }
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
try
{
using var client = CultNetSchemaClients.CreateRudp(
runtimeId: "csharp-rudp-large-snapshot-client",
maxFragmentBytes: 1024);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(response => responseCompletion.TrySetResult(response));
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetSnapshotRequestMessage
{
MessageId = "large-snapshot",
SchemaIds = ["test.large.snapshot.v1"],
RecordKeys = ["test:large:snapshot"]
});
var completed = await Task.WhenAny(responseCompletion.Task, serverFailure.Task, Task.Delay(5000));
if (completed == serverFailure.Task) throw await serverFailure.Task;
if (completed != responseCompletion.Task)
Assert.Fail($"Large snapshot timed out; server sent {server.Stats.BytesSent} bytes, received {server.Stats.BytesReceived} bytes, transports {server.Profile.Transports.Count()}.");
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromMilliseconds(1));
await WaitUntilAsync(
() => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0,
TimeSpan.FromSeconds(2));
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024));
Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024),
"A lossless large response must not remain trapped in the reliable resend queue.");
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
public async Task RudpCultNetSchemaServer_SerializesConcurrentFragmentedSendsPerPeer()
{
const int publisherCount = 4;
const int messagesPerPublisher = 4;
const int expectedMessages = publisherCount * messagesPerPublisher;
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-concurrent-publish-host",
Socket = BindUdpSocket(),
MaxFragmentBytes = 512,
MaxPendingReliablePackets = 1024
});
var receivedIds = new ConcurrentDictionary<string, byte>();
var receivedAll = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var serverFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
try
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}
catch (Exception error) { serverFailure.TrySetResult(error); }
}) { IsBackground = true };
serverThread.Start();
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
try
{
using var client = CultNetSchemaClients.CreateRudp(
runtimeId: "csharp-rudp-concurrent-publish-client",
maxFragmentBytes: 512);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(response =>
{
if (receivedIds.TryAdd(response.MessageId, 0) && receivedIds.Count == expectedMessages)
receivedAll.TrySetResult(true);
});
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected && server.Peers.Count == 1, TimeSpan.FromSeconds(2));
var peer = server.Peers.Single();
using var sendBarrier = new Barrier(publisherCount);
await Task.WhenAll(Enumerable.Range(0, publisherCount).Select(publisher => Task.Run(() =>
{
sendBarrier.SignalAndWait();
for (var message = 0; message < messagesPerPublisher; message++)
{
var messageId = $"publisher-{publisher}-message-{message}";
server.SendCultNet(peer, new CultNetSnapshotResponseRawMessage
{
MessageId = messageId,
Documents =
[
new CultNetRawDocumentRecord
{
SchemaId = "test.concurrent.publish.v1",
RecordKey = $"test:concurrent:{messageId}",
PayloadEncoding = "messagepack",
Payload = Enumerable.Range(0, 4 * 1024)
.Select(index => (byte)((index + publisher + message) % 251)).ToArray()
}
]
});
}
})));
var completed = await Task.WhenAny(receivedAll.Task, serverFailure.Task, Task.Delay(5000));
if (completed == serverFailure.Task) throw await serverFailure.Task;
if (completed != receivedAll.Task)
Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages.");
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages));
}
2026-06-15 23:52:29 +01:00
[Test]
public void RudpSocketTransport_ErgonomicHelpersCarryNamedChannels()
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203044;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-helper-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-helper-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25
});
client.Connect("join");
PumpRudpHandshake(client, server);
client.SendSchema("client-state");
var schemaPayload = server.ReceiveSchema(TimeSpan.FromSeconds(1));
Assert.That(schemaPayload, Is.Not.Null);
Assert.That(Encoding.UTF8.GetString(schemaPayload!), Is.EqualTo("client-state"));
server.SendSchemaMessage(new CultNetHelloMessage
{
RuntimeId = "csharp-rudp-helper-server",
RuntimeKind = "csharp"
});
var hello = client.ReceiveSchemaMessage<CultNetHelloMessage>(TimeSpan.FromSeconds(1));
Assert.That(hello, Is.Not.Null);
Assert.That(hello!.RuntimeId, Is.EqualTo("csharp-rudp-helper-server"));
client.SendLatest("latest-state");
var latest = server.ReceiveUntil(
TimeSpan.FromSeconds(1),
frame => string.Equals(frame.ChannelId, "latest", StringComparison.Ordinal));
Assert.That(latest, Is.Not.Null);
Assert.That(Encoding.UTF8.GetString(latest!.Payload), Is.EqualTo("latest-state"));
client.SendRealtime("tick");
var realtime = server.ReceiveUntil(
TimeSpan.FromSeconds(1),
frame => string.Equals(frame.ChannelId, "realtime", StringComparison.Ordinal));
Assert.That(realtime, Is.Not.Null);
Assert.That(Encoding.UTF8.GetString(realtime!.Payload), Is.EqualTo("tick"));
}
2026-06-15 02:26:34 +01:00
[Test]
public void RudpSocketTransport_CarriesFragmentedReliableOrderedSchemaFrames()
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
var serverEndPoint = serverSocket.LocalEndPoint!;
const uint connectionId = 0x10203041;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-fragment-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = 100,
ResendDelayMs = 25,
MaxFragmentBytes = 8
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-fragment-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
ResendDelayMs = 25,
MaxFragmentBytes = 8
});
var payload = Encoding.UTF8.GetBytes("this-schema-frame-is-larger-than-one-rudp-fragment");
client.Connect(Encoding.UTF8.GetBytes("join"));
PumpRudpHandshake(client, server);
client.Send("schema", payload);
var serverFrame = ReceiveRudpFrame(server);
Assert.That(serverFrame.ChannelId, Is.EqualTo("schema"));
Assert.That(serverFrame.Payload, Is.EqualTo(payload));
Assert.That(client.Stats.FramesSent, Is.EqualTo(1));
Assert.That(server.Stats.FramesReceived, Is.EqualTo(1));
2026-06-14 23:32:24 +01:00
}
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_RawSnapshotResponse()
{
var message = new CultNetSnapshotResponseRawMessage
{
MessageId = "snapshot-1",
ShardId = "players-eu",
ShardEpoch = 12,
ShardLogSequence = 42,
Documents =
[
new CultNetRawDocumentRecord
{
SchemaId = "sha256:ghostlight-agent-state",
RecordKey = "world/main",
StoredAt = "2026-05-06T12:34:56.0000000+00:00",
PayloadEncoding = "messagepack",
Payload = [0x91, 0xA3, 0x66, 0x6F, 0x6F],
SourceRuntimeId = "voidbot",
SourceAgentId = "void",
SourceRole = "herald",
Tags = ["swarm", "dream"]
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("snapshot-1"));
Assert.That(roundTrip.ShardId, Is.EqualTo("players-eu"));
Assert.That(roundTrip.ShardEpoch, Is.EqualTo(12));
Assert.That(roundTrip.ShardLogSequence, Is.EqualTo(42));
Assert.That(roundTrip.Documents, Has.Length.EqualTo(1));
Assert.That(roundTrip.Documents[0].SchemaId, Is.EqualTo("sha256:ghostlight-agent-state"));
Assert.That(roundTrip.Documents[0].PayloadEncoding, Is.EqualTo("messagepack"));
Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(message.Documents[0].Payload));
Assert.That(roundTrip.Documents[0].Tags, Is.EqualTo(["swarm", "dream"]));
}
2026-05-19 17:10:24 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_DatabaseChangeRaw()
{
var message = new CultNetDatabaseChangeRawMessage
{
MessageId = "change-1",
SubscriptionId = "sub-1",
ChangeKind = "updated",
Document = new CultNetRawDocumentRecord
{
SchemaId = "schema-1",
RecordKey = "record-1",
StoredAt = "2026-05-19T12:00:00.0000000+00:00",
PayloadEncoding = "messagepack",
Payload = [0x91, 0x01]
}
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetDatabaseChangeRawMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.SubscriptionId, Is.EqualTo("sub-1"));
Assert.That(roundTrip.ChangeKind, Is.EqualTo("updated"));
Assert.That(roundTrip.Document, Is.Not.Null);
Assert.That(roundTrip.Document!.RecordKey, Is.EqualTo("record-1"));
}
2026-05-19 17:34:49 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_ShardCatalogResponse()
{
var message = new CultNetShardCatalogResponseMessage
{
MessageId = "shards-1",
Shards =
[
new CultNetShardDescriptorMessage
{
ShardId = "players-eu",
OwnerRuntimeId = "runtime-a",
Epoch = 12,
IsPrimary = true,
SchemaIds = ["schema-player"],
KeyPrefix = "player:",
PrimaryEndpoints = ["cultnet://runtime-a:3075"],
ReplicaEndpoints = ["cultnet://runtime-b:3075"],
ReadReplicaEndpoints = ["cultnet://edge-1:3075"],
Region = "eu-west"
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetShardCatalogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("shards-1"));
Assert.That(roundTrip.Shards, Has.Length.EqualTo(1));
Assert.That(roundTrip.Shards[0].ShardId, Is.EqualTo("players-eu"));
Assert.That(roundTrip.Shards[0].Epoch, Is.EqualTo(12));
Assert.That(roundTrip.Shards[0].PrimaryEndpoints, Is.EqualTo(["cultnet://runtime-a:3075"]));
Assert.That(roundTrip.Shards[0].Region, Is.EqualTo("eu-west"));
}
2026-05-19 18:01:11 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_ShardLogResponse()
{
var message = new CultNetShardLogResponseMessage
{
MessageId = "log-1",
ShardId = "players-eu",
ShardEpoch = 12,
2026-05-19 20:53:45 +01:00
CompactedThrough = 40,
2026-05-19 18:01:11 +01:00
Entries =
[
new CultNetShardLogEntryMessage
{
Sequence = 42,
CommittedAt = "2026-05-19T12:00:00.0000000Z",
ChangeKind = "updated",
Put = new CultNetDocumentPutRawMessage
{
MessageId = "put-42",
ShardId = "players-eu",
ShardEpoch = 12,
Document = new CultNetRawDocumentRecord
{
SchemaId = "schema-player",
RecordKey = "player:42",
Payload = [0x91, 0x2A]
}
}
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetShardLogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("log-1"));
Assert.That(roundTrip.ShardId, Is.EqualTo("players-eu"));
Assert.That(roundTrip.ShardEpoch, Is.EqualTo(12));
2026-05-19 20:53:45 +01:00
Assert.That(roundTrip.CompactedThrough, Is.EqualTo(40));
2026-05-19 18:01:11 +01:00
Assert.That(roundTrip.Entries, Has.Length.EqualTo(1));
Assert.That(roundTrip.Entries[0].Sequence, Is.EqualTo(42));
Assert.That(roundTrip.Entries[0].Put, Is.Not.Null);
Assert.That(roundTrip.Entries[0].Put!.Document.RecordKey, Is.EqualTo("player:42"));
}
2026-05-19 21:23:11 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_VerseCatalogResponse()
{
var message = new CultMeshVerseCatalogResponseMessage
{
MessageId = "verses-1",
Verses =
[
new CultMeshVerseDescriptorMessage
{
VerseId = "aetheria-main",
DisplayName = "Aetheria",
AuthorityModel = "OperatorCluster",
Compatibility = new CultMeshVerseCompatibilityMessage
{
TransportVersion = "cultmesh.v0",
RulesHash = "rules",
CompatibleVerseIds = ["aetheria-modded"],
RequiredPluginIds = ["core"],
OptionalPluginIds = ["skylands"]
},
DiscoveryEndpoints = ["cultmesh://aetheria.example.test:3075"],
AuthorityRuntimeIds = ["runtime-a"],
Description = "main branch"
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultMeshVerseCatalogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("verses-1"));
Assert.That(roundTrip.Verses, Has.Length.EqualTo(1));
Assert.That(roundTrip.Verses[0].VerseId, Is.EqualTo("aetheria-main"));
Assert.That(roundTrip.Verses[0].Compatibility.RequiredPluginIds, Is.EqualTo(["core"]));
}
2026-05-20 01:35:39 +01:00
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_PeerExchangeResponse()
{
var message = new CultMeshPeerExchangeResponseMessage
{
MessageId = "pex-1",
Peers =
[
new CultMeshPeerCardMessage
{
PeerId = "peer-a",
VerseId = "aetheria-main",
Endpoints = ["cultnet://peer-a.example.test:3075"],
Roles = [CultMeshPeerRoles.Discovery, CultMeshPeerRoles.ReadReplica],
ShardIds = ["players"],
Region = "eu-west",
AuthorityLeaseId = "lease-1",
ExpiresAt = "2026-05-20T12:00:00.0000000Z",
Signature = "sig"
}
]
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultMeshPeerExchangeResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("pex-1"));
Assert.That(roundTrip.Peers, Has.Length.EqualTo(1));
Assert.That(roundTrip.Peers[0].PeerId, Is.EqualTo("peer-a"));
Assert.That(roundTrip.Peers[0].Roles, Does.Contain(CultMeshPeerRoles.ReadReplica));
Assert.That(roundTrip.Peers[0].AuthorityLeaseId, Is.EqualTo("lease-1"));
}
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_SimulationObservation()
{
var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
var message = new CultNetSimulationObservationMessage
{
MessageId = "observation-1",
Observation = new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash,
ClaimSummary = "alice hit bob first",
ObservedAt = "2026-05-19T12:00:00.0000000Z"
}
};
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetSimulationObservationMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("observation-1"));
Assert.That(roundTrip.Observation.WitnessRuntimeId, Is.EqualTo("watcher-1"));
Assert.That(roundTrip.Observation.Frame, Is.EqualTo(100));
Assert.That(roundTrip.Observation.ClaimHash, Is.EqualTo(claimHash));
}
[Test]
public void CultNetSchemaMessageSerialization_RoundTrips_SimulationConsensusCandidate()
{
var candidate = new CultNetSimulationConsensusCandidate(
"arena",
4,
100,
"bob",
"hit",
"claim-hash",
"alice hit bob first",
witnessCount: 3,
supportWeight: 3d,
totalWeight: 4d,
hasQuorum: true);
var message = CultNetSimulationConsensusCandidateMessage.FromCandidate("candidate-1", candidate);
var payload = CultNetSchemaMessageSerialization.Serialize(message);
var roundTrip = (CultNetSimulationConsensusCandidateMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
Assert.That(roundTrip.MessageId, Is.EqualTo("candidate-1"));
Assert.That(roundTrip.ShardId, Is.EqualTo("arena"));
Assert.That(roundTrip.WitnessCount, Is.EqualTo(3));
Assert.That(roundTrip.SupportWeight, Is.EqualTo(3d));
Assert.That(roundTrip.TotalWeight, Is.EqualTo(4d));
Assert.That(roundTrip.Confidence, Is.EqualTo(0.75d));
Assert.That(roundTrip.HasQuorum, Is.True);
}
[Test]
public async Task CultNetDocumentRegistry_RawSnapshotReplication_PreservesPayloadBytes()
{
var sourceCache = new CultCache();
var targetCache = new CultCache();
var registry = new CultNetDocumentRegistry()
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var sourceEntry = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "cult@example.test",
PasswordHash = "not-a-real-hash",
Username = "CultGhost"
};
var handle = await sourceCache.AddAsync(sourceEntry);
var expectedPayload = SerializePlayerDataPayload(sourceEntry);
var request = registry.CreateSnapshotRequest(
"request-1",
schemaIds: [sourceCache.Registry.GetRequired<PlayerData>().SchemaId],
recordKeys: [handle.Key.Value]);
var response = registry.CreateRawSnapshotResponse(sourceCache, "snapshot-1", request);
var serializedResponse = CultNetSchemaMessageSerialization.Serialize(response);
var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(serializedResponse);
Assert.That(roundTrip.Documents, Has.Length.EqualTo(1));
Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(expectedPayload));
await registry.ApplyRawSnapshotResponseAsync(targetCache, roundTrip);
var replicated = targetCache.GetByIndex<PlayerData>("PlayerId", sourceEntry.PlayerId.ToString("D"));
Assert.That(replicated, Is.Not.Null);
Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload));
}
R-Q3, R-R: v0 lowering preserves v0's pre-cut answers exactly CultNetV0SelectionLowering.Lower collapsed a non-null-but-empty filter list to null ("no filter"). That corrupted two pre-cut v0 behaviours the same function feeds: - CultNetDatabase.CreateShardSnapshotResponse and CultNetDocumentRegistry.CreateRawSnapshotResponse (b3d9cf7) both test `filter?.RecordKeys != null`, not a length, so `recordKeys: []` always answered empty. Lowering the empty list to null made it answer every row instead. This corrects the ruling from the commit-2 fix batch that lowered an empty v0 list to "no filter". - CultMesh.ResolveDefaultSelection used the same collapse to decide whether an explicit empty recordKeys should still fall back to the document type's own schema default; under the corrected Lower it must not. Lower(null) still means "no filter"; Lower(non-null) is always a real (possibly empty) array. The evaluator already treats a non-null empty Keys array as "match nothing" (CultNetSelectionEvaluator.MatchesSchemaKeysFields); only the v0 lowering was erasing that signal before selections reached it. Also restores the pre-cut requested-key order for CultNetDocumentRegistry.CreateRawSnapshotResponse: pre-cut, requested record keys were iterated in the order the caller sent them (a HashSet<string> built from the array, which enumerates in insertion order); the new shared evaluator's constant ordinal fell back to schema-and-key sort order instead. ordinalOf now ranks rows by their position in the requested key list when one was given. Rewrote CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys (renamed ...AnswersEmpty) to assert the corrected behaviour instead of the ruling it used to pin. Added CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty, CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder and CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty to NetworkingTests.cs. Not done in this commit: CultNetDatabase.CreateShardSnapshotResponse (the shard-replication snapshot path) still orders by the evaluator's default schema-and-key tiebreak. Its own pre-cut order was raw cache iteration order, which the new evaluator has no equivalent for and shard replication does not appear to depend on row order; flagged as a discrepancy rather than changed without a test that would pin the "right" order. The side-by-side b3d9cf7 vs HEAD comparison run the spec asks for was not executed in this pass; the pre-cut behaviour above comes from reading CultNetDatabase.cs and CultNetDocumentRegistry.cs at b3d9cf7 directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:31 +02:00
// R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's
// pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter".
[Test]
public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry()
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" });
var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty<string>());
var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request);
Assert.That(response.Documents, Is.Empty);
}
// R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order
// the caller sent them, not in schema-and-key sort order. This pins that order survives the
// move to the shared evaluator.
[Test]
public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry()
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var players = new[] { "zeta", "alpha", "mid" }
.Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name })
.ToArray();
var handles = new List<CultRecordHandle<PlayerData>>();
foreach (var player in players)
handles.Add(await cache.AddAsync(player));
// Request in an order that matches neither the insertion order nor the ordinal-sorted
// record-key order, so a schema-and-key sort would visibly reorder the response.
var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value };
var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder);
var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request);
Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder));
}
[Test]
public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty()
{
Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null);
Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty<string>()), Is.Not.Null);
Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty<string>()), Is.Empty);
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null);
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty);
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" }));
}
2026-05-19 10:44:00 +01:00
[Test]
public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
var key = new CultRecordKey("player:watch");
var playerId = Guid.NewGuid();
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database
.WatchByIndex<PlayerData>("PlayerId", playerId.ToString("D"))
.Subscribe(change => changes.Add(change));
var player = new PlayerData
{
PlayerId = playerId,
Email = "watch@example.test",
PasswordHash = "hash",
Username = "Watcher"
};
await database.PutAsync(key, player);
var roundTrip = await database.GetAsync<PlayerData>(key);
Assert.That(roundTrip, Is.SameAs(player));
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
Assert.That(changes[0].Key, Is.EqualTo(key));
Assert.That(changes[0].Shard.IsPrimary, Is.True);
}
[Test]
public void CultNetDatabase_Rejects_LocalWrites_To_ReadOnlyShard()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
CultNetShardDescriptor.ReadOnly(
"remote-players",
"remote-runtime",
schemaIds: [schemaId])
]
});
Assert.That(
async () => await database.PutAsync(
new CultRecordKey("player:remote"),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "remote@example.test",
PasswordHash = "hash",
Username = "Remote"
}),
Throws.TypeOf<CultNetShardAuthorityException>()
.With.Property(nameof(CultNetShardAuthorityException.Shard))
.Property(nameof(CultNetShardDescriptor.ShardId))
.EqualTo("remote-players"));
}
2026-05-19 17:34:49 +01:00
[Test]
public async Task CultNetDatabase_Rejects_RawPut_With_StaleShardEpoch()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players",
"runtime-a",
epoch: 7,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://runtime-a:3075"])
]
});
var key = new CultRecordKey("player:stale");
var message = registry.CreateRawDocumentPutMessage(
"put-stale",
new CultRecordHandle<PlayerData>(key),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "stale@example.test",
PasswordHash = "hash",
Username = "Stale"
});
message.ShardId = "players";
message.ShardEpoch = 6;
Assert.That(
async () => await database.ApplyPutAsync(message),
Throws.TypeOf<CultNetShardAuthorityException>()
.With.Property(nameof(CultNetShardAuthorityException.Shard))
.Property(nameof(CultNetShardDescriptor.Epoch))
.EqualTo(7));
}
2026-05-19 10:44:00 +01:00
[Test]
public async Task CultNetDatabase_ApplyRawPut_Publishes_DomainChange()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
var key = new CultRecordKey("player:raw");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "raw@example.test",
PasswordHash = "hash",
Username = "Raw"
};
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database.WatchRecord<PlayerData>(key)
.Subscribe(change => changes.Add(change));
var message = registry.CreateRawDocumentPutMessage(
"put-raw",
new CultRecordHandle<PlayerData>(key),
player);
var applied = await database.ApplyPutAsync(message);
Assert.That(applied, Is.TypeOf<PlayerData>());
Assert.That(cache.Get<PlayerData>(key), Is.Not.Null);
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
Assert.That(changes[0].Document, Is.Not.Null);
Assert.That(changes[0].Document!.Username, Is.EqualTo("Raw"));
}
[Test]
public async Task CultNetDatabase_ApplyRawPut_ResolvesForeignSchemaIdFromPayload()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var shard = new CultNetShardDescriptor(
"network-notes",
"runtime-a",
epoch: 3,
isPrimary: true,
schemaIds: [descriptor.SchemaId],
keyPrefix: "network-note:",
primaryEndpoints: ["cultnet://runtime-a:3075"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
var key = new CultRecordKey("network-note:foreign-schema-put");
var changes = new List<CultNetDatabaseChange<NetworkSchemaNote>>();
using var subscription = database.WatchRecord<NetworkSchemaNote>(key)
.Subscribe(change => changes.Add(change));
var message = new CultNetDocumentPutRawMessage
{
MessageId = "put-foreign-schema",
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
Document = new CultNetRawDocumentRecord
{
SchemaId = "runtime.generated.network-note.ui.42",
RecordKey = key.Value,
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
PayloadEncoding = "messagepack",
Make generated and reflective document members one rule The generator and CultDocumentRegistry now discover members by the same steps: most-derived declaration first, override chain collapsed to one member, attributes read up the override chain, rejections in one order with one text (GCC001 / InvalidOperationException). - Accessors are named per document, so documents sharing a [CultName] or [CultIndex] base member no longer emit duplicate methods (CS0111). - Duplicate [Key] slots are refused, including new-hidden fields and properties; a hidden persisted member with a different slot is refused. - String [Key("x")] gets its own message instead of "has no [Key]". - A persisted property whose set accessor is private, protected or init, or a readonly field, is refused instead of CS0272 in generated code. - Overrides: MessagePack's DynamicObjectResolver reads the base declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a re-keyed override and serializes an override marked [IgnoreMember]). An override that changes them is refused in both paths; an override that repeats or omits them is accepted and byte-pinned. - Typed Serialize<T>/Deserialize<T> used static Options and skipped generated codecs and declared resolvers; deleted. CultNetDocumentBinding and CultMeshSnapshots route through the untyped path with their registry. DocumentShapeSweepTests compiles each shape through the generator and compares generated vs reflective schema id, catalog, name/index values and payload against MessagePack's own bytes, or equal rejection text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "payload-routed",
Revision = 5
Make generated and reflective document members one rule The generator and CultDocumentRegistry now discover members by the same steps: most-derived declaration first, override chain collapsed to one member, attributes read up the override chain, rejections in one order with one text (GCC001 / InvalidOperationException). - Accessors are named per document, so documents sharing a [CultName] or [CultIndex] base member no longer emit duplicate methods (CS0111). - Duplicate [Key] slots are refused, including new-hidden fields and properties; a hidden persisted member with a different slot is refused. - String [Key("x")] gets its own message instead of "has no [Key]". - A persisted property whose set accessor is private, protected or init, or a readonly field, is refused instead of CS0272 in generated code. - Overrides: MessagePack's DynamicObjectResolver reads the base declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a re-keyed override and serializes an override marked [IgnoreMember]). An override that changes them is refused in both paths; an override that repeats or omits them is accepted and byte-pinned. - Typed Serialize<T>/Deserialize<T> used static Options and skipped generated codecs and declared resolvers; deleted. CultNetDocumentBinding and CultMeshSnapshots route through the untyped path with their registry. DocumentShapeSweepTests compiles each shape through the generator and compares generated vs reflective schema id, catalog, name/index values and payload against MessagePack's own bytes, or equal rejection text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
}, typeof(NetworkSchemaNote))
}
};
var applied = await database.ApplyPutAsync(message);
Assert.That(applied, Is.TypeOf<NetworkSchemaNote>());
Assert.That(cache.Get<NetworkSchemaNote>(key)!.Text, Is.EqualTo("payload-routed"));
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(changes[0].Document!.Revision, Is.EqualTo(5));
}
[Test]
public async Task CultNetDatabaseServer_Creates_Filtered_SnapshotResponse()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "snapshot@example.test",
PasswordHash = "hash",
Username = "Snapshot"
};
var handle = await database.PutAsync(new CultRecordKey("player:snapshot"), player);
var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest(
"snapshot-request",
recordKeys: [handle.Key.Value]));
Assert.That(response.MessageId, Is.EqualTo("snapshot-request"));
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(handle.Key.Value));
Assert.That(response.Documents[0].Payload, Is.EqualTo(SerializePlayerDataPayload(player)));
}
[Test]
public async Task CultNetDatabaseServer_Creates_SnapshotResponse_ForCompatibleSchemaAlias()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var key = new CultRecordKey("network-note:alias-snapshot");
var note = new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "alias snapshot",
Revision = 7
};
await database.PutAsync(key, note);
var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest(
"snapshot-alias-request",
schemaIds: ["tests.networking_note.v1"],
recordKeys: [key.Value]));
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
Make generated and reflective document members one rule The generator and CultDocumentRegistry now discover members by the same steps: most-derived declaration first, override chain collapsed to one member, attributes read up the override chain, rejections in one order with one text (GCC001 / InvalidOperationException). - Accessors are named per document, so documents sharing a [CultName] or [CultIndex] base member no longer emit duplicate methods (CS0111). - Duplicate [Key] slots are refused, including new-hidden fields and properties; a hidden persisted member with a different slot is refused. - String [Key("x")] gets its own message instead of "has no [Key]". - A persisted property whose set accessor is private, protected or init, or a readonly field, is refused instead of CS0272 in generated code. - Overrides: MessagePack's DynamicObjectResolver reads the base declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a re-keyed override and serializes an override marked [IgnoreMember]). An override that changes them is refused in both paths; an override that repeats or omits them is accepted and byte-pinned. - Typed Serialize<T>/Deserialize<T> used static Options and skipped generated codecs and declared resolvers; deleted. CultNetDocumentBinding and CultMeshSnapshots route through the untyped path with their registry. DocumentShapeSweepTests compiles each shape through the generator and compares generated vs reflective schema id, catalog, name/index values and payload against MessagePack's own bytes, or equal rejection text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
[Test]
public async Task CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var shard = new CultNetShardDescriptor(
"network-notes",
"runtime-a",
epoch: 4,
isPrimary: true,
schemaIds: [descriptor.SchemaId],
keyPrefix: "network-note:",
primaryEndpoints: ["cultnet://runtime-a:3075"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
var key = new CultRecordKey("network-note:alias-shard-snapshot");
var note = new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "alias shard snapshot",
Revision = 8
};
await database.PutAsync(key, note);
var response = database.CreateShardSnapshotResponse(
shard,
"snapshot-shard-alias-request",
registry.CreateSnapshotRequest(
"snapshot-shard-alias-request",
schemaIds: ["tests.networking_note.v1"],
recordKeys: [key.Value]));
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
Make generated and reflective document members one rule The generator and CultDocumentRegistry now discover members by the same steps: most-derived declaration first, override chain collapsed to one member, attributes read up the override chain, rejections in one order with one text (GCC001 / InvalidOperationException). - Accessors are named per document, so documents sharing a [CultName] or [CultIndex] base member no longer emit duplicate methods (CS0111). - Duplicate [Key] slots are refused, including new-hidden fields and properties; a hidden persisted member with a different slot is refused. - String [Key("x")] gets its own message instead of "has no [Key]". - A persisted property whose set accessor is private, protected or init, or a readonly field, is refused instead of CS0272 in generated code. - Overrides: MessagePack's DynamicObjectResolver reads the base declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a re-keyed override and serializes an override marked [IgnoreMember]). An override that changes them is refused in both paths; an override that repeats or omits them is accepted and byte-pinned. - Typed Serialize<T>/Deserialize<T> used static Options and skipped generated codecs and declared resolvers; deleted. CultNetDocumentBinding and CultMeshSnapshots route through the untyped path with their registry. DocumentShapeSweepTests compiles each shape through the generator and compares generated vs reflective schema id, catalog, name/index values and payload against MessagePack's own bytes, or equal rejection text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
// S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): the shard snapshot answers
// through the same evaluator as the non-shard path, no loop of its own. An unfiltered request
// ({schemas:null, keys:null}) must return the same rows from both.
[Test]
public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var shard = new CultNetShardDescriptor(
"unfiltered-notes",
"runtime-a",
epoch: 1,
isPrimary: true,
schemaIds: [descriptor.SchemaId],
keyPrefix: "unfiltered-note:",
primaryEndpoints: ["cultnet://runtime-a:3080"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
await database.PutAsync(new CultRecordKey("unfiltered-note:one"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "one"
});
await database.PutAsync(new CultRecordKey("unfiltered-note:two"), new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "two"
});
// Outside the shard's key prefix (written to the cache directly: the database refuses a write no shard owns): must reach the non-shard snapshot but never the
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
// shard-scoped one, so an unfiltered request still proves shard membership is enforced.
await cache.UpsertAsync(new NetworkSchemaNote
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
{
Schema = "tests.networking_note.v1",
Text = "outside"
}, new CultRecordHandle<NetworkSchemaNote>(new CultRecordKey("other-note:outside-shard")));
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-unfiltered", filter: null);
var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-unfiltered", filter: null);
Assert.That(
shardScoped.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal),
Is.EqualTo(new[] { "unfiltered-note:one", "unfiltered-note:two" }));
Assert.That(
nonShard.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal),
Is.EqualTo(new[] { "other-note:outside-shard", "unfiltered-note:one", "unfiltered-note:two" }));
Assert.That(shardScoped.Documents, Has.Length.EqualTo(2));
}
// S2-3, second probe: an override of the binding schema id (rather than the descriptor's own)
// must match on both the shard and non-shard paths - both run binding-alias expansion.
[Test]
public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnBindingSchemaIdOverride()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var shard = new CultNetShardDescriptor(
"override-notes",
"runtime-a",
epoch: 1,
isPrimary: true,
schemaIds: [descriptor.SchemaId],
keyPrefix: "override-note:",
primaryEndpoints: ["cultnet://runtime-a:3081"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
var key = new CultRecordKey("override-note:one");
await database.PutAsync(key, new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "override"
});
var request = registry.CreateSnapshotRequest(
"snapshot-override-request",
schemaIds: ["tests.networking_note.v1"],
recordKeys: [key.Value]);
var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-override", request);
var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-override", request);
Assert.That(nonShard.Documents, Has.Length.EqualTo(1));
Assert.That(shardScoped.Documents, Has.Length.EqualTo(1));
Assert.That(shardScoped.Documents[0].RecordKey, Is.EqualTo(nonShard.Documents[0].RecordKey));
}
[Test]
public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"wrong-network-notes",
"runtime-b",
epoch: 1,
isPrimary: false,
schemaIds: ["other.note.v1"],
keyPrefix: "network-note:"),
new CultNetShardDescriptor(
"network-notes",
"runtime-a",
epoch: 2,
isPrimary: true,
schemaIds: ["tests.networking_note.v1"],
keyPrefix: "network-note:")
]
});
var key = new CultRecordKey("network-note:alias-routed-write");
await database.PutAsync(key, new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "typed write routed by alias",
Revision = 12
});
Assert.That(cache.Get<NetworkSchemaNote>(key)!.Text, Is.EqualTo("typed write routed by alias"));
Assert.That(database.GetMutationLog("network-notes"), Has.Count.EqualTo(1));
Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty);
}
2026-06-27 22:07:31 +01:00
[Test]
public async Task CultNetDatabase_Applies_Delete_BySchemaAlias()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"wrong-network-notes",
"runtime-b",
epoch: 1,
isPrimary: false,
schemaIds: ["other.note.v1"],
keyPrefix: "network-note:"),
new CultNetShardDescriptor(
"network-notes",
"runtime-a",
epoch: 2,
isPrimary: true,
schemaIds: ["tests.networking_note.v1"],
keyPrefix: "network-note:")
]
});
var key = new CultRecordKey("network-note:alias-delete");
await database.PutAsync(key, new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "delete through alias",
Revision = 13
});
await database.ApplyDeleteAsync(new CultNetDocumentDeleteMessage
{
MessageId = "delete-alias",
SchemaId = "tests.networking_note.v1",
RecordKey = key.Value,
ShardId = "network-notes",
ShardEpoch = 2
});
Assert.That(cache.Get<NetworkSchemaNote>(key), Is.Null);
Assert.That(database.GetMutationLog("network-notes").Last().Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed));
Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty);
}
[Test]
public async Task CultNetDatabase_Predicts_ClientOwnedInput_ThroughSchemaAliasScope()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "pilot-a",
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"wrong-network-inputs",
"server",
epoch: 1,
isPrimary: false,
schemaIds: ["other.note.v1"],
keyPrefix: "network-input:"),
new CultNetShardDescriptor(
"network-inputs",
"server",
epoch: 1,
isPrimary: false,
schemaIds: ["tests.networking_note.v1"],
keyPrefix: "network-input:")
],
ClientAuthorityScopes =
[
new CultNetClientAuthorityScope(
"pilot-a",
schemaIds: ["tests.networking_note.v1"],
keyPrefix: "network-input:pilot-a")
]
});
var key = new CultRecordKey("network-input:pilot-a:alias-prediction");
await database.PutPredictedAsync(key, new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "predicted through alias scope",
Revision = 1
});
Assert.That(cache.Get<NetworkSchemaNote>(key)!.Text, Is.EqualTo("predicted through alias scope"));
}
2026-05-19 17:34:49 +01:00
[Test]
public void CultNetDatabaseServer_Creates_Filtered_ShardCatalogResponse()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"players",
"runtime-a",
epoch: 3,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://runtime-a:3075"],
replicaEndpoints: ["cultnet://runtime-b:3075"],
readReplicaEndpoints: ["cultnet://edge-a:3075"],
region: "eu-west"),
new CultNetShardDescriptor(
"world",
"runtime-c",
epoch: 1,
isPrimary: false,
schemaIds: ["schema-world"],
keyPrefix: "world:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateShardCatalogResponse(new CultNetShardCatalogRequestMessage
{
MessageId = "catalog-players",
SchemaIds = [schemaId],
RecordKeys = ["player:one"]
});
Assert.That(response.MessageId, Is.EqualTo("catalog-players"));
Assert.That(response.Shards, Has.Length.EqualTo(1));
Assert.That(response.Shards[0].ShardId, Is.EqualTo("players"));
Assert.That(response.Shards[0].Epoch, Is.EqualTo(3));
Assert.That(response.Shards[0].PrimaryEndpoints, Is.EqualTo(["cultnet://runtime-a:3075"]));
Assert.That(response.Shards[0].ReadReplicaEndpoints, Is.EqualTo(["cultnet://edge-a:3075"]));
var aliasResponse = databaseServer.CreateShardCatalogResponse(new CultNetShardCatalogRequestMessage
{
MessageId = "catalog-players-alias",
SchemaIds = ["gamecult.player_data.v2"],
RecordKeys = ["player:one"]
});
Assert.That(aliasResponse.Shards, Has.Length.EqualTo(1));
Assert.That(aliasResponse.Shards[0].ShardId, Is.EqualTo("players"));
2026-05-19 17:34:49 +01:00
}
[Test]
public async Task CultNetDatabaseServer_Forwards_NonPrimaryWrites_WhenConfigured()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players-remote",
"runtime-owner",
epoch: 9,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://runtime-owner:3075"])
]
});
var forwarder = new CapturingShardWriteForwarder();
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(
server,
database,
new CultNetDatabaseServerOptions
{
ForwardNonPrimaryWrites = true,
WriteForwarder = forwarder
});
var key = new CultRecordKey("player:forward");
var message = registry.CreateRawDocumentPutMessage(
"put-forward",
new CultRecordHandle<PlayerData>(key),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "forward@example.test",
PasswordHash = "hash",
Username = "Forward"
});
var exception = Assert.ThrowsAsync<CultNetShardAuthorityException>(
async () => await database.ApplyPutAsync(message));
var forwarded = await databaseServer.TryForwardPutAsync(exception!, message);
Assert.That(forwarded, Is.True);
Assert.That(forwarder.PutCount, Is.EqualTo(1));
Assert.That(forwarder.LastPutShard!.ShardId, Is.EqualTo("players-remote"));
Assert.That(forwarder.LastPutMessage!.ShardId, Is.EqualTo("players-remote"));
Assert.That(forwarder.LastPutMessage.ShardEpoch, Is.EqualTo(9));
}
2026-05-19 17:47:43 +01:00
[Test]
public void CultNetSchemaWriteForwarder_Parses_CultNetEndpoints()
{
var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("cultnet://primary.example.test:4075");
Assert.That(parsed.Host, Is.EqualTo("primary.example.test"));
Assert.That(parsed.Port, Is.EqualTo(4075));
}
[Test]
public void CultNetSchemaWriteForwarder_Uses_DefaultPort()
{
var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("cultnet://primary.example.test");
Assert.That(parsed.Host, Is.EqualTo("primary.example.test"));
Assert.That(parsed.Port, Is.EqualTo(3075));
}
[Test]
public void CultNetSchemaWriteForwarder_Parses_RudpEndpoints()
{
var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("rudp://primary.example.test:5075");
Assert.That(parsed.Host, Is.EqualTo("primary.example.test"));
Assert.That(parsed.Port, Is.EqualTo(5075));
}
2026-05-19 17:47:43 +01:00
[Test]
public void CultNetSchemaWriteForwarder_Rejects_InvalidEndpoint()
{
Assert.That(
() => CultNetSchemaWriteForwarder.ParseEndpoint("http://primary.example.test:3075"),
Throws.TypeOf<FormatException>());
}
[Test]
public void CultNetSchemaClients_CreateForEndpoint_Selects_RudpClient()
{
using var rudp = CultNetSchemaClients.CreateForEndpoint("rudp://127.0.0.1:5075");
using var liteNetLib = CultNetSchemaClients.CreateForEndpoint(
"cultnet://127.0.0.1:3075",
DevelopmentClientSecurity);
Assert.That(rudp, Is.TypeOf<RudpCultNetSchemaClient>());
Assert.That(liteNetLib, Is.TypeOf<LiteNetLibCultNetSchemaClient>());
}
[Test]
public async Task RudpCultNetSchemaClient_Carries_SchemaMessages()
{
using var serverSocket = BindUdpSocket();
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-schema-server",
Mode = CultNetRudpSocketMode.Server,
Socket = serverSocket,
ConnectionId = 0x43554c54,
InitialSequence = 100,
TransportId = "schema-rudp",
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var serverThread = new Thread(() =>
{
try
{
while (!serverDone.Task.IsCompleted)
{
var request = server.ReceiveSchemaMessageOnce<CultNetSchemaCatalogRequestMessage>();
server.PollResends();
if (request == null)
{
Thread.Sleep(5);
continue;
}
server.SendSchemaMessage(new CultNetSchemaCatalogResponseMessage
{
MessageId = request.MessageId,
Schemas =
[
new CultNetSchemaDescriptor
{
SchemaId = "rudp.schema.test",
Kind = "wire_message",
SchemaVersion = "rudp.schema.test.v0",
WireContracts = ["cultnet.schema.v0"]
}
]
});
serverDone.TrySetResult();
return;
}
}
catch (Exception error)
{
serverDone.TrySetException(error);
}
})
{
IsBackground = true
};
serverThread.Start();
using var client = CultNetSchemaClients.CreateRudp(runtimeId: "csharp-rudp-schema-client");
var responseCompletion = new TaskCompletionSource<CultNetSchemaCatalogResponseMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(response => responseCompletion.TrySetResult(response));
client.Connect("127.0.0.1", ((IPEndPoint)serverSocket.LocalEndPoint!).Port);
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
client.SendCultNet(new CultNetSchemaCatalogRequestMessage
{
MessageId = "rudp-schema-client-test",
IncludeSchemaJson = false
});
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2));
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
serverThread.Join();
Assert.That(response.MessageId, Is.EqualTo("rudp-schema-client-test"));
Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.test"));
}
[Test]
public async Task CultMeshVerseDiscoveryClient_Fetches_Over_RudpEndpoint()
{
using var serverSocket = BindUdpSocket();
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-verse-discovery-server",
Mode = CultNetRudpSocketMode.Server,
Socket = serverSocket,
ConnectionId = 0x43554c54,
InitialSequence = 100,
TransportId = "schema-rudp",
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var serverThread = new Thread(() =>
{
try
{
while (!serverDone.Task.IsCompleted)
{
var request = server.ReceiveSchemaMessageOnce<CultMeshVerseCatalogRequestMessage>();
server.PollResends();
if (request == null)
{
Thread.Sleep(5);
continue;
}
server.SendSchemaMessage(new CultMeshVerseCatalogResponseMessage
{
MessageId = request.MessageId,
Verses =
[
new CultMeshVerseDescriptor(
"rudp-verse",
"RUDP Verse",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
discoveryEndpoints: ["rudp://127.0.0.1"]).ToMessage()
]
});
serverDone.TrySetResult();
return;
}
}
catch (Exception error)
{
serverDone.TrySetException(error);
}
})
{
IsBackground = true
};
serverThread.Start();
var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions
{
ConnectTimeout = TimeSpan.FromSeconds(2),
ResponseTimeout = TimeSpan.FromSeconds(2)
});
var response = await client.FetchAsync($"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}");
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
serverThread.Join();
Assert.That(response.Verses.Single().VerseId, Is.EqualTo("rudp-verse"));
}
[Test]
public async Task CultMeshPeerExchangeClient_Fetches_Over_RudpEndpoint()
{
using var serverSocket = BindUdpSocket();
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-peer-exchange-server",
Mode = CultNetRudpSocketMode.Server,
Socket = serverSocket,
ConnectionId = 0x43554c54,
InitialSequence = 100,
TransportId = "schema-rudp",
ResendDelayMs = 25,
MaxFragmentBytes = 1024
});
var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var serverThread = new Thread(() =>
{
try
{
while (!serverDone.Task.IsCompleted)
{
var request = server.ReceiveSchemaMessageOnce<CultMeshPeerExchangeRequestMessage>();
server.PollResends();
if (request == null)
{
Thread.Sleep(5);
continue;
}
server.SendSchemaMessage(new CultMeshPeerExchangeResponseMessage
{
MessageId = request.MessageId,
Peers =
[
new CultMeshPeerCard(
"rudp-peer",
request.VerseId,
["rudp://127.0.0.1"],
roles: ["mesh-peer"]).ToMessage()
]
});
serverDone.TrySetResult();
return;
}
}
catch (Exception error)
{
serverDone.TrySetException(error);
}
})
{
IsBackground = true
};
serverThread.Start();
var client = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions
{
ConnectTimeout = TimeSpan.FromSeconds(2),
ResponseTimeout = TimeSpan.FromSeconds(2)
});
var response = await client.FetchAsync(
$"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}",
new CultMeshPeerExchangeRequestMessage { VerseId = "local" });
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
serverThread.Join();
Assert.That(response.Peers.Single().PeerId, Is.EqualTo("rudp-peer"));
}
2026-05-19 19:08:48 +01:00
[Test]
public void CultMesh_CreateClient_Returns_CultNetClient()
{
using var client = CultMesh.CreateClient(DevelopmentClientSecurity);
Assert.That(client, Is.Not.Null);
Assert.That(client.Connected, Is.False);
}
2026-05-20 01:35:39 +01:00
[Test]
public void CultMesh_CreateVerseDiscoveryClient_Returns_DiscoveryClient()
{
var client = CultMesh.CreateVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions
{
ConnectTimeout = TimeSpan.FromMilliseconds(250),
ResponseTimeout = TimeSpan.FromMilliseconds(250)
});
Assert.That(client, Is.Not.Null);
}
2026-05-20 01:41:12 +01:00
[Test]
public void CultMesh_CreatePeerExchangeClient_Returns_ExchangeClient()
{
var client = CultMesh.CreatePeerExchangeClient(new CultMeshPeerExchangeClientOptions
{
ConnectTimeout = TimeSpan.FromMilliseconds(250),
ResponseTimeout = TimeSpan.FromMilliseconds(250)
});
Assert.That(client, Is.Not.Null);
}
2026-06-16 14:22:41 +01:00
[Test]
public async Task CultMeshVerseDiscoveryClient_Uses_SchemaClientPort()
{
var fake = new CapturingSchemaClient(message =>
{
var request = (CultMeshVerseCatalogRequestMessage)message;
return new CultMeshVerseCatalogResponseMessage
{
MessageId = request.MessageId,
Verses =
[
new CultMeshVerseDescriptor(
"local",
"Local Verse",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
discoveryEndpoints: ["cultnet://mesh.example.test:4075"]).ToMessage()
]
};
});
var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions
{
CreateClient = () => fake
});
var response = await client.FetchAsync("cultnet://mesh.example.test:4075");
Assert.That(fake.ConnectedHost, Is.EqualTo("mesh.example.test"));
Assert.That(fake.ConnectedPort, Is.EqualTo(4075));
Assert.That(fake.SentMessages.Single(), Is.TypeOf<CultMeshVerseCatalogRequestMessage>());
Assert.That(response.Verses.Single().VerseId, Is.EqualTo("local"));
}
[Test]
public async Task CultMeshVerseDiscoveryClient_ReportsDeterministicConnectionFailureTimeline()
{
var clock = new ManualCultMeshClock(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero));
var diagnostics = new CultMeshDiagnosticBuffer();
var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions
{
CreateClient = () => new NeverConnectedSchemaClient(),
Clock = clock,
Diagnostics = diagnostics,
SourceId = "test-bootstrap",
ConnectTimeout = TimeSpan.FromMilliseconds(50)
});
var error = Assert.ThrowsAsync<TimeoutException>(() =>
client.FetchAsync("rudp://unavailable.example.test:4075"));
Assert.That(error!.Message, Does.Contain("unavailable.example.test"));
var timeline = diagnostics.Snapshot();
Assert.That(timeline.Select(item => item.Kind), Is.EqualTo(new[]
{
CultMeshDiagnosticKind.ConnectionAttempt,
CultMeshDiagnosticKind.CandidateRejected
}));
Assert.That(timeline[0].ObservedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero)));
Assert.That(timeline[1].ObservedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 7, 12, 18, 0, 0, 50, TimeSpan.Zero)));
Assert.That(timeline[1].State, Is.EqualTo("unavailable"));
Assert.That(timeline[1].ReasonCode, Is.EqualTo("timeout"));
Assert.That(timeline[1].SourceId, Is.EqualTo("test-bootstrap"));
Assert.That(timeline[1].LibraryVersion, Is.Not.Empty);
}
[Test]
public async Task CultMeshVerseDiscoveryClient_CompatibilityDiscoveryKeepsGoodConcurrentSource()
{
var clients = new ConcurrentQueue<ICultNetSchemaClient>();
clients.Enqueue(new NeverConnectedSchemaClient());
clients.Enqueue(new CapturingSchemaClient(message =>
{
var request = (CultMeshVerseCatalogRequestMessage)message;
return new CultMeshVerseCatalogResponseMessage
{
MessageId = request.MessageId,
Verses = [new CultMeshVerseDescriptor(
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
discoveryEndpoints: ["rudp://aetheria:3076"]).ToMessage()]
};
}));
var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions
{
CreateClient = () => clients.TryDequeue(out var next) ? next : throw new InvalidOperationException("Unexpected client."),
Clock = new ManualCultMeshClock(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero)),
ConnectTimeout = TimeSpan.FromMilliseconds(50)
});
using var catalog = new CultMeshVerseCatalog();
var count = await client.DiscoverAsync(
catalog,
new[] { "rudp://dead.example.test:4075", "rudp://good.example.test:4075" });
Assert.That(count, Is.EqualTo(1));
Assert.That(catalog.Verses.Single().VerseId, Is.EqualTo("aetheria"));
}
2026-06-16 14:22:41 +01:00
[Test]
public async Task CultMeshPeerExchangeClient_Uses_SchemaClientPort()
{
var fake = new CapturingSchemaClient(message =>
{
var request = (CultMeshPeerExchangeRequestMessage)message;
return new CultMeshPeerExchangeResponseMessage
{
MessageId = request.MessageId,
Peers =
[
new CultMeshPeerCard(
"peer-a",
request.VerseId,
["cultnet://peer-a.example.test:3075"],
roles: ["shard-primary"]).ToMessage()
]
};
});
var client = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions
{
CreateClient = () => fake
});
var response = await client.FetchAsync(
"cultnet://mesh.example.test:4075",
new CultMeshPeerExchangeRequestMessage { VerseId = "local" });
Assert.That(fake.ConnectedHost, Is.EqualTo("mesh.example.test"));
Assert.That(fake.SentMessages.Single(), Is.TypeOf<CultMeshPeerExchangeRequestMessage>());
Assert.That(response.Peers.Single().PeerId, Is.EqualTo("peer-a"));
}
[Test]
public async Task CultNetShardFetchers_Use_SchemaClientPort()
{
var logClient = new CapturingSchemaClient(message =>
{
var request = (CultNetShardLogRequestMessage)message;
return new CultNetShardLogResponseMessage
{
MessageId = request.MessageId,
ShardId = request.ShardId,
ShardEpoch = request.ShardEpoch ?? 0,
Entries = []
};
});
var snapshotClient = new CapturingSchemaClient(message =>
{
var request = (CultNetSnapshotRequestMessage)message;
return new CultNetSnapshotResponseRawMessage
{
MessageId = request.MessageId,
ShardId = request.ShardId,
ShardEpoch = request.ShardEpoch,
Documents = []
};
});
var shard = new CultNetShardDescriptor(
"notes",
"primary",
7,
isPrimary: false,
schemaIds: ["note.v0"],
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
var logFetcher = new CultNetSchemaShardLogFetcher(new CultNetSchemaShardLogFetcherOptions
{
CreateClient = () => logClient
});
var snapshotFetcher = new CultNetSchemaShardSnapshotFetcher(new CultNetSchemaShardSnapshotFetcherOptions
{
CreateClient = () => snapshotClient
});
var log = await logFetcher.FetchAsync(shard, afterSequence: 4, limit: 2);
var snapshot = await snapshotFetcher.FetchAsync(shard);
Assert.That(logClient.ConnectedHost, Is.EqualTo("primary.example.test"));
Assert.That(logClient.SentMessages.Single(), Is.TypeOf<CultNetShardLogRequestMessage>());
Assert.That(log.ShardId, Is.EqualTo("notes"));
Assert.That(snapshotClient.SentMessages.Single(), Is.TypeOf<CultNetSnapshotRequestMessage>());
Assert.That(snapshot.ShardEpoch, Is.EqualTo(7));
}
[Test]
public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort()
{
var fake = new CapturingSchemaClient(_ => null);
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
{
CreateClient = () => fake
});
var shard = new CultNetShardDescriptor(
"notes",
"primary",
3,
isPrimary: false,
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage
{
SchemaId = "note.v0",
RecordKey = "note:1"
});
var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single();
Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test"));
Assert.That(sent.ShardId, Is.EqualTo("notes"));
Assert.That(sent.ShardEpoch, Is.EqualTo(3));
}
2026-05-19 19:08:48 +01:00
[Test]
public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets()
{
var vanillaRules = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "rules:v1", "vanilla");
var moddedRules = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "rules:v1", "skylands");
var aetheria = new CultMeshVerseDescriptor(
"aetheria-main",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", vanillaRules),
authorityRoutes:
[
new CultMeshAuthorityRoute(
"gc-us-east",
"cultmesh://us-east.aetheria.example.test:3075",
[CultMeshProtocols.Documents.Value],
generation: "east-1"),
new CultMeshAuthorityRoute(
"gc-eu-west",
"cultmesh://eu-west.aetheria.example.test:3075",
[CultMeshProtocols.Documents.Value],
generation: "west-1")
]);
2026-05-19 19:08:48 +01:00
var modded = new CultMeshVerseDescriptor(
"aetheria-skylands",
"Aetheria: Skylands",
CultMeshVerseAuthorityModel.SubscribedOverlay,
new CultMeshVerseCompatibility(
"cultmesh.v0",
moddedRules,
compatibleVerseIds: ["aetheria-main"],
requiredPluginIds: ["skylands"]),
parentVerseId: "aetheria-main");
var incompatible = new CultMeshVerseDescriptor(
"other-game",
"Other Game",
CultMeshVerseAuthorityModel.PeerToPeer,
new CultMeshVerseCompatibility("cultmesh.v0", CultMeshVerseDescriptor.ComputeRulesHash("other")));
using var catalog = CultMesh.CreateVerseCatalog();
var updates = new List<CultMeshVerseDescriptor>();
using var subscription = catalog.Watch().Subscribe(update => updates.Add(update));
catalog.Upsert(aetheria);
catalog.Upsert(modded);
catalog.Upsert(incompatible);
var targets = catalog.FindTransferTargets(aetheria);
Assert.That(updates, Has.Count.EqualTo(3));
Assert.That(targets, Has.Count.EqualTo(1));
Assert.That(targets[0].VerseId, Is.EqualTo("aetheria-skylands"));
Assert.That(modded.CanTransferFrom(aetheria), Is.True);
Assert.That(incompatible.CanTransferFrom(aetheria), Is.False);
}
2026-05-19 21:23:11 +01:00
[Test]
public void CultMeshVerseDiscoveryServer_Creates_FilteredCatalogResponse()
{
using var catalog = CultMesh.CreateVerseCatalog();
var rulesHash = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "vanilla");
catalog.Upsert(new CultMeshVerseDescriptor(
"aetheria-main",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", rulesHash),
discoveryEndpoints: ["cultmesh://aetheria.example.test:3075"],
authorityRuntimeIds: ["runtime-a"]));
catalog.Upsert(new CultMeshVerseDescriptor(
"old-branch",
"Old Branch",
CultMeshVerseAuthorityModel.PeerToPeer,
new CultMeshVerseCompatibility("cultmesh.legacy", rulesHash)));
using var server = new Server(new CultCache(), DevelopmentServerSecurity);
using var discovery = new CultMeshVerseDiscoveryServer(server, catalog);
var response = discovery.CreateResponse(new CultMeshVerseCatalogRequestMessage
{
MessageId = "discover-1",
TransportVersion = "cultmesh.v0"
});
Assert.That(response.MessageId, Is.EqualTo("discover-1"));
Assert.That(response.Verses, Has.Length.EqualTo(1));
Assert.That(response.Verses[0].VerseId, Is.EqualTo("aetheria-main"));
Assert.That(response.Verses[0].DiscoveryEndpoints, Is.EqualTo(["cultmesh://aetheria.example.test:3075"]));
}
[Test]
public void CultMeshVerseCatalog_Upserts_WireDiscoveryResponse()
{
using var catalog = CultMesh.CreateVerseCatalog();
var updates = new List<CultMeshVerseDescriptor>();
using var subscription = catalog.Watch().Subscribe(updates.Add);
catalog.Upsert(new CultMeshVerseCatalogResponseMessage
{
MessageId = "verses-apply",
Verses =
[
new CultMeshVerseDescriptorMessage
{
VerseId = "aetheria-branch",
DisplayName = "Aetheria Branch",
AuthorityModel = nameof(CultMeshVerseAuthorityModel.SubscribedOverlay),
Compatibility = new CultMeshVerseCompatibilityMessage
{
TransportVersion = "cultmesh.v0",
RulesHash = "branch-rules",
CompatibleVerseIds = ["aetheria-main"]
},
ParentVerseId = "aetheria-main"
}
]
});
var verse = catalog.Get("aetheria-branch");
Assert.That(verse, Is.Not.Null);
Assert.That(verse!.AuthorityModel, Is.EqualTo(CultMeshVerseAuthorityModel.SubscribedOverlay));
Assert.That(verse.ParentVerseId, Is.EqualTo("aetheria-main"));
Assert.That(updates, Has.Count.EqualTo(1));
}
2026-05-20 01:35:39 +01:00
[Test]
public void CultMeshPeerExchangeServer_Creates_FilteredPeerResponse()
{
using var catalog = CultMesh.CreatePeerCatalog();
catalog.Upsert(new CultMeshPeerCard(
"peer-primary",
"aetheria-main",
["cultnet://primary.example.test:3075"],
roles: [CultMeshPeerRoles.ShardPrimary],
shardIds: ["players"],
region: "us-east",
authorityLeaseId: "lease-primary"));
catalog.Upsert(new CultMeshPeerCard(
"peer-read",
"aetheria-main",
["cultnet://read.example.test:3075"],
roles: [CultMeshPeerRoles.ReadReplica],
shardIds: ["players"],
region: "eu-west"));
using var server = new Server(new CultCache(), DevelopmentServerSecurity);
using var exchange = new CultMeshPeerExchangeServer(server, catalog);
var response = exchange.CreateResponse(new CultMeshPeerExchangeRequestMessage
{
MessageId = "pex-filter",
VerseId = "aetheria-main",
Roles = [CultMeshPeerRoles.ReadReplica],
KnownPeerIds = ["already-known"]
});
Assert.That(response.MessageId, Is.EqualTo("pex-filter"));
Assert.That(response.Peers, Has.Length.EqualTo(1));
Assert.That(response.Peers[0].PeerId, Is.EqualTo("peer-read"));
Assert.That(response.Peers[0].Roles, Does.Contain(CultMeshPeerRoles.ReadReplica));
}
[Test]
public void CultMeshPeerCatalog_Upserts_WirePeerExchangeResponse()
{
using var catalog = CultMesh.CreatePeerCatalog();
var updates = new List<CultMeshPeerCard>();
using var subscription = catalog.Watch().Subscribe(updates.Add);
catalog.Upsert(new CultMeshPeerExchangeResponseMessage
{
MessageId = "pex-apply",
Peers =
[
new CultMeshPeerCardMessage
{
PeerId = "peer-observer",
VerseId = "aetheria-main",
Endpoints = ["cultnet://observer.example.test:3075"],
Roles = [CultMeshPeerRoles.SimulationObserver],
ShardIds = ["arena"]
}
]
});
var peers = catalog.Find("aetheria-main", CultMeshPeerRoles.SimulationObserver);
Assert.That(peers, Has.Count.EqualTo(1));
Assert.That(peers[0].PeerId, Is.EqualTo("peer-observer"));
Assert.That(updates, Has.Count.EqualTo(1));
}
2026-05-20 01:49:18 +01:00
[Test]
2026-07-18 22:34:22 +01:00
public void CultMeshAuthorityResolver_Authorizes_PeerRoleAndShard()
2026-05-20 01:49:18 +01:00
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00.0000000Z");
var catalog = CultMesh.CreateAuthorityLeaseCatalog();
2026-07-18 22:34:22 +01:00
var resolver = new CultMeshAuthorityResolver(
catalog,
new AcceptingAuthoritySignatureVerifier(),
new NoAuthorityRevocations(),
new ManualCultMeshClock(now));
2026-06-16 16:57:35 +01:00
using var peers = CultMesh.CreatePeerCatalog();
2026-05-20 01:49:18 +01:00
var peer = new CultMeshPeerCard(
"peer-primary",
"aetheria-main",
["cultnet://primary.example.test:3075"],
roles: [CultMeshPeerRoles.ShardPrimary],
shardIds: ["players-us-east"],
authorityLeaseId: "lease-primary");
2026-06-16 16:57:35 +01:00
peers.Upsert(peer);
2026-07-18 22:34:22 +01:00
Assert.That(peers.FindAuthorized(
"aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east"), Is.Empty);
2026-05-20 01:49:18 +01:00
catalog.Upsert(new CultMeshAuthorityLease(
"lease-primary",
"aetheria-main",
"peer-primary",
[CultMeshPeerRoles.ShardPrimary],
["players-us-east"],
"gc-operator",
now.AddMinutes(-5),
now.AddMinutes(5),
2026-07-18 22:34:22 +01:00
signature: "sig",
authorityEpoch: 1));
Assert.That(resolver.Resolve(new CultMeshAuthorityRequest(
peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", 1)).IsAuthorized, Is.True);
Assert.That(peers.FindAuthorized(
"aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east").Single(), Is.SameAs(peer));
Assert.That(peers.FirstAuthorized(
"aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east"), Is.SameAs(peer));
Assert.That(peers.FirstAuthorized(
"aetheria-main", CultMeshPeerRoles.ReadReplica, resolver, 1, "players-us-east"), Is.Null);
Assert.That(resolver.Resolve(new CultMeshAuthorityRequest(
peer, CultMeshPeerRoles.ShardPrimary, "players-eu", 1)).IsAuthorized, Is.False);
Assert.That(resolver.Resolve(new CultMeshAuthorityRequest(
peer, CultMeshPeerRoles.ReadReplica, "players-us-east", 1)).IsAuthorized, Is.False);
#pragma warning disable CS0618
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now), Is.False);
#pragma warning restore CS0618
2026-05-20 01:49:18 +01:00
}
private static string? RudpClientBoundHost(string endpoint, string? bindHost = null)
{
using var client = CultMesh.CreateRudpClient(
"csharp-cultmesh-bind-client",
0x10203060u,
endpoint,
new CultMeshRudpSocketOptions { BindHost = bindHost });
return client.Profile.Transports[0].Host;
}
// A client bound to loopback cannot send to another host on Windows, so a client for a
// remote endpoint binds the unspecified address of its family.
[Test]
public void CultMeshRudpClient_ForARemoteEndpoint_BindsTheUnspecifiedAddress()
{
Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872"), Is.EqualTo("0.0.0.0"));
Assert.That(RudpClientBoundHost("rudp://[2001:db8::1]:17872"), Is.EqualTo("::"));
}
[Test]
public void CultMeshRudpClient_ForALoopbackEndpoint_BindsLoopback()
{
Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872"), Is.EqualTo("127.0.0.1"));
Assert.That(RudpClientBoundHost("rudp://[::1]:17872"), Is.EqualTo("::1"));
}
[Test]
public void CultMeshRudpClient_BindsAnExplicitBindHostWhateverTheEndpoint()
{
Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1"), Is.EqualTo("127.0.0.1"));
Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0"), Is.EqualTo("0.0.0.0"));
}
[Test]
public void CultMeshRudpServer_BindsEveryIPv4InterfaceUnlessToldOtherwise()
{
using var anywhere = CultMesh.CreateRudpServer("csharp-cultmesh-bind-server", 0x10203061u);
using var loopback = CultMesh.CreateRudpServer(
"csharp-cultmesh-bind-server",
0x10203062u,
new CultMeshRudpSocketOptions { BindHost = "127.0.0.1" });
Assert.That(anywhere.Profile.Transports[0].Host, Is.EqualTo("0.0.0.0"));
Assert.That(loopback.Profile.Transports[0].Host, Is.EqualTo("127.0.0.1"));
}
2026-06-16 16:57:35 +01:00
[Test]
public void CultMeshRudpFacade_CreatesAuthorizedClientFromPeerEndpoint()
{
var connectionId = 0x10203045u;
using var server = CultMesh.CreateRudpServer(
"csharp-cultmesh-rudp-server",
connectionId,
new CultMeshRudpSocketOptions
{
InitialSequence = 100,
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 16
});
var serverPort = server.Profile.Transports[0].Port;
Assert.That(serverPort, Is.Not.Null);
var endpoint = CultMesh.ParseRudpEndpoint($"rudp://127.0.0.1:{serverPort}");
Assert.That(endpoint.Host, Is.EqualTo("127.0.0.1"));
Assert.That(endpoint.Port, Is.EqualTo(serverPort));
using var peers = CultMesh.CreatePeerCatalog();
var leases = CultMesh.CreateAuthorityLeaseCatalog();
var now = DateTimeOffset.UtcNow;
2026-07-18 22:34:22 +01:00
var authority = new CultMeshAuthorityResolver(
leases,
new AcceptingAuthoritySignatureVerifier(),
new NoAuthorityRevocations(),
new ManualCultMeshClock(now));
2026-06-16 16:57:35 +01:00
var peer = new CultMeshPeerCard(
"csharp-cultmesh-rudp-server",
"local",
[endpoint.Uri],
roles: ["schema"],
authorityLeaseId: "lease:csharp-cultmesh-rudp-server");
peers.Upsert(peer);
Assert.Throws<InvalidOperationException>(() => CultMesh.CreateRudpClientForAuthorizedPeer(
"csharp-cultmesh-rudp-client",
connectionId,
peers,
2026-07-18 22:34:22 +01:00
authority,
1,
2026-06-16 16:57:35 +01:00
"local",
"schema",
options: new CultMeshRudpSocketOptions
{
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 16
}));
leases.Upsert(new CultMeshAuthorityLease(
"lease:csharp-cultmesh-rudp-server",
"local",
"csharp-cultmesh-rudp-server",
["schema"],
[],
"csharp-authority",
now.AddSeconds(-1),
2026-07-18 22:34:22 +01:00
now.AddSeconds(30),
signature: "sig",
authorityEpoch: 1));
2026-06-16 16:57:35 +01:00
2026-06-17 05:16:46 +01:00
using var serverPumpCts = new CancellationTokenSource();
var serverPump = Task.Run(() =>
{
while (!server.Connected && !serverPumpCts.IsCancellationRequested)
{
_ = server.ReceiveOnce();
server.PollResends();
Thread.Sleep(TimeSpan.FromMilliseconds(5));
}
});
using var client = CultMesh.ConnectRudpClientForAuthorizedPeer(
2026-06-16 16:57:35 +01:00
"csharp-cultmesh-rudp-client",
connectionId,
peers,
2026-07-18 22:34:22 +01:00
authority,
1,
2026-06-16 16:57:35 +01:00
"local",
"schema",
2026-06-17 05:16:46 +01:00
options: new CultMeshRudpClientOptions
2026-06-16 16:57:35 +01:00
{
2026-06-17 05:16:46 +01:00
ConnectPayload = Encoding.UTF8.GetBytes("join"),
SocketOptions = new CultMeshRudpSocketOptions
{
ResendDelayMs = 25,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 16
}
2026-06-16 16:57:35 +01:00
});
2026-06-17 05:16:46 +01:00
serverPumpCts.Cancel();
Assert.That(serverPump.Wait(TimeSpan.FromSeconds(1)), Is.True);
2026-06-16 16:57:35 +01:00
Assert.That(client.Connected, Is.True);
Assert.That(server.Connected, Is.True);
2026-06-17 05:16:46 +01:00
client.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage
{
MessageId = "csharp-cultmesh-rudp-schema-catalog",
Kinds = ["document_payload"],
IncludeSchemaJson = true
});
var request = ReceiveRudpSchemaMessage<CultNetSchemaCatalogRequestMessage>(server);
Assert.That(request.MessageId, Is.EqualTo("csharp-cultmesh-rudp-schema-catalog"));
Assert.That(request.Kinds, Is.EqualTo(new[] { "document_payload" }));
2026-06-16 16:57:35 +01:00
Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
Assert.That(client.Profile.Transports[0].Protocol, Is.EqualTo("rudp"));
}
[Test]
public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-node", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
var cachePath = Path.Combine(rootPath, "world.ccmp");
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var options = new CultMeshNodeOptions
{
StartServer = false,
EnableDurableShardLogs = true,
DatabaseOptions = new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players-mesh-default-log",
"mesh-runtime",
epoch: 1,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
}
};
var key = new CultRecordKey("player:mesh-log");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "mesh-log@example.test",
PasswordHash = "hash",
Username = "MeshLog"
};
using (var node = await CultMesh.CreateNodeAsync(cachePath, options))
{
await node.Database.PutAsync(key, player);
}
using var restarted = await CultMesh.CreateNodeAsync(cachePath, options);
var response = restarted.DatabaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-mesh-default-log"
});
Assert.That(response.Entries, Has.Length.EqualTo(1));
Assert.That(response.Entries[0].Put, Is.Not.Null);
Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo(key.Value));
Assert.That(Directory.Exists(Path.Combine(rootPath, "world.cultmesh", "shard-logs")), Is.True);
}
[Test]
public async Task CultMeshNode_CanRoundTrip_DurableTypedDocument_Through_PublicDatabaseSurface()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-quickstart", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
var cachePath = Path.Combine(rootPath, "world.ccmp");
var documents = new CultNetDocumentRegistry()
.Register(CultNetDocumentBinding.ForDocument<MeshQuickstartNote>());
var key = new CultRecordKey("note:intro");
using (var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
DatabaseOptions = new CultNetDatabaseOptions
{
RuntimeId = "quickstart-local",
DocumentRegistry = documents
}
}))
{
await node.Database.PutAsync(key, new MeshQuickstartNote
{
NoteId = key.Value,
Body = "hello from a durable CultMesh node"
});
var live = await node.Database.GetAsync<MeshQuickstartNote>(key);
Assert.That(node.Store, Is.Not.Null);
Assert.That(live, Is.Not.Null);
Assert.That(live!.Body, Is.EqualTo("hello from a durable CultMesh node"));
await node.FlushAsync();
}
using var reopened = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
DatabaseOptions = new CultNetDatabaseOptions
{
RuntimeId = "quickstart-local",
DocumentRegistry = documents
}
});
var stored = await reopened.Database.GetAsync<MeshQuickstartNote>(key);
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.NoteId, Is.EqualTo(key.Value));
Assert.That(stored.Body, Is.EqualTo("hello from a durable CultMesh node"));
}
2026-05-19 17:52:29 +01:00
[Test]
public async Task CultNetDatabase_Appends_PerShardMutationLog()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"players-log",
"runtime-a",
epoch: 4,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
var key = new CultRecordKey("player:log");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "log@example.test",
PasswordHash = "hash",
Username = "Log"
};
await database.PutAsync(key, player);
player.Username = "LogUpdated";
await database.PutAsync(key, player);
await database.DeleteAsync<PlayerData>(key);
var entries = database.GetMutationLog("players-log");
Assert.That(entries, Has.Count.EqualTo(3));
Assert.That(new[] { entries[0].Sequence, entries[1].Sequence, entries[2].Sequence }, Is.EqualTo([1, 2, 3]));
Assert.That(entries[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
Assert.That(entries[1].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Updated));
Assert.That(entries[2].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed));
Assert.That(entries[0].ShardEpoch, Is.EqualTo(4));
}
[Test]
public async Task CultNetDatabase_MutationLog_CanCatchUpAfterSequence()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"players-catchup",
"runtime-a",
epoch: 1,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
await database.PutAsync(
new CultRecordKey("player:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "one@example.test", PasswordHash = "hash", Username = "One" });
await database.PutAsync(
new CultRecordKey("player:two"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "two@example.test", PasswordHash = "hash", Username = "Two" });
var entries = database.GetMutationLog("players-catchup", afterSequence: 1);
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].Sequence, Is.EqualTo(2));
Assert.That(entries[0].Key.Value, Is.EqualTo("player:two"));
}
2026-05-19 18:01:11 +01:00
[Test]
public async Task CultNetDatabaseServer_Creates_ShardLogResponse_AfterSequence()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players-wire-log",
"runtime-a",
epoch: 5,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var first = new PlayerData { PlayerId = Guid.NewGuid(), Email = "one@example.test", PasswordHash = "hash", Username = "One" };
var second = new PlayerData { PlayerId = Guid.NewGuid(), Email = "two@example.test", PasswordHash = "hash", Username = "Two" };
await database.PutAsync(new CultRecordKey("player:one"), first);
await database.PutAsync(new CultRecordKey("player:two"), second);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
MessageId = "wire-log",
ShardId = "players-wire-log",
ShardEpoch = 5,
AfterSequence = 1
});
Assert.That(response.MessageId, Is.EqualTo("wire-log"));
Assert.That(response.ShardId, Is.EqualTo("players-wire-log"));
Assert.That(response.ShardEpoch, Is.EqualTo(5));
Assert.That(response.ResyncRequired, Is.False);
Assert.That(response.Entries, Has.Length.EqualTo(1));
Assert.That(response.Entries[0].Sequence, Is.EqualTo(2));
Assert.That(response.Entries[0].ChangeKind, Is.EqualTo("added"));
Assert.That(response.Entries[0].Put, Is.Not.Null);
Assert.That(response.Entries[0].Put!.ShardId, Is.EqualTo("players-wire-log"));
Assert.That(response.Entries[0].Put!.ShardEpoch, Is.EqualTo(5));
Assert.That(response.Entries[0].Put!.Document.Payload, Is.EqualTo(SerializePlayerDataPayload(second)));
}
[Test]
public async Task CultNetDatabaseServer_Creates_ShardLogDeleteEntry()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players-wire-delete",
"runtime-a",
epoch: 6,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var key = new CultRecordKey("player:delete-log");
await database.PutAsync(
key,
new PlayerData { PlayerId = Guid.NewGuid(), Email = "delete@example.test", PasswordHash = "hash", Username = "Delete" });
await database.DeleteAsync<PlayerData>(key);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-wire-delete",
AfterSequence = 1
});
Assert.That(response.Entries, Has.Length.EqualTo(1));
Assert.That(response.Entries[0].Sequence, Is.EqualTo(2));
Assert.That(response.Entries[0].ChangeKind, Is.EqualTo("removed"));
Assert.That(response.Entries[0].Put, Is.Null);
Assert.That(response.Entries[0].Delete, Is.Not.Null);
Assert.That(response.Entries[0].Delete!.SchemaId, Is.EqualTo(schemaId));
Assert.That(response.Entries[0].Delete!.RecordKey, Is.EqualTo(key.Value));
Assert.That(response.Entries[0].Delete!.ShardEpoch, Is.EqualTo(6));
}
2026-05-19 19:55:29 +01:00
[Test]
public async Task CultNetDatabaseServer_Reads_DurableShardLog_AfterRestart()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N"));
var schemaId = new CultCache().Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-durable-log",
"runtime-a",
epoch: 7,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:");
var sourceCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "durable@example.test",
PasswordHash = "hash",
Username = "Durable"
};
await sourceDatabase.PutAsync(new CultRecordKey("player:durable"), player);
sourceDatabase.Dispose();
var restartedCache = new CultCache();
var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions
{
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
using var server = new Server(restartedCache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, restartedDatabase);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
MessageId = "durable-log",
ShardId = "players-durable-log",
ShardEpoch = 7
});
Assert.That(response.ResyncRequired, Is.False);
Assert.That(response.Entries, Has.Length.EqualTo(1));
Assert.That(response.Entries[0].Sequence, Is.EqualTo(1));
Assert.That(response.Entries[0].Put, Is.Not.Null);
Assert.That(response.Entries[0].Put!.Document.SchemaId, Is.EqualTo(schemaId));
Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo("player:durable"));
Assert.That(response.Entries[0].Put!.Document.Payload, Is.EqualTo(SerializePlayerDataPayload(player)));
}
2026-05-19 20:53:45 +01:00
[Test]
public async Task CultNetDatabaseServer_RequiresResync_ForCompactedShardLogHistory()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N"));
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var store = new CultNetFileShardMutationLogStore(rootPath);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
MutationLogStore = store,
Shards =
[
new CultNetShardDescriptor(
"players-compacted-log",
"runtime-a",
epoch: 8,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
await database.PutAsync(
new CultRecordKey("player:first"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "first@example.test", PasswordHash = "hash", Username = "First" });
await database.PutAsync(
new CultRecordKey("player:second"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "second@example.test", PasswordHash = "hash", Username = "Second" });
store.CompactThrough("players-compacted-log", 1);
var staleResponse = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-compacted-log",
ShardEpoch = 8,
AfterSequence = 0
});
var currentResponse = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-compacted-log",
ShardEpoch = 8,
AfterSequence = 1
});
Assert.That(staleResponse.ResyncRequired, Is.True);
Assert.That(staleResponse.Reason, Is.EqualTo("compacted_history"));
Assert.That(staleResponse.CompactedThrough, Is.EqualTo(1));
Assert.That(staleResponse.Entries, Is.Empty);
Assert.That(currentResponse.ResyncRequired, Is.False);
Assert.That(currentResponse.Entries, Has.Length.EqualTo(1));
Assert.That(currentResponse.Entries[0].Sequence, Is.EqualTo(2));
}
2026-05-19 18:05:51 +01:00
[Test]
public async Task CultNetDatabase_Applies_ShardLogResponse_ToReplica()
{
var sourceCache = new CultCache();
var targetCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
targetCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().SchemaId;
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
Shards =
[
new CultNetShardDescriptor(
"players-replica",
"runtime-a",
epoch: 10,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions
{
DocumentRegistry = targetRegistry,
Shards =
[
new CultNetShardDescriptor(
"players-replica",
"runtime-a",
epoch: 10,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
using var server = new Server(sourceCache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase);
var key = new CultRecordKey("player:replica");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "replica@example.test",
PasswordHash = "hash",
Username = "Replica"
};
await sourceDatabase.PutAsync(key, player);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-replica",
ShardEpoch = 10
});
var sequence = await targetDatabase.ApplyShardLogResponseAsync(response);
var replicated = targetCache.Get<PlayerData>(key);
var replayedSequence = await targetDatabase.ApplyShardLogResponseAsync(response);
Assert.That(sequence, Is.EqualTo(1));
Assert.That(replayedSequence, Is.EqualTo(1));
Assert.That(targetDatabase.GetAppliedShardSequence("players-replica"), Is.EqualTo(1));
Assert.That(replicated, Is.Not.Null);
Assert.That(replicated!.Username, Is.EqualTo("Replica"));
Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1));
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
// R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too -
// it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the
// ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and
// cursor treat a replicated row as never written.
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
}
[Test]
public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence()
{
var sourceCache = new CultCache();
var targetCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
targetCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().SchemaId;
CultNetShardDescriptor Shard(bool isPrimary) => new(
"players-replica-delete",
"runtime-a",
epoch: 1,
isPrimary: isPrimary,
schemaIds: [schemaId],
keyPrefix: "player:");
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
Shards = [Shard(true)]
});
var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions
{
DocumentRegistry = targetRegistry,
Shards = [Shard(false)]
});
using var server = new Server(sourceCache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase);
var key = new CultRecordKey("player:replica-delete");
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" };
await sourceDatabase.PutAsync(key, player);
await sourceDatabase.DeleteAsync<PlayerData>(key);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-replica-delete",
ShardEpoch = 1
});
await targetDatabase.ApplyShardLogResponseAsync(response);
Assert.That(targetCache.Get<PlayerData>(key), Is.Null);
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2));
}
[Test]
public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N"));
var schemaId = new CultCache().Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-durable-sequence",
"runtime-a",
epoch: 1,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:");
var sourceCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
var key = new CultRecordKey("player:durable-sequence");
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" };
await sourceDatabase.PutAsync(key, player);
Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
sourceDatabase.Dispose();
// R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the
// sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences
// (what the next local commit gets), never _lastWriteSequence (what a row's own last
// write was), so a selection evaluated right after restart would order/page the row as
// "never written" until it was written again.
var restartedCache = new CultCache();
var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions
{
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
}
[Test]
public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"),
new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
await database.PutAsync(
new CultRecordKey("a:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" });
await database.PutAsync(
new CultRecordKey("b:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" });
// R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log
// and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than
// answered, reusing the selection_invalid door refusal (R-N) rather than a second shape.
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
Selection = new CultNetSelection { Schemas = [schemaId] }
}));
Assert.That(ex!.Field, Is.EqualTo("asOf"));
2026-05-19 18:05:51 +01:00
}
// S-9 (docs/cultnet-selection-cut.md, fix batch 4): asOf is the watermark of the shard(s) the
// selection's matched rows actually come from, not CurrentAsOf()'s database-wide maximum across
// every shard - a shard-log sequence is a per-shard counter, so a selection matching only
// shard-a's row must not claim exactness as of a sequence shard-b's unrelated writes advanced to.
[Test]
public async Task CultNetDatabaseServer_SelectionResponseAsOf_IsTheMatchedShardsOwnWatermarkNotTheDatabaseWideMaximum()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"),
new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
await database.PutAsync(
new CultRecordKey("a:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" });
// shard-b takes several more writes than shard-a, so the database-wide maximum runs well
// ahead of shard-a's own watermark.
for (var i = 0; i < 4; i++)
{
await database.PutAsync(
new CultRecordKey("b:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = $"b{i}@example.test", PasswordHash = "hash", Username = "B" });
}
Assert.That(database.CurrentAsOf(), Is.EqualTo(4UL), "sanity: the database-wide maximum is shard-b's watermark");
Assert.That(database.CurrentAsOf("shard-a"), Is.EqualTo(1UL), "sanity: shard-a's own watermark is unaffected by shard-b's writes");
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
Selection = new CultNetSelection { Keys = ["a:one"] }
});
Assert.That(response.AsOf, Is.EqualTo(1UL));
}
[Test]
public async Task CultNetDatabase_Predicts_ClientOwnedInput_AndReconciles_AuthoritativeLog()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var key = new CultRecordKey("input:client-a");
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "client-a",
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"inputs",
"server",
epoch: 1,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "input:",
primaryEndpoints: ["cultnet://server.example.test:3075"])
],
ClientAuthorityScopes =
[
new CultNetClientAuthorityScope(
"client-a",
schemaIds: [schemaId],
keyPrefix: "input:client-a")
]
});
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database.WatchRecord<PlayerData>(key)
.Subscribe(change => changes.Add(change));
var predicted = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "input@example.test",
PasswordHash = "hash",
Username = "Predicted"
};
var authoritative = new PlayerData
{
PlayerId = predicted.PlayerId,
Email = predicted.Email,
PasswordHash = predicted.PasswordHash,
Username = "Authoritative"
};
var put = registry.CreateRawDocumentPutMessage("input-commit", new CultRecordHandle<PlayerData>(key), authoritative);
put.ShardId = "inputs";
put.ShardEpoch = 1;
await database.PutPredictedAsync(key, predicted);
await database.ApplyShardLogResponseAsync(new CultNetShardLogResponseMessage
{
ShardId = "inputs",
ShardEpoch = 1,
Entries =
[
new CultNetShardLogEntryMessage
{
Sequence = 1,
CommittedAt = "2026-05-19T12:00:00.0000000Z",
ChangeKind = "updated",
Put = put
}
]
});
Assert.That(cache.Get<PlayerData>(key)!.Username, Is.EqualTo("Authoritative"));
Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Predicted), Is.True);
Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Reconciled), Is.True);
}
[Test]
public void CultNetDatabase_Rejects_Prediction_OutsideClientAuthorityScope()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "client-a",
Shards =
[
new CultNetShardDescriptor(
"inputs",
"server",
epoch: 1,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "input:")
],
ClientAuthorityScopes =
[
new CultNetClientAuthorityScope(
"client-a",
schemaIds: [schemaId],
keyPrefix: "input:client-a")
]
});
Assert.That(
async () => await database.PutPredictedAsync(
new CultRecordKey("input:client-b"),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "input-b@example.test",
PasswordHash = "hash",
Username = "WrongClient"
}),
Throws.TypeOf<CultNetShardAuthorityException>()
.With.Property(nameof(CultNetShardAuthorityException.Reason))
.EqualTo("not_client_authority"));
}
2026-05-19 18:05:51 +01:00
[Test]
public void CultNetDatabase_Rejects_ShardLogGap()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor(
"players-gap",
"runtime-a",
epoch: 1,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:")
]
});
Assert.That(
async () => await database.ApplyShardLogResponseAsync(new CultNetShardLogResponseMessage
{
ShardId = "players-gap",
ShardEpoch = 1,
Entries =
[
new CultNetShardLogEntryMessage
{
Sequence = 2,
ChangeKind = "removed",
Delete = new CultNetDocumentDeleteMessage
{
SchemaId = schemaId,
RecordKey = "player:gap",
ShardId = "players-gap",
ShardEpoch = 1
}
}
]
}),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("log has a gap"));
}
2026-05-19 18:12:24 +01:00
[Test]
public async Task CultNetShardReplicator_Pulls_AndApplies_NextBatch()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-pull",
"runtime-a",
epoch: 2,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://primary.example.test:3075"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
var key = new CultRecordKey("player:pull");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "pull@example.test",
PasswordHash = "hash",
Username = "Pull"
};
var put = registry.CreateRawDocumentPutMessage("put-pull", new CultRecordHandle<PlayerData>(key), player);
put.ShardId = "players-pull";
put.ShardEpoch = 2;
var fetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage
{
ShardId = "players-pull",
ShardEpoch = 2,
Entries =
[
new CultNetShardLogEntryMessage
{
Sequence = 1,
CommittedAt = "2026-05-19T12:00:00.0000000Z",
ChangeKind = "added",
Put = put
}
]
});
using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions
{
Fetcher = fetcher,
BatchSize = 7
});
var sequence = await replicator.PullOnceAsync("players-pull");
Assert.That(sequence, Is.EqualTo(1));
Assert.That(fetcher.FetchCount, Is.EqualTo(1));
Assert.That(fetcher.LastShard!.ShardId, Is.EqualTo("players-pull"));
Assert.That(fetcher.LastAfterSequence, Is.EqualTo(0));
Assert.That(fetcher.LastLimit, Is.EqualTo(7));
Assert.That(cache.Get<PlayerData>(key)!.Username, Is.EqualTo("Pull"));
}
2026-05-19 18:19:09 +01:00
[Test]
public async Task CultNetShardReplicator_Resumes_From_FileCursorStore()
{
var path = Path.Combine(Path.GetTempPath(), $"cultnet-cursors-{Guid.NewGuid():N}.mpack");
try
{
var store = new CultNetFileShardReplicaCursorStore(path);
await store.WriteAsync(new CultNetShardReplicaCursor
{
ShardId = "players-cursor",
ShardEpoch = 3,
LastAppliedSequence = 5,
UpdatedAt = "2026-05-19T12:00:00.0000000Z"
});
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-cursor",
"runtime-a",
epoch: 3,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://primary.example.test:3075"]);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards = [shard]
});
var fetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage
{
ShardId = "players-cursor",
ShardEpoch = 3
});
using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions
{
Fetcher = fetcher,
CursorStore = store
});
var sequence = await replicator.PullOnceAsync("players-cursor");
var cursor = await store.ReadAsync("players-cursor");
Assert.That(fetcher.LastAfterSequence, Is.EqualTo(5));
Assert.That(sequence, Is.EqualTo(5));
Assert.That(database.GetAppliedShardSequence("players-cursor"), Is.EqualTo(5));
Assert.That(cursor, Is.Not.Null);
Assert.That(cursor!.LastAppliedSequence, Is.EqualTo(5));
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
[Test]
public async Task CultNetShardReplicator_AppliesSnapshot_WhenLogHistoryWasCompacted()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-snapshot-resync",
"runtime-a",
epoch: 9,
isPrimary: false,
schemaIds: [schemaId],
keyPrefix: "player:",
primaryEndpoints: ["cultnet://primary.example.test:3075"]);
var keepKey = new CultRecordKey("player:keep");
var goneKey = new CultRecordKey("player:gone");
await cache.UpsertAsync(
new PlayerData { PlayerId = Guid.NewGuid(), Email = "keep-old@example.test", PasswordHash = "hash", Username = "KeepOld" },
new CultRecordHandle<PlayerData>(keepKey));
await cache.UpsertAsync(
new PlayerData { PlayerId = Guid.NewGuid(), Email = "gone@example.test", PasswordHash = "hash", Username = "Gone" },
new CultRecordHandle<PlayerData>(goneKey));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards = [shard]
});
var keep = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "keep-new@example.test",
PasswordHash = "hash",
Username = "KeepNew"
};
var put = registry.CreateRawDocumentPutMessage(
"snapshot-keep",
new CultRecordHandle<PlayerData>(keepKey),
keep);
var logFetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage
{
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
ResyncRequired = true,
Reason = "compacted_history",
CompactedThrough = 2
});
var snapshotFetcher = new CapturingShardSnapshotFetcher(new CultNetSnapshotResponseRawMessage
{
MessageId = "snapshot-resync",
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
ShardLogSequence = 4,
Documents = [put.Document]
});
using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions
{
Fetcher = logFetcher,
SnapshotFetcher = snapshotFetcher
});
var sequence = await replicator.PullOnceAsync(shard);
Assert.That(sequence, Is.EqualTo(4));
Assert.That(database.GetAppliedShardSequence(shard.ShardId), Is.EqualTo(4));
Assert.That(snapshotFetcher.FetchCount, Is.EqualTo(1));
Assert.That(cache.Get<PlayerData>(goneKey), Is.Null);
Assert.That(cache.Get<PlayerData>(keepKey)!.Username, Is.EqualTo("KeepNew"));
}
[Test]
public void CultNetSimulationConsensus_Builds_QuorumCandidate_FromWitnesses()
{
var hitA = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
var hitB = CultNetSimulationObservation.ComputeClaimHash("hit", "charlie", "bob", "frame:100");
var consensus = new CultNetSimulationConsensus(new CultNetSimulationConsensusOptions
{
MinimumWitnesses = 2,
QuorumRatio = 0.6d
});
var candidates = consensus.BuildCandidates(
[
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = hitA,
ClaimSummary = "alice hit bob first"
},
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-2",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = hitA
},
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-3",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = hitB,
ClaimSummary = "charlie hit bob first"
}
]);
Assert.That(candidates, Has.Count.EqualTo(1));
Assert.That(candidates[0].ClaimHash, Is.EqualTo(hitA));
Assert.That(candidates[0].WitnessCount, Is.EqualTo(2));
Assert.That(candidates[0].SupportWeight, Is.EqualTo(2d));
Assert.That(candidates[0].TotalWeight, Is.EqualTo(3d));
Assert.That(candidates[0].HasQuorum, Is.True);
}
[Test]
public void CultNetSimulationConsensus_DeduplicatesWitness_AndBreaksTiesDeterministically()
{
var lowerHash = CultNetSimulationObservation.ComputeClaimHash("a");
var higherHash = CultNetSimulationObservation.ComputeClaimHash("b");
if (string.CompareOrdinal(lowerHash, higherHash) > 0)
{
(lowerHash, higherHash) = (higherHash, lowerHash);
}
var consensus = new CultNetSimulationConsensus(new CultNetSimulationConsensusOptions
{
QuorumRatio = 0.5d
});
var candidates = consensus.BuildCandidates(
[
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 1,
Frame = 20,
SubjectId = "door",
ClaimKind = "state",
ClaimHash = higherHash,
Weight = 1d
},
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 1,
Frame = 20,
SubjectId = "door",
ClaimKind = "state",
ClaimHash = lowerHash,
Weight = 0.5d
},
new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-2",
ShardId = "arena",
ShardEpoch = 1,
Frame = 20,
SubjectId = "door",
ClaimKind = "state",
ClaimHash = lowerHash,
Weight = 1d
}
]);
Assert.That(candidates, Has.Count.EqualTo(1));
Assert.That(candidates[0].ClaimHash, Is.EqualTo(lowerHash));
Assert.That(candidates[0].TotalWeight, Is.EqualTo(2d));
Assert.That(candidates[0].SupportWeight, Is.EqualTo(1d));
Assert.That(candidates[0].HasQuorum, Is.True);
}
2026-05-19 18:44:29 +01:00
[Test]
public void CultNetSimulationObservationHub_Emits_ReactiveCandidates()
{
var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
using var hub = new CultNetSimulationObservationHub(new CultNetSimulationConsensusOptions
{
MinimumWitnesses = 2,
QuorumRatio = 1d
});
var observations = new List<CultNetSimulationObservation>();
var candidates = new List<CultNetSimulationConsensusCandidate>();
using var observationSubscription = hub.WatchObservations()
.Subscribe(observation => observations.Add(observation));
using var candidateSubscription = hub.WatchCandidates()
.Subscribe(candidate => candidates.Add(candidate));
hub.Submit(new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 1,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash
});
var current = hub.Submit(new CultNetSimulationObservationMessage
{
Observation = new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-2",
ShardId = "arena",
ShardEpoch = 1,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash
}
});
Assert.That(observations, Has.Count.EqualTo(2));
Assert.That(current, Has.Count.EqualTo(1));
Assert.That(current[0].HasQuorum, Is.True);
Assert.That(candidates.Exists(candidate => candidate.HasQuorum), Is.True);
}
[Test]
public void CultNetSimulationObservationServer_Creates_CandidateMessages()
{
var cache = new CultCache();
using var server = new Server(cache, DevelopmentServerSecurity);
using var hub = new CultNetSimulationObservationHub(new CultNetSimulationConsensusOptions
{
MinimumWitnesses = 2,
QuorumRatio = 1d
});
using var observationServer = new CultNetSimulationObservationServer(server, hub);
var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
var first = new CultNetSimulationObservationMessage
{
MessageId = "observe-1",
Observation = new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 1,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash
}
};
var second = new CultNetSimulationObservationMessage
{
MessageId = "observe-2",
Observation = new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-2",
ShardId = "arena",
ShardEpoch = 1,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash
}
};
observationServer.CreateCandidateMessages(first);
var candidates = observationServer.CreateCandidateMessages(second);
Assert.That(candidates, Has.Length.EqualTo(1));
Assert.That(candidates[0].MessageId, Is.EqualTo("observe-2"));
Assert.That(candidates[0].ClaimHash, Is.EqualTo(claimHash));
Assert.That(candidates[0].HasQuorum, Is.True);
}
[Test]
public async Task CultMeshSimulationFactCommitter_Commits_QuorumCandidate_ToShardLog()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<CultMeshSimulationFact>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"arena",
"runtime-a",
epoch: 4,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "simulation:")
]
});
var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
var candidate = new CultNetSimulationConsensusCandidate(
"arena",
4,
100,
"bob",
"hit",
claimHash,
"alice shot bob first",
witnessCount: 3,
supportWeight: 3d,
totalWeight: 3d,
hasQuorum: true);
var committer = CultMesh.CreateSimulationFactCommitter(database);
var handle = await committer.CommitAsync(candidate);
var fact = cache.Get<CultMeshSimulationFact>(handle.Key);
var log = database.GetMutationLog("arena");
Assert.That(handle.Key, Is.EqualTo(CultMeshSimulationFact.CreateRecordKey(candidate)));
Assert.That(fact, Is.Not.Null);
Assert.That(fact!.ClaimHash, Is.EqualTo(claimHash));
Assert.That(fact.ClaimSummary, Is.EqualTo("alice shot bob first"));
Assert.That(fact.Confidence, Is.EqualTo(1d));
Assert.That(log, Has.Count.EqualTo(1));
Assert.That(log[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
}
2026-05-20 07:45:10 +01:00
[Test]
public async Task CultMeshGameSession_SubmitsWitnesses_AndCommitsQuorumFact()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-session", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
var cachePath = Path.Combine(rootPath, "world.ccmp");
var schemaId = new CultCache().Registry.GetRequired<CultMeshSimulationFact>().SchemaId;
using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
DatabaseOptions = new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"arena",
"runtime-a",
epoch: 4,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "simulation:")
]
}
});
using var session = CultMesh.CreateGameSession(node, new CultMeshGameSessionOptions
{
ConsensusOptions = new CultNetSimulationConsensusOptions
{
MinimumWitnesses = 2,
QuorumRatio = 1d
},
ServeSimulationObservations = false,
ServeVerseDiscovery = false,
ServePeerExchange = false
});
var committed = new List<CultNetDatabaseChange<CultMeshSimulationFact>>();
using var facts = session.WatchSimulationFacts().Subscribe(committed.Add);
var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100");
var firstCommit = await session.SubmitAndCommitAsync(new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-1",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash,
ClaimSummary = "alice shot bob first"
});
var secondCommit = await session.SubmitAndCommitAsync(new CultNetSimulationObservation
{
WitnessRuntimeId = "watcher-2",
ShardId = "arena",
ShardEpoch = 4,
Frame = 100,
SubjectId = "bob",
ClaimKind = "hit",
ClaimHash = claimHash,
ClaimSummary = "alice shot bob first"
});
Assert.That(firstCommit, Is.Empty);
Assert.That(secondCommit, Has.Count.EqualTo(1));
Assert.That(node.Cache.Get<CultMeshSimulationFact>(secondCommit[0].Key)!.ClaimHash, Is.EqualTo(claimHash));
Assert.That(committed.Exists(change => change.Document?.ClaimHash == claimHash), Is.True);
}
[Test]
public void CultMeshSimulationFactCommitter_Rejects_CandidateWithoutQuorum()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<CultMeshSimulationFact>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
new CultNetShardDescriptor(
"arena",
"runtime-a",
epoch: 4,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "simulation:")
]
});
var candidate = new CultNetSimulationConsensusCandidate(
"arena",
4,
100,
"bob",
"hit",
CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"),
null,
witnessCount: 1,
supportWeight: 1d,
totalWeight: 3d,
hasQuorum: false);
var committer = CultMesh.CreateSimulationFactCommitter(database);
Assert.That(
async () => await committer.CommitAsync(candidate),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("before quorum"));
}
2026-05-19 17:10:24 +01:00
[Test]
public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var key = new CultRecordKey("player:change");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "change@example.test",
PasswordHash = "hash",
Username = "Change"
};
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var change = new CultNetDatabaseChange<PlayerData>(
CultNetDatabaseChangeKind.Added,
key,
schemaId,
database.Shards[0],
player,
previousDocument: null);
var method = typeof(CultNetDatabaseServer).GetMethod(
"CreateChangeMessage",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(databaseServer, new object[]
{
change,
"sub-1",
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
new CultNetSelection
2026-05-19 17:10:24 +01:00
{
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
Schemas = [schemaId],
Keys = [key.Value],
Projection = CultNetSelectionProjections.Document
2026-05-19 17:10:24 +01:00
}
});
Assert.That(message, Is.Not.Null);
Assert.That(message!.SubscriptionId, Is.EqualTo("sub-1"));
Assert.That(message.ChangeKind, Is.EqualTo("added"));
Assert.That(message.Document, Is.Not.Null);
Assert.That(message.Document!.Payload, Is.EqualTo(SerializePlayerDataPayload(player)));
}
[Test]
public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange_ForSchemaAlias()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var key = new CultRecordKey("network-note:alias-change");
var note = new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "alias change",
Revision = 11
};
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
var change = new CultNetDatabaseChange<NetworkSchemaNote>(
CultNetDatabaseChangeKind.Added,
key,
descriptor.SchemaId,
database.Shards[0],
note,
previousDocument: null);
var method = typeof(CultNetDatabaseServer).GetMethod(
"CreateChangeMessage",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(databaseServer, new object[]
{
change,
"sub-alias",
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
new CultNetSelection
{
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas, keys, fields, cites, cited, projection, descending, limit, cursor) with its door validation (S1), and CultNetSelectionEvaluator: the single-row Matches fast path (D6) and the full Select (order, hop, cursor, paging). Neither reflects over a document; both read only through the cache's public surface (commit 0). Three v1 messages beside v0 (cultnet.snapshot_request.v1, cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their schema files under contracts/cultnet, registry entries and dispatch arms. CultNetSchemaAliasMatching goes internal -> public (D4), the one alias matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed at the one append site (AppendMutationLogEntry) that every committed write takes a shard-log sequence, including a local PutAsync outside any shard. Three engines deleted, one evaluator wired in their place: - CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments replaces the reflection entirely. v0 lowers into a Selection and walks the evaluator's own pages so a v0 caller still sees its whole matching set (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/ ToRawRecord/CreateSnapshotRequestV1 added. - CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/ MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers v0 into a Selection and matches through the evaluator. - CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/ CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and CreateProjectedSnapshot's collapse are commit 2's, per the map's own deletion line, and are untouched here. Discrepancy found and fixed (not in the map): CultNetDocumentBinding can carry a schema id different from its descriptor's own (CultNetDocumentBinding.ForDocument's schemaId override), which the old per-engine matchers checked and a descriptor-only evaluator cannot see by design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two call sites and in CultNetDocumentRegistry.SelectPage by resolving the binding's schema id into the effective selection before evaluation (CultNetSchemaAliasMatching.WithBindingSchemaAlias, CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator. Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing out; both call sites now pass. Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth, undocumented copy of schema-matching logic (MatchesRequestedSchema, private, serving CreateShardSnapshotResponse), which the map's section 1 audit did not name among "three separate selector engines". Left untouched this commit: it is out of the map's explicit deletes list and touching it risks shard snapshot behavior with no budgeted verification. Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150 existing + 12 new), 0 failed; the seven map-named tests all still pass, two rewritten for the new signatures (CreateChangeMessage now takes CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into CreateMatchedRecord, returning CultNetRawDocumentRecord? directly). tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected (188, unchanged from commit 0). Negative greps: GetProperty|GetField|GetCustomAttribute in GameCult.Networking - only against the generic `object change` dispatch (CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing and out of this cut's scope, never against a document's declared values. _projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected, commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the one reported discrepancy. scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter), replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file of the same six mutations (re-verified: all six still KILL). It is now generic to any NUnit project in this repo, not only this campaign's two targets - a candidate to sit beside tools/eureka-mutations.ps1 as the dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2, S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by tests, not by a committed mutant); S9-S15 (subscription reconcile lowering, v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are cache-layer, covered in commit 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
Schemas = ["tests.networking_note.v1"],
Keys = [key.Value],
Projection = CultNetSelectionProjections.Document
}
});
Assert.That(message, Is.Not.Null);
Assert.That(message!.SubscriptionId, Is.EqualTo("sub-alias"));
Assert.That(message.ChangeKind, Is.EqualTo("added"));
Assert.That(message.Document, Is.Not.Null);
Assert.That(message.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId));
Make generated and reflective document members one rule The generator and CultDocumentRegistry now discover members by the same steps: most-derived declaration first, override chain collapsed to one member, attributes read up the override chain, rejections in one order with one text (GCC001 / InvalidOperationException). - Accessors are named per document, so documents sharing a [CultName] or [CultIndex] base member no longer emit duplicate methods (CS0111). - Duplicate [Key] slots are refused, including new-hidden fields and properties; a hidden persisted member with a different slot is refused. - String [Key("x")] gets its own message instead of "has no [Key]". - A persisted property whose set accessor is private, protected or init, or a readonly field, is refused instead of CS0272 in generated code. - Overrides: MessagePack's DynamicObjectResolver reads the base declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a re-keyed override and serializes an override marked [IgnoreMember]). An override that changes them is refused in both paths; an override that repeats or omits them is accepted and byte-pinned. - Typed Serialize<T>/Deserialize<T> used static Options and skipped generated codecs and declared resolvers; deleted. CultNetDocumentBinding and CultMeshSnapshots route through the untyped path with their registry. DocumentShapeSweepTests compiles each shape through the generator and compares generated vs reflective schema id, catalog, name/index values and payload against MessagePack's own bytes, or equal rejection text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
[Test]
public void CultNetSchemaRegistry_BuiltInCatalog_AdvertisesRawLane_AndSharedGhostlightContract()
{
var response = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse(
new CultNetSchemaCatalogRequestMessage
{
MessageId = "catalog-raw",
IncludeSchemaJson = true
});
var rawPut = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DocumentPutRaw);
var rawSnapshot = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.SnapshotResponseRaw);
var subscribe = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseSubscribe);
var unsubscribe = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseUnsubscribe);
var change = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseChangeRaw);
var ghostlight = Array.Find(response.Schemas,
schema => schema.SchemaVersion == "ghostlight.agent_state.v0");
2026-06-14 20:32:25 +01:00
var transportProfile = Array.Find(response.Schemas,
schema => schema.SchemaVersion == "cultnet.transport_profile.v0");
Assert.That(rawPut, Is.Not.Null);
Assert.That(rawPut!.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0]));
Assert.That(rawPut.SchemaJson, Does.Contain("cultnet.document_put_raw.v0"));
Assert.That(rawPut.ContentHash, Has.Length.EqualTo(64));
Assert.That(rawSnapshot, Is.Not.Null);
Assert.That(rawSnapshot!.SchemaJson, Does.Contain("cultnet.snapshot_response_raw.v0"));
Assert.That(subscribe, Is.Not.Null);
Assert.That(subscribe!.SchemaJson, Does.Contain("cultnet.database_subscribe.v0"));
Assert.That(unsubscribe, Is.Not.Null);
Assert.That(unsubscribe!.SchemaJson, Does.Contain("cultnet.database_unsubscribe.v0"));
Assert.That(change, Is.Not.Null);
Assert.That(change!.SchemaJson, Does.Contain("cultnet.database_change_raw.v0"));
Assert.That(ghostlight, Is.Not.Null);
Assert.That(ghostlight!.DocumentType, Is.EqualTo("ghostlight.agent-state"));
Assert.That(ghostlight.Kind, Is.EqualTo("document_payload"));
2026-06-14 20:32:25 +01:00
Assert.That(transportProfile, Is.Not.Null);
Assert.That(transportProfile!.Kind, Is.EqualTo("shared_contract"));
Assert.That(transportProfile.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0]));
Assert.That(transportProfile.SchemaJson, Does.Contain("cultnet.transport_profile.v0"));
Assert.That(transportProfile.SchemaJson, Does.Contain("reconnectPolicy"));
Assert.That(transportProfile.SchemaJson, Does.Contain("cultnet.reconnect_policy.v0"));
2026-06-14 20:32:25 +01:00
Assert.That(transportProfile.SchemaJson, Does.Contain("litenetlib"));
}
[Test]
public void CultWitnessArtifactBundle_Has_Stable_CacheContract()
{
var descriptor = new CultCache().Registry.GetRequired<CultWitnessArtifactBundle>();
Assert.That(descriptor.SchemaName, Is.EqualTo("cultnet.witness_artifact_bundle"));
Assert.That(descriptor.SchemaVersion, Is.EqualTo(CultNetSchemaVersions.WitnessArtifactBundle));
Assert.That(descriptor.NameMember, Is.EqualTo(nameof(CultWitnessArtifactBundle.BundleId)));
Assert.That(descriptor.CanonicalSchemaJson, Is.EqualTo(
"{\"schemaName\":\"cultnet.witness_artifact_bundle\",\"schemaVersion\":\"cultnet.witness_artifact_bundle.v0\",\"members\":[{\"slot\":0,\"name\":\"BundleId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"WitnessKind\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":2,\"name\":\"CapturedAt\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":3,\"name\":\"Subject\",\"type\":\"GameCult.Networking.CultWitnessSubjectPin\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":4,\"name\":\"Contracts\",\"type\":\"GameCult.Networking.CultWitnessContractPin[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":5,\"name\":\"Artifacts\",\"type\":\"GameCult.Networking.CultWitnessArtifactEntry[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":6,\"name\":\"TimingWitnesses\",\"type\":\"GameCult.Networking.CultWitnessTimingEntry[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":7,\"name\":\"Provenance\",\"type\":\"GameCult.Networking.CultWitnessProvenance\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}"));
}
[Test]
public void CultNetSchemaRegistry_BuiltInCatalog_Advertises_WitnessArtifactBundle()
{
var response = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse(
new CultNetSchemaCatalogRequestMessage
{
MessageId = "catalog-witness",
IncludeSchemaJson = true
});
var witnessBundle = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.WitnessArtifactBundle);
Assert.That(witnessBundle, Is.Not.Null);
Assert.That(witnessBundle!.DocumentType, Is.EqualTo("cultnet.witness-artifact-bundle"));
Assert.That(witnessBundle.Kind, Is.EqualTo("document_payload"));
Assert.That(witnessBundle.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0]));
Assert.That(witnessBundle.SchemaJson, Does.Contain("\"bundleId\""));
Assert.That(witnessBundle.SchemaJson, Does.Contain("\"timingWitnesses\""));
Assert.That(witnessBundle.SchemaJson, Does.Contain("\"witnessArtifactUri\""));
}
[Test]
public void Server_Separates_Connection_And_Auth_RateLimits()
{
var cache = new CultCache();
var server = new Server(cache, DevelopmentServerSecurity);
var serverType = typeof(Server);
var connectionMethod = serverType.GetMethod("CheckConnectionRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!;
var authMethod = serverType.GetMethod("CheckAuthRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!;
for (var index = 0; index < 5; index++)
{
Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True);
}
Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True);
for (var index = 0; index < 5; index++)
{
_ = authMethod.Invoke(server, new object[] { "127.0.0.2" });
}
Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.False);
Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.True);
}
[Test]
public void Client_Reconnect_Backoff_Is_Bounded_And_Grows()
{
var method = typeof(Client).GetMethod(
"GetReconnectDelayForAttempt",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(int) },
null)!;
var deterministicMethod = typeof(Client).GetMethod(
"GetReconnectDelayForAttempt",
BindingFlags.Static | BindingFlags.NonPublic,
null,
new[] { typeof(int), typeof(int), typeof(CultNetReconnectPolicy) },
null)!;
var first = (TimeSpan)method.Invoke(null, new object[] { 1 })!;
var third = (TimeSpan)method.Invoke(null, new object[] { 3 })!;
var tenth = (TimeSpan)method.Invoke(null, new object[] { 10 })!;
Assert.That(first, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1)));
Assert.That(first, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1.25)));
Assert.That(third, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(4)));
Assert.That(third, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(4.25)));
Assert.That(tenth, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(30)));
Assert.That(tenth, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(30.25)));
var deterministic = (TimeSpan)deterministicMethod.Invoke(
null,
new object[] { 3, 17, CultNetReconnectPolicies.CreateDefault(maxAttempts: 8) })!;
Assert.That(deterministic, Is.EqualTo(TimeSpan.FromMilliseconds(4_017)));
}
[Test]
public void Client_Reconnect_Uses_Portable_Policy_Controller()
{
var policy = CultNetReconnectPolicies.CreateDefault("client-rudp", maxAttempts: 2);
var client = new Client(DevelopmentClientSecurity, policy);
Assert.That(client.ReconnectPolicy, Is.SameAs(policy));
Assert.That(client.ReconnectAttempt, Is.EqualTo(0));
Assert.That(client.NextReconnectAttemptAtMs, Is.Null);
var schedule = typeof(Client).GetMethod("ScheduleReconnect", BindingFlags.Instance | BindingFlags.NonPublic)!;
schedule.Invoke(client, Array.Empty<object>());
Assert.That(client.ReconnectAttempt, Is.EqualTo(1));
Assert.That(client.NextReconnectAttemptAtMs, Is.Not.Null);
Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.WaitingToReconnect));
client.Dispose();
}
[Test]
public void SensitivePayloadLogging_Is_Gated_By_Default()
{
var client = new Client(DevelopmentClientSecurity);
var productionServer = new Server(new CultCache(), new ServerSecurityOptions("prod-connection-key", "prod-session-signing-secret"));
var developmentServer = new Server(new CultCache(), DevelopmentServerSecurity);
Assert.That(client.LogSensitivePayloads, Is.False);
Assert.That(productionServer.LogSensitivePayloads, Is.False);
Assert.That(developmentServer.LogSensitivePayloads, Is.True);
}
2026-05-09 13:32:30 +01:00
[Test]
public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-{Guid.NewGuid():N}.msgpack");
try
{
var host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions
{
StartServer = false
});
Assert.That(host.Cache, Is.Not.Null);
Assert.That(host.Store, Is.Not.Null);
Assert.That(host.Server, Is.Not.Null);
await host.Cache.UpsertAsync(new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "host@example.test",
PasswordHash = "hash",
Username = "HostUser"
});
await host.FlushAsync();
host.Dispose();
var reopened = await CultCacheMessagePack.OpenAsync(filePath);
Assert.That(reopened.GetByName<PlayerData>("HostUser"), Is.Not.Null);
}
finally
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public void CultNetLocal_CreateClient_Uses_Development_Defaults()
{
var client = CultNetLocal.CreateClient();
Assert.That(client, Is.Not.Null);
Assert.That(client.LogSensitivePayloads, Is.False);
Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.Idle));
}
[CultDocument("sample.mesh_note", "sample.mesh_note.v0")]
[MessagePack.MessagePackObject]
public sealed class MeshQuickstartNote
{
[MessagePack.Key(0)]
[CultName]
public string NoteId { get; set; } = string.Empty;
[MessagePack.Key(1)]
public string Body { get; set; } = string.Empty;
}
[CultDocument("tests.networking_note", "tests.networking_note.v1")]
[MessagePack.MessagePackObject]
public sealed class NetworkSchemaNote
{
[MessagePack.Key(0)]
public string Schema { get; set; } = string.Empty;
[MessagePack.Key(1)]
public string Text { get; set; } = string.Empty;
[MessagePack.Key(2)]
public int Revision { get; set; }
}
private sealed class EnvironmentVariableScope : IDisposable
{
private readonly (string Name, string? Value)[] _originalValues;
public EnvironmentVariableScope(params (string Name, string? Value)[] values)
{
_originalValues = new (string Name, string? Value)[values.Length];
for (var i = 0; i < values.Length; i++)
{
_originalValues[i] = (values[i].Name, Environment.GetEnvironmentVariable(values[i].Name));
Environment.SetEnvironmentVariable(values[i].Name, values[i].Value);
}
}
public void Dispose()
{
foreach (var original in _originalValues)
{
Environment.SetEnvironmentVariable(original.Name, original.Value);
}
}
}
private sealed class CapturingShardWriteForwarder : ICultNetShardWriteForwarder
{
public int PutCount { get; private set; }
public CultNetShardDescriptor? LastPutShard { get; private set; }
public CultNetDocumentPutRawMessage? LastPutMessage { get; private set; }
public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
PutCount++;
LastPutShard = shard;
LastPutMessage = message;
return Task.CompletedTask;
}
public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
return Task.CompletedTask;
}
}
2026-05-19 18:12:24 +01:00
private sealed class CapturingShardLogFetcher : ICultNetShardLogFetcher
{
private readonly CultNetShardLogResponseMessage _response;
public CapturingShardLogFetcher(CultNetShardLogResponseMessage response)
{
_response = response;
}
public int FetchCount { get; private set; }
public CultNetShardDescriptor? LastShard { get; private set; }
public long LastAfterSequence { get; private set; }
public int? LastLimit { get; private set; }
public Task<CultNetShardLogResponseMessage> FetchAsync(
CultNetShardDescriptor shard,
long afterSequence,
int? limit = null)
{
FetchCount++;
LastShard = shard;
LastAfterSequence = afterSequence;
LastLimit = limit;
return Task.FromResult(_response);
}
}
private sealed class CapturingShardSnapshotFetcher : ICultNetShardSnapshotFetcher
{
private readonly CultNetSnapshotResponseRawMessage _response;
public CapturingShardSnapshotFetcher(CultNetSnapshotResponseRawMessage response)
{
_response = response;
}
public int FetchCount { get; private set; }
public CultNetShardDescriptor? LastShard { get; private set; }
public Task<CultNetSnapshotResponseRawMessage> FetchAsync(CultNetShardDescriptor shard)
{
FetchCount++;
LastShard = shard;
return Task.FromResult(_response);
}
}
2026-06-16 14:22:41 +01:00
private sealed class CapturingSchemaClient : ICultNetSchemaClient
{
private readonly Func<ICultNetSchemaMessage, ICultNetSchemaMessage?> _respond;
private readonly Dictionary<Type, List<Delegate>> _handlers = new Dictionary<Type, List<Delegate>>();
public CapturingSchemaClient(Func<ICultNetSchemaMessage, ICultNetSchemaMessage?> respond)
{
_respond = respond;
}
public bool Connected { get; private set; }
public string? ConnectedHost { get; private set; }
public int ConnectedPort { get; private set; }
public List<ICultNetSchemaMessage> SentMessages { get; } = new List<ICultNetSchemaMessage>();
public void Connect(string host, int port)
{
Connected = true;
ConnectedHost = host;
ConnectedPort = port;
}
public void SendCultNet<T>(T message)
where T : ICultNetSchemaMessage
{
SentMessages.Add(message);
var response = _respond(message);
if (response == null)
{
return;
}
if (_handlers.TryGetValue(response.GetType(), out var handlers))
{
foreach (var handler in handlers)
{
handler.DynamicInvoke(response);
}
}
}
public void OnCultNet<T>(Action<T> callback)
where T : ICultNetSchemaMessage
{
if (!_handlers.TryGetValue(typeof(T), out var handlers))
{
handlers = new List<Delegate>();
_handlers[typeof(T)] = handlers;
}
handlers.Add(callback);
}
public void Dispose()
{
}
}
private sealed class NeverConnectedSchemaClient : ICultNetSchemaClient
{
public bool Connected => false;
public void Connect(string host, int port)
{
}
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
throw new InvalidOperationException("A disconnected client cannot send.");
}
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
}
public void Dispose()
{
}
}
private sealed class ManualCultMeshClock : ICultMeshClock
{
public ManualCultMeshClock(DateTimeOffset utcNow)
{
UtcNow = utcNow;
}
public DateTimeOffset UtcNow { get; private set; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
UtcNow += delay;
return Task.CompletedTask;
}
}
2026-07-18 22:34:22 +01:00
private sealed class AcceptingAuthoritySignatureVerifier : ICultMeshAuthoritySignatureVerifier
{
public bool Verify(CultMeshAuthorityLease lease) =>
string.Equals(lease.Signature, "sig", StringComparison.Ordinal);
}
private sealed class NoAuthorityRevocations : ICultMeshAuthorityRevocationSource
{
public bool IsRevoked(string leaseId, long authorityEpoch) => false;
}
[MessagePack.MessagePackObject]
public sealed class PlayerDataPayload
{
[MessagePack.Key(0)] public Guid PlayerId { get; set; }
[MessagePack.Key(1)] public string Email { get; set; } = string.Empty;
[MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty;
[MessagePack.Key(3)] public string Username { get; set; } = string.Empty;
[MessagePack.Key(4)] public long SessionVersion { get; set; }
}
private static byte[] SerializePlayerDataPayload(PlayerData entry)
{
return MessagePack.MessagePackSerializer.Serialize(new PlayerDataPayload
{
PlayerId = entry.PlayerId,
Email = entry.Email,
PasswordHash = entry.PasswordHash,
Username = entry.Username,
SessionVersion = entry.SessionVersion
});
}
private static PlayerData DeserializePlayerDataPayload(byte[] payload)
{
var decoded = MessagePack.MessagePackSerializer.Deserialize<PlayerDataPayload>(payload);
return new PlayerData
{
PlayerId = decoded.PlayerId,
Email = decoded.Email,
PasswordHash = decoded.PasswordHash,
Username = decoded.Username,
SessionVersion = decoded.SessionVersion
};
}
2026-04-18 06:04:39 +01:00
}
}