Ack Cut D (C#): a failed send to one peer is that peer's lost datagram
CultNetRudpSocketTransportServer.SendPacket let a SocketException escape, so the first failed SendTo ended PollResends and TryReceiveOnce for every peer. SendPacket now counts the failure in CultNetTransportStats.SendFailures and returns; only an encode failure throws. The refusal goodbye's own catch is redundant and goes. The tests inject the failure at SendPacket through an internal FailingSendPeers set, exposed to GameCult.Networking.Tests by InternalsVisibleTo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
def93c5d89
commit
8e609284d2
3 changed files with 166 additions and 11 deletions
|
|
@ -39,6 +39,12 @@ namespace GameCult.Networking
|
|||
/// the peer, or a packet the session refuses.
|
||||
/// </summary>
|
||||
public long PacketsDropped { get; internal set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<EndPoint> FailingSendPeers { get; } = new HashSet<EndPoint>();
|
||||
|
||||
private static string RemoteKey(EndPoint endpoint)
|
||||
{
|
||||
return endpoint.ToString() ?? string.Empty;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameCult.Networking.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\contracts\cultnet\*.schema.json" LogicalName="GameCult.Networking.Contracts.%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
133
tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs
Normal file
133
tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>FailingSendPeers</c>.
|
||||
/// </summary>
|
||||
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<CultNetRudpPacket> Drain(Socket socket)
|
||||
{
|
||||
var packets = new List<CultNetRudpPacket>();
|
||||
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<string>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue