#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using GameCult.Logging;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
///
/// A schema server's poll serves every peer whatever one peer sends. A message that cannot be
/// decoded or handled is that peer's failure: its session ends, the drop is counted and logged,
/// and the drain goes on to the other peers.
///
public class CultNetRudpSchemaServerPeerInputTests
{
private const uint ConnectionId = 0x10203096;
private const string Canary = "canary-5f1c9e0b";
private sealed class Client
{
public Socket Socket = null!;
public CultNetRudpSession Session = null!;
public EndPoint EndPoint => Socket.LocalEndPoint!;
}
private sealed class CapturingLogger : ILogger
{
public List Errors { get; } = new();
public void LogInfo(string message) { }
public void LogWarning(string message) { }
public void LogError(string message) => Errors.Add(message);
public void LogDebug(string message) { }
}
private static Socket Bind()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return socket;
}
private static List Drain(Socket socket)
{
var packets = new List();
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;
}
private static async Task Connect(RudpCultNetSchemaServer server)
{
var client = new Client
{
Socket = Bind(),
Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId })
};
client.Socket.SendTo(CultNetRudpPacketCodec.Encode(client.Session.CreateConnect(0)), server.LocalEndPoint);
await server.PollAvailableAsync(8);
client.Session.Receive(Drain(client.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
return client;
}
private static void SendSchema(RudpCultNetSchemaServer server, Client client, byte[] payload) =>
client.Socket.SendTo(
CultNetRudpPacketCodec.Encode(client.Session.Send("schema", payload, new CultNetRudpSendOptions { Reliable = true, Ordered = true })),
server.LocalEndPoint);
private static byte[] Malformed() => new byte[] { 0xc1 };
// Well-formed MessagePack naming a schema version no runtime knows. The decoder's own error
// quotes that version.
private static byte[] UnknownSchemaVersion() =>
MessagePackSerializer.Serialize(
new Dictionary { ["schemaVersion"] = "cultnet." + Canary + ".v0", ["messageId"] = Canary },
CultNetSchemaMessageSerialization.Options);
private sealed class Scene : IDisposable
{
public RudpCultNetSchemaServer Server = null!;
public Client Bad = null!;
public Client Good = null!;
public CapturingLogger Logger = new();
public List<(string MessageId, EndPoint From)> Served = new();
public List Ended = new();
public void Dispose()
{
Server.Dispose();
Bad.Socket.Dispose();
Good.Socket.Dispose();
}
}
// The bad peer's frame is read first, so the drain reaches the good peer only by getting past it.
private static async Task BadThenGood(byte[] badPayload)
{
var scene = new Scene
{
Server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-peer-input",
ConnectionId = ConnectionId
})
};
scene.Server.Logger = scene.Logger;
scene.Server.OnCultNet((message, peer) =>
scene.Served.Add((message.MessageId, peer.RemoteEndPoint)));
scene.Server.PeerDisconnected += peer => scene.Ended.Add(((RudpCultNetSchemaServerPeer)peer).RemoteEndPoint);
scene.Bad = await Connect(scene.Server);
scene.Good = await Connect(scene.Server);
SendSchema(scene.Server, scene.Bad, badPayload);
SendSchema(scene.Server, scene.Good, CultNetSchemaMessageSerialization.Serialize(
new CultNetShardCatalogRequestMessage { MessageId = "good-request" }));
return scene;
}
[Test]
public async Task AMalformedPayloadDoesNotStopTheDrainServingAnotherPeer()
{
using var scene = await BadThenGood(Malformed());
Assert.DoesNotThrowAsync(async () => await scene.Server.PollAvailableAsync(16));
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
}
[Test]
public async Task AnUnknownSchemaVersionDoesNotStopTheDrainServingAnotherPeer()
{
using var scene = await BadThenGood(UnknownSchemaVersion());
Assert.DoesNotThrowAsync(async () => await scene.Server.PollAvailableAsync(16));
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
}
[Test]
public async Task PollOnceDoesNotThrowForAPeersUndecodableMessage()
{
using var scene = await BadThenGood(Malformed());
Assert.DoesNotThrowAsync(async () =>
{
for (var attempt = 0; attempt < 16; attempt++)
await scene.Server.PollOnceAsync();
});
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
}
[Test]
public async Task APeerWhoseMessageCannotBeDecodedHasItsSessionEnded()
{
using var scene = await BadThenGood(Malformed());
var dropped = scene.Server.Stats.PacketsDropped;
try { await scene.Server.PollAvailableAsync(16); }
catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); }
// The frame was already acknowledged, so the session cannot be kept: the peer is told,
// the listener forgets it, and the drop is counted.
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }));
Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint }));
Assert.That(scene.Server.Stats.PacketsDropped, Is.EqualTo(dropped + 1));
var goodbye = Drain(scene.Bad.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect);
Assert.That(scene.Bad.Session.Receive(goodbye, 1).Disconnected, Is.True);
Assert.That(Drain(scene.Good.Socket).Select(packet => packet.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect));
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
}
// The peer's malformed message and a well-formed one after it arrive in one datagram's
// delivery: the second is held for order, and both are released together. The session ends
// on the first, so the second belongs to no session and is never handled.
[Test]
public async Task ARefusedPeersFramesStillQueuedAreNotHandled()
{
var logger = new CapturingLogger();
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-peer-input",
ConnectionId = ConnectionId
});
server.Logger = logger;
var served = new List();
server.OnCultNet((message, _) => served.Add(message.MessageId));
var bad = await Connect(server);
var options = new CultNetRudpSendOptions { Reliable = true, Ordered = true };
var malformed = bad.Session.Send("schema", Malformed(), options);
var after = bad.Session.Send("schema", CultNetSchemaMessageSerialization.Serialize(
new CultNetShardCatalogRequestMessage { MessageId = "after-the-refused-one" }), options);
bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(after), server.LocalEndPoint);
bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(malformed), server.LocalEndPoint);
await server.PollAvailableAsync(16);
Assert.That(served, Is.Empty);
Assert.That(server.Peers, Is.Empty);
Assert.That(logger.Errors, Has.Count.EqualTo(1));
bad.Socket.Dispose();
}
private static async Task<(RudpCultNetSchemaServer Server, Client Client, CapturingLogger Logger)> ServerWhoseHandlerAnswers(
byte[] response, int? maxFragmentBytes, int? unsendableAfter = null)
{
var logger = new CapturingLogger();
var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-peer-input",
ConnectionId = ConnectionId,
MaxFragmentBytes = maxFragmentBytes
});
server.Logger = logger;
server.OnCultNet((_, peer) =>
{
if (unsendableAfter is { } count)
server.Transport.UnsendableAfter[peer.RemoteEndPoint] = count;
server.Transport.SendSchema(peer.TransportPeer, response);
});
var client = await Connect(server);
SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(
new CultNetShardCatalogRequestMessage { MessageId = "ask" }));
return (server, client, logger);
}
// A handler whose own response can never be sent (larger than a datagram, unfragmented) fails
// on the server's account, not the peer's. The session still ends, but its goodbye does not say
// the peer's packet was refused.
[Test]
public async Task AHandlerFaultEndsTheSessionWithAReasonThatDoesNotBlameThePeer()
{
var (server, client, logger) = await ServerWhoseHandlerAnswers(new byte[70_000], maxFragmentBytes: null);
using (server)
{
await server.PollAvailableAsync(16);
var goodbye = Drain(client.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect);
Assert.That(Encoding.UTF8.GetString(goodbye.Payload!), Is.EqualTo("server could not handle a message"));
Assert.That(server.Peers, Is.Empty);
Assert.That(logger.Errors, Has.Count.EqualTo(1));
Assert.That(logger.Errors[0], Does.Contain("handler failed (SocketException)"));
Assert.That(logger.Errors[0], Does.Contain("that peer's session ended."));
}
client.Socket.Dispose();
}
// A response that fails after its first fragment has already ended the session over the send,
// with a goodbye that names the send failure. The backstop ends nothing more and says so.
[Test]
public async Task AHandlerWhoseResponseEndedTheSessionIsLoggedAsAlreadyEnded()
{
var (server, client, logger) = await ServerWhoseHandlerAnswers(new byte[3000], maxFragmentBytes: 1000, unsendableAfter: 1);
using (server)
{
await server.PollAvailableAsync(16);
await server.PollAvailableAsync(16);
var goodbyes = Drain(client.Socket).Where(packet => packet.PacketType == CultNetRudpPacketType.Disconnect).ToList();
Assert.That(goodbyes, Has.Count.EqualTo(1));
Assert.That(Encoding.UTF8.GetString(goodbyes[0].Payload!), Does.StartWith("packet could not be sent: "));
Assert.That(server.Peers, Is.Empty);
Assert.That(logger.Errors, Has.Count.EqualTo(1));
Assert.That(logger.Errors[0], Does.Contain("that peer's session had already ended."));
}
client.Socket.Dispose();
}
[Test]
public async Task TheDropIsLoggedByErrorTypeWithoutEchoingWhatThePeerSent()
{
using var scene = await BadThenGood(UnknownSchemaVersion());
try { await scene.Server.PollAvailableAsync(16); }
catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); }
// The decoder's own error message quotes the unknown version, which is the peer's input.
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
Assert.That(scene.Logger.Errors[0], Does.Contain(nameof(MessagePackSerializationException)));
Assert.That(scene.Logger.Errors[0], Does.Not.Contain(Canary));
}
}
}