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:
Meta Vens 2026-09-30 00:51:29 +02:00
commit 8e609284d2
3 changed files with 166 additions and 11 deletions

View file

@ -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;

View file

@ -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>

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