GameCult.Networking: an ended session fails its flush; a repeated Connect owes one Accept
FlushReliable throws for writes an ended session forgot. A server-mode transport and the listener answer a Connect from the peer they already hold with AnswerRepeatedConnect: the Accept still owed, or an Ack, queueing nothing. The listener replaced the peer session on every Connect, reusing sequences and never reporting PeerDisconnected; it now replaces only on a different connect payload and reports the old peer, as the Rust hub does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4814aea8b0
commit
ca18dfac55
2 changed files with 166 additions and 2 deletions
|
|
@ -1025,6 +1025,36 @@ namespace GameCult.Networking
|
|||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers a connect from a peer this session has already accepted. The packet's sequence is
|
||||
/// remembered and nothing is queued, so a connect storm cannot grow the reliable queue. The reply
|
||||
/// is the accept still awaiting acknowledgement, or an ack once it was acknowledged.
|
||||
/// </summary>
|
||||
public CultNetRudpPacket AnswerRepeatedConnect(CultNetRudpPacket packet, long nowMs = 0)
|
||||
{
|
||||
RequireConnection(packet);
|
||||
if (packet.PacketType != CultNetRudpPacketType.Connect)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected RUDP connect packet, got {packet.PacketType}.");
|
||||
}
|
||||
|
||||
ApplyAcknowledgements(packet);
|
||||
RememberReceived(packet.Sequence);
|
||||
_lastReceivedAtMs = nowMs;
|
||||
lock (_pendingReliableGate)
|
||||
{
|
||||
foreach (var pending in _pendingReliable.Values)
|
||||
{
|
||||
if (pending.Packet.PacketType != CultNetRudpPacketType.Accept)
|
||||
continue;
|
||||
pending.LastSentAtMs = nowMs;
|
||||
return ClonePacket(pending.Packet);
|
||||
}
|
||||
}
|
||||
|
||||
return CreateAck();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a data packet.
|
||||
/// </summary>
|
||||
|
|
@ -1715,6 +1745,7 @@ namespace GameCult.Networking
|
|||
public sealed class CultNetRudpSocketTransportConnection : IDisposable
|
||||
{
|
||||
private const int MinimumReceiveBufferBytes = 4 * 1024 * 1024;
|
||||
private static readonly byte[] ReplacedPeerReason = Encoding.UTF8.GetBytes("replaced by a new Connect generation");
|
||||
// One acknowledgement carries the preceding 32-packet receive mask.
|
||||
// Pace at that transport window instead of sleeping after every fragment;
|
||||
// a one-millisecond sleep can consume a full scheduler quantum on Windows.
|
||||
|
|
@ -1810,6 +1841,7 @@ namespace GameCult.Networking
|
|||
throw new InvalidOperationException("Only a client RUDP socket transport can initiate connect.");
|
||||
}
|
||||
|
||||
DisconnectReason = null;
|
||||
lock (_sessionGate)
|
||||
SendPacket(_session.CreateConnect(NowMs(), payload ?? Array.Empty<byte>()));
|
||||
}
|
||||
|
|
@ -1890,6 +1922,12 @@ namespace GameCult.Networking
|
|||
{
|
||||
while (true)
|
||||
{
|
||||
// An ended session forgot its unacknowledged writes; reporting them
|
||||
// flushed would be a lie.
|
||||
var ended = DisconnectReason;
|
||||
if (ended != null)
|
||||
throw new InvalidOperationException(
|
||||
"RUDP session ended before its reliable writes were acknowledged: " + Encoding.UTF8.GetString(ended));
|
||||
lock (_sessionGate)
|
||||
{
|
||||
if (_session.OutstandingReliablePacketCount == 0)
|
||||
|
|
@ -2186,7 +2224,17 @@ namespace GameCult.Networking
|
|||
CultNetRudpPacket accept;
|
||||
try
|
||||
{
|
||||
accept = _session.AcceptConnect(packet, NowMs());
|
||||
// A connect from the peer this transport already accepted repeats: answer it
|
||||
// with the accept already owed and queue nothing.
|
||||
if (_session.Connected)
|
||||
{
|
||||
accept = _session.AnswerRepeatedConnect(packet, NowMs());
|
||||
}
|
||||
else
|
||||
{
|
||||
accept = _session.AcceptConnect(packet, NowMs());
|
||||
DisconnectReason = null;
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
|
|
@ -2390,12 +2438,14 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public sealed class CultNetRudpSocketServerPeer
|
||||
{
|
||||
internal CultNetRudpSocketServerPeer(EndPoint remoteEndPoint, CultNetRudpSession session)
|
||||
internal CultNetRudpSocketServerPeer(EndPoint remoteEndPoint, byte[] connectPayload, CultNetRudpSession session)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint;
|
||||
ConnectPayload = connectPayload;
|
||||
Session = session;
|
||||
}
|
||||
|
||||
internal byte[] ConnectPayload { get; }
|
||||
internal CultNetRudpSession Session { get; }
|
||||
internal object SessionGate { get; } = new object();
|
||||
|
||||
|
|
@ -2616,8 +2666,32 @@ namespace GameCult.Networking
|
|||
var peerKey = RemoteKey(remote);
|
||||
if (packet.PacketType == CultNetRudpPacketType.Connect)
|
||||
{
|
||||
var connectPayload = packet.Payload ?? Array.Empty<byte>();
|
||||
_peers.TryGetValue(peerKey, out var admitted);
|
||||
// The same payload from an admitted peer is a repeated Connect: answer it with the
|
||||
// Accept already owed and queue nothing. A different payload is a new generation.
|
||||
if (admitted != null && admitted.ConnectPayload.AsSpan().SequenceEqual(connectPayload))
|
||||
{
|
||||
lock (admitted.SessionGate)
|
||||
{
|
||||
CultNetRudpPacket reply;
|
||||
try
|
||||
{
|
||||
reply = admitted.Session.AnswerRepeatedConnect(packet, NowMs());
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
_stats.PacketsDropped++;
|
||||
return true;
|
||||
}
|
||||
SendPacket(admitted.RemoteEndPoint, reply);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var peer = new CultNetRudpSocketServerPeer(
|
||||
CloneEndPoint(remote),
|
||||
connectPayload,
|
||||
new CultNetRudpSession(new CultNetRudpSessionOptions
|
||||
{
|
||||
ConnectionId = _connectionId,
|
||||
|
|
@ -2628,6 +2702,11 @@ namespace GameCult.Networking
|
|||
_peers[peerKey] = peer;
|
||||
lock (peer.SessionGate)
|
||||
SendPacket(peer.RemoteEndPoint, peer.Session.AcceptConnect(packet, NowMs(), _acceptPayload));
|
||||
if (admitted != null)
|
||||
{
|
||||
admitted.DisconnectReason = ReplacedPeerReason;
|
||||
PeerDisconnected?.Invoke(admitted);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1438,6 +1438,91 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(DrainPackets(peerSocket), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpFlushReliable_FailsForAWriteTheEndedSessionForgot()
|
||||
{
|
||||
using var serverSocket = BindUdpSocket();
|
||||
using var peerSocket = BindUdpSocket();
|
||||
var serverEndPoint = serverSocket.LocalEndPoint!;
|
||||
const uint connectionId = 0x10203058;
|
||||
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "csharp-rudp-server",
|
||||
Socket = serverSocket,
|
||||
Mode = CultNetRudpSocketMode.Server,
|
||||
ConnectionId = connectionId
|
||||
});
|
||||
var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId });
|
||||
|
||||
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint);
|
||||
server.ReceiveOnce();
|
||||
peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
|
||||
server.Send("schema", Encoding.UTF8.GetBytes("never acknowledged"));
|
||||
|
||||
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(PoisonedFrame(peer)), serverEndPoint);
|
||||
Assert.That(server.ReceiveOnce(), Is.Null);
|
||||
Assert.That(server.DisconnectReason, Is.Not.Null);
|
||||
Assert.Throws<InvalidOperationException>(() => server.FlushReliable(TimeSpan.FromMilliseconds(200)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpServerMode_OwesOneReliableAcceptHoweverManyConnectsRepeat()
|
||||
{
|
||||
using var serverSocket = BindUdpSocket();
|
||||
using var peerSocket = BindUdpSocket();
|
||||
const uint connectionId = 0x10203059;
|
||||
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "csharp-rudp-server",
|
||||
Socket = serverSocket,
|
||||
Mode = CultNetRudpSocketMode.Server,
|
||||
ConnectionId = connectionId
|
||||
});
|
||||
var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0);
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!);
|
||||
server.ReceiveOnce();
|
||||
}
|
||||
|
||||
var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable);
|
||||
Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpListener_OwesOneReliableAcceptPerGenerationAndReportsAReplacedPeer()
|
||||
{
|
||||
using var serverSocket = BindUdpSocket();
|
||||
using var peerSocket = BindUdpSocket();
|
||||
const uint connectionId = 0x1020305a;
|
||||
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-rudp-listener",
|
||||
Socket = serverSocket,
|
||||
ConnectionId = connectionId
|
||||
});
|
||||
var ended = new List<CultNetRudpSocketServerPeer>();
|
||||
server.PeerDisconnected += ended.Add;
|
||||
var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("join"));
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!);
|
||||
server.ReceiveOnce();
|
||||
}
|
||||
|
||||
var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable);
|
||||
Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
Assert.That(ended, Is.Empty);
|
||||
|
||||
// A different payload from the same endpoint is a new generation, and the old one is reported.
|
||||
var other = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("rejoin"));
|
||||
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(other), serverSocket.LocalEndPoint!);
|
||||
server.ReceiveOnce();
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
Assert.That(ended, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpConfiguration_RejectsLimitsThatCannotAdmitAConnect()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue