diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index e98fdc03..4554eacd 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -39,6 +39,12 @@ namespace GameCult.Networking /// the peer, or a packet the session refuses. /// public long PacketsDropped { get; internal set; } + /// + /// Gets the number of datagrams a multi-peer server could not send to one peer. Each is + /// that peer's lost datagram: a reliable packet stays pending and is resent, and no other + /// peer is affected. Zero for single-peer transports, whose callers get the send error. + /// + public long SendFailures { get; internal set; } internal CultNetTransportStats Snapshot() { @@ -48,7 +54,8 @@ namespace GameCult.Networking FramesReceived = FramesReceived, BytesSent = BytesSent, BytesReceived = BytesReceived, - PacketsDropped = PacketsDropped + PacketsDropped = PacketsDropped, + SendFailures = SendFailures }; } } @@ -2734,14 +2741,8 @@ namespace GameCult.Networking if (outcome == null) { - try - { - SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.EndRefused()); - } - catch (SocketException) - { - // Best effort: the session ends whether or not the peer hears it. - } + // The session ends whether or not the peer hears the goodbye. + SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.EndRefused()); } else { @@ -2826,13 +2827,30 @@ namespace GameCult.Networking _socket.Dispose(); } + // A failed send is that peer's lost datagram: counted, never thrown. A reliable packet + // stays pending and is resent; an unreliable one was allowed to be lost. The peer's + // session ends by the rules that already end sessions. Only an encode failure, the + // listener's own bug, throws. private void SendPacket(EndPoint remoteEndPoint, CultNetRudpPacket packet) { var wire = CultNetRudpPacketCodec.Encode(packet); - var sent = _socket.SendTo(wire, remoteEndPoint); - _stats.BytesSent += sent; + try + { + if (FailingSendPeers.Contains(remoteEndPoint)) + throw new SocketException((int)SocketError.HostUnreachable); + var sent = _socket.SendTo(wire, remoteEndPoint); + _stats.BytesSent += sent; + } + catch (SocketException) + { + _stats.SendFailures++; + } } + // Test seam: peers whose every datagram fails to send, standing in for an unroutable or + // full path that a loopback peer cannot be made to have. + internal HashSet FailingSendPeers { get; } = new HashSet(); + private static string RemoteKey(EndPoint endpoint) { return endpoint.ToString() ?? string.Empty; diff --git a/src/GameCult.Networking/GameCult.Networking.csproj b/src/GameCult.Networking/GameCult.Networking.csproj index 1eef5653..08ef2ed6 100644 --- a/src/GameCult.Networking/GameCult.Networking.csproj +++ b/src/GameCult.Networking/GameCult.Networking.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs new file mode 100644 index 00000000..162ad4c6 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs @@ -0,0 +1,133 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + /// + /// One peer whose datagrams cannot be sent must not stop the listener serving the others. The + /// failure is injected at the listener's one send function through FailingSendPeers. + /// + public class CultNetRudpSendFailureTests + { + private const uint ConnectionId = 0x10203099; + + private sealed class Peer + { + public Socket Socket = null!; + public CultNetRudpSession Session = null!; + public CultNetRudpSocketServerPeer Server = null!; + public EndPoint EndPoint => Socket.LocalEndPoint!; + } + + 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 Peer Connect(CultNetRudpSocketTransportServer server, EndPoint serverEndPoint) + { + var peer = new Peer + { + Socket = Bind(), + Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId }) + }; + peer.Socket.SendTo(CultNetRudpPacketCodec.Encode(peer.Session.CreateConnect(0)), serverEndPoint); + server.ReceiveOnce(); + peer.Session.Receive(Drain(peer.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0); + peer.Server = server.Peers.Single(p => p.RemoteEndPoint.Equals(peer.EndPoint)); + return peer; + } + + private static (CultNetRudpSocketTransportServer Server, EndPoint ServerEndPoint, Peer X, Peer Y) TwoPeers() + { + var serverSocket = Bind(); + var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions + { + RuntimeId = "csharp-send-failure", + Socket = serverSocket, + ConnectionId = ConnectionId, + ResendDelayMs = 5 + }); + var x = Connect(server, serverSocket.LocalEndPoint!); + var y = Connect(server, serverSocket.LocalEndPoint!); + return (server, serverSocket.LocalEndPoint!, x, y); + } + + private static CultNetRudpPacket ReliableFrame(Peer peer, string text) => + peer.Session.Send("schema", Encoding.UTF8.GetBytes(text), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); + + [Test] + public void AFailedResendToOnePeerDoesNotStopTheOthersResends() + { + var (server, _, x, y) = TwoPeers(); + using (server) + { + // Both peers' first datagram is lost, so both have a reliable resend due. + server.FailingSendPeers.Add(x.EndPoint); + server.FailingSendPeers.Add(y.EndPoint); + server.SendSchema(x.Server, "for-x"); + server.SendSchema(y.Server, "for-y"); + Assert.That(server.Stats.SendFailures, Is.EqualTo(2)); + + // Y's path recovers; X's stays dead. X is visited first. + server.FailingSendPeers.Remove(y.EndPoint); + Thread.Sleep(30); + Assert.DoesNotThrow(() => server.PollResends()); + + var resent = Drain(y.Socket).Where(p => p.PacketType == CultNetRudpPacketType.Data).ToList(); + Assert.That(resent, Has.Count.EqualTo(1)); + Assert.That(Encoding.UTF8.GetString(resent[0].Payload!), Is.EqualTo("for-y")); + Assert.That(server.Stats.SendFailures, Is.GreaterThanOrEqualTo(3)); + Assert.That(server.Peers, Has.Count.EqualTo(2), "a send failure ends no session"); + } + } + + [Test] + public void AFailedAckToOnePeerDoesNotStopTheListenerServingTheOthers() + { + var (server, listenerEndPoint, x, y) = TwoPeers(); + using (server) + { + server.FailingSendPeers.Add(x.EndPoint); + x.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(x, "from-x")), listenerEndPoint); + y.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(y, "from-y")), listenerEndPoint); + + var frames = new List(); + for (var attempt = 0; attempt < 20 && frames.Count < 2; attempt++) + { + Assert.DoesNotThrow(() => + { + if (server.ReceiveOnce() is { } frame) + frames.Add(Encoding.UTF8.GetString(frame.Frame.Payload)); + }); + } + + Assert.That(frames, Is.EquivalentTo(new[] { "from-x", "from-y" })); + Assert.That(server.Stats.SendFailures, Is.GreaterThanOrEqualTo(1), "X's ack was counted as lost"); + Assert.That(Drain(y.Socket).Select(p => p.PacketType), Does.Contain(CultNetRudpPacketType.Ack)); + Assert.That(server.Peers, Has.Count.EqualTo(2)); + } + } + } +}