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));
+ }
+ }
+ }
+}