CultLib/tools/GameCult.TransportParity/Program.cs

554 lines
22 KiB
C#

using System.Diagnostics;
using System.Buffers.Binary;
using System.IO.MemoryMappedFiles;
using System.Net;
using System.Net.Quic;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using GameCult.Caching;
using GameCult.Mesh;
using GameCult.Mesh.Quic;
using GameCult.Networking;
var payloadBytes = ReadPayloadBytes(args);
var payload = new byte[payloadBytes];
new Random(0xAE7E).NextBytes(payload);
var expectedHash = Convert.ToHexString(SHA256.HashData(payload));
Console.WriteLine($"payloadBytes={payloadBytes} quicSupported={QuicListener.IsSupported}");
if (!args.Contains("--state-only", StringComparer.Ordinal))
{
Print(MeasureMappedFile(payload));
var tcp = await MeasureTcpAsync(payload, expectedHash);
Print(tcp);
Print(await MeasureCultMeshTcpAsync(payload));
if (QuicListener.IsSupported)
Print(await MeasureQuicAsync(payload, expectedHash));
else
Console.WriteLine("plane=quic status=unsupported");
Print(await MeasureCultMeshRudpAsync(payload));
}
var statePayloadBytes = ReadPositiveOption(args, "--state-bytes", 16 * 1024);
var stateFrameCount = ReadPositiveOption(args, "--state-frames", 1_000);
Console.WriteLine($"statePayloadBytes={statePayloadBytes} stateFrames={stateFrameCount}");
if (QuicListener.IsSupported)
PrintState(await MeasureCultMeshQuicStateAsync(statePayloadBytes, stateFrameCount));
else
Console.WriteLine("plane=cultmesh-quic-latest status=unsupported");
PrintState(await MeasureCultNetRudpLatestStateAsync(statePayloadBytes, stateFrameCount));
static int ReadPayloadBytes(string[] args)
{
const int defaultBytes = 56_204_750;
var index = Array.IndexOf(args, "--bytes");
if (index < 0) return defaultBytes;
if (index + 1 >= args.Length || !int.TryParse(args[index + 1], out var bytes) || bytes <= 0)
throw new ArgumentException("--bytes requires a positive 32-bit integer.");
return bytes;
}
static int ReadPositiveOption(string[] args, string option, int defaultValue)
{
var index = Array.IndexOf(args, option);
if (index < 0) return defaultValue;
if (index + 1 >= args.Length || !int.TryParse(args[index + 1], out var value) || value <= 0)
throw new ArgumentException($"{option} requires a positive 32-bit integer.");
return value;
}
static void Print(Result result) =>
Console.WriteLine(
$"plane={result.Plane} elapsedMs={result.Elapsed.TotalMilliseconds:F1} " +
(result.GoodputMiBps.HasValue ? $"goodputMiBps={result.GoodputMiBps.Value:F1}" : "") +
(result.WireBytes.HasValue ? $" wireBytes={result.WireBytes.Value}" : string.Empty));
static void PrintState(StateResult result) =>
Console.WriteLine(
$"plane={result.Plane} sendElapsedMs={result.SendElapsed.TotalMilliseconds:F1} " +
$"settledElapsedMs={result.SettledElapsed.TotalMilliseconds:F1} " +
$"offeredMiBps={result.OfferedMiBps:F1} deliveredFrames={result.DeliveredFrames}/{result.SentFrames} " +
$"highestSequence={result.HighestSequence}" +
(result.WireBytes.HasValue ? $" wireBytes={result.WireBytes.Value}" : string.Empty));
static Result MeasureMappedFile(byte[] payload)
{
var source = TempPath("mapped-source");
File.WriteAllBytes(source, payload);
var elapsed = Stopwatch.StartNew();
try
{
using var mapping = MemoryMappedFile.CreateFromFile(
source, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
using var view = mapping.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
if (view.ReadByte(0) != payload[0] || view.ReadByte(payload.LongLength - 1) != payload[^1])
throw new InvalidDataException("Mapped body did not expose the provider file.");
elapsed.Stop();
return Result.CreateOpen("mapped-file-open", elapsed.Elapsed);
}
finally
{
File.Delete(source);
}
}
static async Task<Result> MeasureTcpAsync(byte[] payload, string expectedHash)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
var server = Task.Run(async () =>
{
using var peer = await listener.AcceptTcpClientAsync();
await peer.GetStream().WriteAsync(payload);
peer.Client.Shutdown(SocketShutdown.Send);
});
var output = TempPath("tcp");
var elapsed = Stopwatch.StartNew();
using (var client = new TcpClient())
{
await client.ConnectAsync(endpoint.Address, endpoint.Port);
await using var file = new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.None,
1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
await client.GetStream().CopyToAsync(file);
await file.FlushAsync();
}
await server;
VerifyAndDelete(output, expectedHash);
elapsed.Stop();
return Result.Create("tcp", payload.LongLength, elapsed.Elapsed);
}
static async Task<Result> MeasureQuicAsync(byte[] payload, string expectedHash)
{
var protocol = new SslApplicationProtocol("cultmesh-parity");
using var certificate = CreateCertificate();
await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ApplicationProtocols = [protocol],
ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions
{
DefaultCloseErrorCode = 0,
DefaultStreamErrorCode = 0,
ServerAuthenticationOptions = new SslServerAuthenticationOptions
{
ApplicationProtocols = [protocol],
EnabledSslProtocols = SslProtocols.Tls13,
ServerCertificate = certificate
}
})
});
var server = Task.Run(async () =>
{
await using var connection = await listener.AcceptConnectionAsync();
await using var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Unidirectional);
await stream.WriteAsync(payload);
stream.CompleteWrites();
await stream.WritesClosed;
});
var output = TempPath("quic");
var elapsed = Stopwatch.StartNew();
await using (var connection = await QuicConnection.ConnectAsync(new QuicClientConnectionOptions
{
RemoteEndPoint = listener.LocalEndPoint,
DefaultCloseErrorCode = 0,
DefaultStreamErrorCode = 0,
MaxInboundUnidirectionalStreams = 1,
ClientAuthenticationOptions = new SslClientAuthenticationOptions
{
ApplicationProtocols = [protocol],
EnabledSslProtocols = SslProtocols.Tls13,
RemoteCertificateValidationCallback = (_, _, _, _) => true
}
}))
await using (var stream = await connection.AcceptInboundStreamAsync())
await using (var file = new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.None,
1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
await stream.CopyToAsync(file);
await file.FlushAsync();
}
await server;
VerifyAndDelete(output, expectedHash);
elapsed.Stop();
return Result.Create("quic-msquic", payload.LongLength, elapsed.Elapsed);
}
static async Task<StateResult> MeasureCultMeshQuicStateAsync(int payloadBytes, int frameCount)
{
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(
new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var endpoint = $"{CultMeshQuicRealtimeTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
Array.Empty<ICultMeshContentTransportConnector>(),
[new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
{
ValidateProviderCertificate = (_, _, _, _) => true
})],
LocalDevelopmentSessions());
var session = await sessions.ConnectRealtimeAsync(
new CultMeshSessionTarget("parity", "service:parity.provider"));
var deadline = DateTime.UtcNow.AddSeconds(5);
while (server.ConnectionCount == 0)
{
if (DateTime.UtcNow >= deadline) throw new TimeoutException("QUIC state client did not connect.");
await Task.Delay(1);
}
var receivedFrames = 0;
var highestSequence = -1L;
using var receiveDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var receive = Task.Run(async () =>
{
while (true)
{
var frame = await session.ReceiveAsync(receiveDeadline.Token);
receivedFrames++;
highestSequence = Math.Max(highestSequence, frame.Sequence);
if (frame.ProducerEpoch == 1 && frame.Sequence == frameCount - 1) return;
}
});
var payload = new byte[payloadBytes];
var elapsed = Stopwatch.StartNew();
for (var sequence = 0; sequence < frameCount; sequence++)
{
await server.BroadcastAsync(new CultMeshRealtimeFrame
{
ChannelId = "parity.entities",
SchemaId = "parity.entity_soa.v1",
BodyId = "body:parity:entities",
ProducerEpoch = 1,
Sequence = sequence,
Delivery = CultMeshRealtimeDelivery.LatestOnly,
Payload = payload
});
}
var sendElapsed = elapsed.Elapsed;
await receive;
elapsed.Stop();
return StateResult.Create(
"cultmesh-quic-latest",
payloadBytes,
frameCount,
receivedFrames,
highestSequence,
sendElapsed,
elapsed.Elapsed);
}
static async Task<StateResult> MeasureCultNetRudpLatestStateAsync(int payloadBytes, int frameCount)
{
using var serverSocket = BindUdpSocket();
using var clientSocket = BindUdpSocket();
const uint connectionId = 0x50415249;
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "parity-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = 100,
MaxFragmentBytes = 1024
});
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "parity-rudp-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = serverSocket.LocalEndPoint,
ConnectionId = connectionId,
InitialSequence = 1,
MaxFragmentBytes = 1024
});
client.Connect("parity");
var handshakeDeadline = DateTime.UtcNow.AddSeconds(5);
while (!client.Connected || !server.Connected)
{
server.ReceiveOnce();
client.ReceiveOnce();
if (DateTime.UtcNow >= handshakeDeadline) throw new TimeoutException("RUDP state peers did not connect.");
}
var receivedFrames = 0;
var highestSequence = -1L;
using var receiveDeadline = new CancellationTokenSource();
var receive = Task.Run(() =>
{
while (!receiveDeadline.IsCancellationRequested)
{
var frame = server.ReceiveOnce();
if (frame == null) continue;
receivedFrames++;
highestSequence = Math.Max(highestSequence, BinaryPrimitives.ReadInt64LittleEndian(frame.Payload));
}
});
var payload = new byte[Math.Max(payloadBytes, sizeof(long))];
var elapsed = Stopwatch.StartNew();
for (var sequence = 0; sequence < frameCount; sequence++)
{
BinaryPrimitives.WriteInt64LittleEndian(payload, sequence);
client.SendLatest(payload);
}
var sendElapsed = elapsed.Elapsed;
await Task.Delay(250);
receiveDeadline.Cancel();
await receive;
elapsed.Stop();
return StateResult.Create(
"cultnet-rudp-latest",
payload.Length,
frameCount,
receivedFrames,
highestSequence,
sendElapsed,
elapsed.Elapsed,
client.Stats.BytesSent);
}
static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
{
var artifact = CultMesh.PackCdnArtifact(
"parity/body", payload, new CultMeshCdnPackOptions { ChunkSizeBytes = 1024 * 1024 });
using var providerCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
await CultMeshCdn.PublishAsync(providerCache, artifact);
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
using var wireServer = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "cultmesh-transport-parity",
Socket = socket,
MaxFragmentBytes = 1024,
MaxPendingReliablePackets = 8192
});
using var contentServer = new CultMeshLegacyRudpContentServer(wireServer, providerCache);
using var identityServer = new CultMeshSessionIdentityServer(
wireServer,
"service:parity.provider",
["parity"],
[CultMeshProtocols.Content.Value],
["parity-route-1"]);
using var pumpCancellation = new CancellationTokenSource();
var pump = Task.Run(async () =>
{
while (!pumpCancellation.IsCancellationRequested)
{
var progress = await wireServer.PollAvailableAsync(256);
wireServer.PollResends();
if (progress.TransportItemsConsumed == 0)
await Task.Delay(1, pumpCancellation.Token);
}
});
var endpoint = $"rudp://127.0.0.1:{wireServer.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
[new CultMeshLegacyRudpContentTransportConnector(
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })],
LocalDevelopmentSessions());
var provider = new CultMeshSessionContentProvider(
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshContentTransferStateDocument)));
var transfer = new CultMeshContentTransferService(
transferState, [provider], new CultMeshContentTransferOptions(directory));
var elapsed = Stopwatch.StartNew();
try
{
var path = await transfer.FetchAsync(artifact.Manifest);
elapsed.Stop();
if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(payload))
throw new InvalidDataException("CultMesh RUDP payload did not match source.");
return Result.Create("cultmesh-rudp", payload.LongLength, elapsed.Elapsed, wireServer.Stats.BytesSent);
}
finally
{
pumpCancellation.Cancel();
try { await pump; } catch (OperationCanceledException) { }
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
static async Task<Result> MeasureCultMeshTcpAsync(byte[] payload)
{
var artifact = CultMesh.PackCdnArtifact(
"parity/body", payload, new CultMeshCdnPackOptions { ChunkSizeBytes = 1024 * 1024 });
using var providerCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk)));
await CultMeshCdn.PublishAsync(providerCache, artifact);
using var contentServer = new CultMeshTcpContentServer(
new TcpListener(IPAddress.Loopback, 0),
providerCache,
new CultMeshTcpContentServerOptions
{
VerseId = "parity",
AuthorityRuntimeId = "service:parity.provider",
RouteGeneration = "parity-route-1"
});
var endpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://127.0.0.1:{contentServer.LocalEndPoint.Port}";
using var discovery = new CultMeshDiscoveryService([new RouteSource(endpoint)]);
using var sessions = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
[new CultMeshTcpContentTransportConnector()],
LocalDevelopmentSessions());
var provider = new CultMeshSessionContentProvider(
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
using var transferState = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshContentTransferStateDocument)));
var transfer = new CultMeshContentTransferService(
transferState, [provider], new CultMeshContentTransferOptions(directory));
var elapsed = Stopwatch.StartNew();
try
{
var path = await transfer.FetchAsync(artifact.Manifest);
elapsed.Stop();
if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(payload))
throw new InvalidDataException("CultMesh TCP payload did not match source.");
return Result.Create("cultmesh-tcp-content", payload.LongLength, elapsed.Elapsed);
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, true);
}
}
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 X509EnhancedKeyUsageExtension(
new OidCollection { new("1.3.6.1.5.5.7.3.1") }, false));
var names = new SubjectAlternativeNameBuilder();
names.AddDnsName("localhost");
names.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(names.Build());
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1));
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null);
}
static string TempPath(string plane) =>
Path.Combine(Path.GetTempPath(), $"cultmesh-parity-{plane}-{Guid.NewGuid():N}.body");
static CultMeshSessionManagerOptions LocalDevelopmentSessions() => new()
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
};
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;
}
static void VerifyAndDelete(string path, string expectedHash)
{
try
{
using var input = File.OpenRead(path);
var actualHash = Convert.ToHexString(SHA256.HashData(input));
if (!actualHash.Equals(expectedHash, StringComparison.Ordinal))
throw new InvalidDataException($"Body hash mismatch: expected {expectedHash}, got {actualHash}.");
}
finally
{
File.Delete(path);
}
}
internal sealed class RouteSource(string endpoint) : ICultMeshLookupSource
{
public string SourceId => "parity-route";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
IReadOnlyList<CultMeshDiscoveryObservation> observations =
[
new(
new CultMeshVerseDescriptor(
"parity", "Transport parity", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v1", "parity"),
authorityRoutes:
[
new CultMeshAuthorityRoute(
"service:parity.provider",
endpoint,
generation: "parity-route-1")
]),
SourceId, now, now.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
];
return Task.FromResult(observations);
}
}
internal sealed record Result(string Plane, TimeSpan Elapsed, double? GoodputMiBps, long? WireBytes)
{
public static Result Create(string plane, long bytes, TimeSpan elapsed, long? wireBytes = null) =>
new(plane, elapsed, bytes / 1024d / 1024d / elapsed.TotalSeconds, wireBytes);
public static Result CreateOpen(string plane, TimeSpan elapsed) =>
new(plane, elapsed, null, null);
}
internal sealed record StateResult(
string Plane,
int SentFrames,
int DeliveredFrames,
long HighestSequence,
TimeSpan SendElapsed,
TimeSpan SettledElapsed,
double OfferedMiBps,
long? WireBytes)
{
public static StateResult Create(
string plane,
int payloadBytes,
int sentFrames,
int deliveredFrames,
long highestSequence,
TimeSpan sendElapsed,
TimeSpan settledElapsed,
long? wireBytes = null) =>
new(
plane,
sentFrames,
deliveredFrames,
highestSequence,
sendElapsed,
settledElapsed,
(long)payloadBytes * sentFrames / 1024d / 1024d / sendElapsed.TotalSeconds,
wireBytes);
}