Cut C's headline cross-process proof. The TS `serve` peer already tore its listener down the instant it finished broadcasting, so nothing could dial it late; it gains `--linger-ms` to hold the listener open afterward instead. The interop harness starts that provider, runs a throwaway `dial` peer that receives every frame and disconnects (the provider requires a first peer before it broadcasts at all, so this is also what proves broadcasting finished), then only after that dials with the C# native connector and asserts the frame it gets is the retained one, by payload, not just non-empty. New `NativeConnectorReceivesTheRetainedFrameWhenJoiningLate` in GameCult.Mesh.Quic.Native.Tests carries the assertion; the existing `NativeConnectorReceivesFromExternalProvider` is untouched. Windows-only, matching the native connector under test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
9.8 KiB
C#
210 lines
9.8 KiB
C#
using System.Net;
|
|
using System.Net.Quic;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using FluentAssertions;
|
|
using GameCult.Mesh.Quic;
|
|
using GameCult.Mesh.Quic.Native;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Mesh.Quic.Native.Tests;
|
|
|
|
[TestFixture]
|
|
public sealed class CultMeshNativeQuicRealtimeTransportTests
|
|
{
|
|
[Test]
|
|
public async Task NativeConnectorReceivesFromExternalProvider()
|
|
{
|
|
// The provider on the other end is no longer necessarily the C#
|
|
// managed one (Cut 5 adds a TypeScript provider on this same lane);
|
|
// the env var name stays, since it names the endpoint, not the peer.
|
|
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
|
|
if (string.IsNullOrWhiteSpace(endpoint))
|
|
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/external-provider parity.");
|
|
|
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
|
using var client = await connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint!),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var frame = await client.ReceiveAsync(timeout.Token);
|
|
|
|
client.TransportId.Should().Be("msquic-native-realtime");
|
|
frame.SchemaId.Should().NotBeNullOrWhiteSpace();
|
|
frame.BodyId.Should().NotBeNullOrWhiteSpace();
|
|
frame.Payload.Should().NotBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cut C's headline cross-process proof: the harness lets the external
|
|
/// TypeScript provider finish broadcasting and disconnect its trigger peer
|
|
/// <i>before</i> this test dials, so nothing is retransmitted live. If a
|
|
/// frame arrives at all here, it can only be the provider's retained seed;
|
|
/// this test also checks the retained frame's own payload, so a
|
|
/// regression that seeds a stale generation (not just no seed) is caught
|
|
/// too.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task NativeConnectorReceivesTheRetainedFrameWhenJoiningLate()
|
|
{
|
|
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
|
|
if (string.IsNullOrWhiteSpace(endpoint))
|
|
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise the late-join retention lane.");
|
|
var expectedPayload = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD");
|
|
if (string.IsNullOrWhiteSpace(expectedPayload))
|
|
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD to the retained frame's expected payload text.");
|
|
|
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
|
using var client = await connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint!),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var frame = await client.ReceiveAsync(timeout.Token);
|
|
|
|
frame.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly);
|
|
System.Text.Encoding.UTF8.GetString(frame.Payload.ToArray()).Should().Be(expectedPayload);
|
|
}
|
|
|
|
[Test]
|
|
public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame()
|
|
{
|
|
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
|
|
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
|
|
using var certificate = CreateCertificate();
|
|
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
|
|
{
|
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
|
ServerCertificate = certificate
|
|
});
|
|
var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
|
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
|
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
|
|
|
using var client = await connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
var expected = new CultMeshRealtimeFrame
|
|
{
|
|
ChannelId = "aetheria.entities",
|
|
SchemaId = "gamecult.aetheria.entity_soa.v1",
|
|
BodyId = "aetheria.entities.current-zone",
|
|
ProducerEpoch = 17,
|
|
Sequence = 23,
|
|
Delivery = CultMeshRealtimeDelivery.LatestOnly,
|
|
Payload = new byte[] { 1, 3, 3, 7 }
|
|
};
|
|
await WaitUntilAsync(() => server.ConnectionCount == 1);
|
|
await server.BroadcastAsync(expected);
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var actual = await client.ReceiveAsync(timeout.Token);
|
|
|
|
client.TransportId.Should().Be("msquic-native-realtime");
|
|
actual.ChannelId.Should().Be(expected.ChannelId);
|
|
actual.SchemaId.Should().Be(expected.SchemaId);
|
|
actual.BodyId.Should().Be(expected.BodyId);
|
|
actual.ProducerEpoch.Should().Be(expected.ProducerEpoch);
|
|
actual.Sequence.Should().Be(expected.Sequence);
|
|
actual.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly);
|
|
actual.Payload.ToArray().Should().Equal(expected.Payload.ToArray());
|
|
}
|
|
|
|
[Test]
|
|
public async Task NativeConnectorRejectsWrongAdvertisedPin()
|
|
{
|
|
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
|
|
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
|
|
using var certificate = CreateCertificate();
|
|
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
|
|
{
|
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
|
ServerCertificate = certificate
|
|
});
|
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
|
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={new string('0', 64)}";
|
|
|
|
var connect = () => connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
|
|
await connect.Should().ThrowAsync<IOException>()
|
|
.WithMessage("*certificate*pin*");
|
|
}
|
|
|
|
[Test]
|
|
public async Task NativeClientDepartureCannotBreakProviderBroadcast()
|
|
{
|
|
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
|
|
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
|
|
using var certificate = CreateCertificate();
|
|
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
|
|
{
|
|
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
|
|
ServerCertificate = certificate
|
|
});
|
|
var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
|
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
|
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
|
var departedClient = await connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
using var healthyClient = await connector.ConnectAsync(
|
|
new CultMeshTransportCandidate(endpoint),
|
|
new CultMeshSessionTarget("aetheria", "aetheria.local"));
|
|
await WaitUntilAsync(() => server.ConnectionCount == 2);
|
|
await server.BroadcastAsync(Frame(sequence: 1));
|
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await departedClient.ReceiveAsync(receiveTimeout.Token);
|
|
await healthyClient.ReceiveAsync(receiveTimeout.Token);
|
|
|
|
departedClient.Dispose();
|
|
|
|
for (var sequence = 2; sequence <= 100; sequence++)
|
|
await server.BroadcastAsync(Frame(sequence)).WaitAsync(TimeSpan.FromSeconds(1));
|
|
|
|
CultMeshRealtimeFrame received;
|
|
do
|
|
{
|
|
received = await healthyClient.ReceiveAsync(receiveTimeout.Token);
|
|
}
|
|
while (received.Sequence < 100);
|
|
|
|
received.Sequence.Should().Be(100,
|
|
"a departed observer cannot stall provider publication or starve healthy peers");
|
|
}
|
|
|
|
private static CultMeshRealtimeFrame Frame(long sequence) => new()
|
|
{
|
|
ChannelId = "aetheria.entities",
|
|
SchemaId = "gamecult.aetheria.entity_soa.v1",
|
|
BodyId = "aetheria.entities.current-zone",
|
|
ProducerEpoch = 17,
|
|
Sequence = sequence,
|
|
Delivery = CultMeshRealtimeDelivery.LatestOnly,
|
|
Payload = new byte[] { 1, 3, 3, 7 }
|
|
};
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> predicate)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!predicate())
|
|
await Task.Delay(10, timeout.Token);
|
|
}
|
|
|
|
private static X509Certificate2 CreateCertificate()
|
|
{
|
|
using var key = RSA.Create(2048);
|
|
var request = new CertificateRequest(
|
|
"CN=localhost",
|
|
key,
|
|
HashAlgorithmName.SHA256,
|
|
RSASignaturePadding.Pkcs1);
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
|
|
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
|
using var generated = request.CreateSelfSigned(
|
|
DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
DateTimeOffset.UtcNow.AddMinutes(10));
|
|
return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null);
|
|
}
|
|
}
|