Ack Cut 1d (C#): stale generations are duplicates, a stale Connect is answered not obeyed, sessions draw their own sequence

A frame at or before the seeded watermark is a duplicate of an earlier generation and is acknowledged, never delivered again (serial compare within the receive window). A Connect that precedes the current generation's within the window is a stale copy of an earlier attempt: it is answered like a repeat (Accept owed or Ack), changes no state and does not refresh liveness. A Connect unanswered for 3 s is replaced by a fresh Connect with a newly drawn sequence and the same payload. In server mode a Connect from another endpoint is always a new generation. The initial sequence is unset by default and each session draws its own; the public random helper is removed.

An accepted peer now stamps its liveness on accept, so one that goes silent times out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 09:11:52 +02:00
commit 1569f013c6
4 changed files with 417 additions and 53 deletions

View file

@ -543,8 +543,8 @@ namespace GameCult.Mesh
public int BindPort { get; set; }
/// <summary>Gets or sets a caller-owned bound socket.</summary>
public Socket? Socket { get; set; }
/// <summary>Gets or sets the first local packet sequence.</summary>
public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
/// <summary>Gets or sets the first local packet sequence. Unset, each session draws its own at random.</summary>
public uint? InitialSequence { get; set; }
/// <summary>Gets or sets the reliable resend delay in milliseconds.</summary>
public long ResendDelayMs { get; set; } = 250;
/// <summary>Gets or sets the advertised transport id.</summary>

View file

@ -43,9 +43,10 @@ namespace GameCult.Networking
/// </summary>
public uint ConnectionId { get; set; } = 0x43554c54;
/// <summary>
/// Gets or sets the first local packet sequence for each accepted peer.
/// Gets or sets the first local packet sequence for each accepted peer. Unset, each peer's
/// session draws its own at random.
/// </summary>
public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
public uint? InitialSequence { get; set; }
/// <summary>
/// Gets or sets the resend delay in milliseconds.
/// </summary>

View file

@ -768,9 +768,11 @@ namespace GameCult.Networking
/// </summary>
public uint ConnectionId { get; set; }
/// <summary>
/// Gets or sets the first local packet sequence.
/// Gets or sets the first local packet sequence. Unset, each session draws its own at random:
/// every session is a new identity to its peer, and an options object reused for several
/// must not give them one sequence.
/// </summary>
public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
public uint? InitialSequence { get; set; }
/// <summary>
/// Gets or sets the resend delay in milliseconds.
/// </summary>
@ -845,9 +847,11 @@ namespace GameCult.Networking
/// </summary>
public uint ConnectionId { get; set; }
/// <summary>
/// Gets or sets the first local packet sequence.
/// Gets or sets the first local packet sequence. Unset, each session draws its own at random:
/// every session is a new identity to its peer, and an options object reused for several
/// must not give them one sequence.
/// </summary>
public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
public uint? InitialSequence { get; set; }
/// <summary>
/// Gets or sets the resend delay in milliseconds.
/// </summary>
@ -882,6 +886,11 @@ namespace GameCult.Networking
/// <summary>Maximum reliable packets admitted to the wire before acknowledgements advance the window.</summary>
public const int ReliableSendWindowPackets = 32;
private const int ReceivedSequenceWindow = 4096;
// How long a client keeps retransmitting a Connect nobody answered before it abandons that
// attempt for a fresh one. A Connect the server answers with an Ack, never an Accept, is one
// the server judged stale (see AcceptConnect); retransmitting the same sequence would never
// change its mind.
private const long ConnectAttemptMs = 3_000;
private sealed class PendingReliablePacket
{
public CultNetRudpPacket Packet { get; set; } = new CultNetRudpPacket();
@ -914,6 +923,9 @@ namespace GameCult.Networking
private uint? _connectSequence;
// A Connect this side sent is unanswered. Only then is an Accept honoured.
private bool _awaitingAccept;
// When the unanswered Connect first went out; DueResends abandons it for a fresh attempt once
// ConnectAttemptMs has passed.
private long _connectStartedAtMs;
private long? _lastReceivedAtMs;
private uint? _highestReceivedSequence;
private readonly HashSet<uint> _receivedSequences = new HashSet<uint>();
@ -940,31 +952,43 @@ namespace GameCult.Networking
ValidateLimits(options.InitialSequence, options.MaxPendingReliablePackets);
ConnectionId = options.ConnectionId;
_nextSequence = options.InitialSequence;
_nextSequence = options.InitialSequence ?? DrawSequence(1);
ResendDelayMs = options.ResendDelayMs;
_maxPendingReliablePackets = options.MaxPendingReliablePackets;
}
/// <summary>
/// A fresh default initial sequence, drawn from a secure source in [1, 2^31). The Connect's
/// A sequence drawn from a secure source in [<paramref name="floor"/>, 2^31). The Connect's
/// sequence is what tells a peer whether a Connect repeats one it already accepted or starts a
/// new session, so two sessions must not share one by default.
/// new session, so two sessions must not share one by default. A floor above the range draws
/// nothing new: the caller keeps its own sequence.
/// </summary>
public static uint RandomInitialSequence()
private static uint DrawSequence(uint floor)
{
floor = Math.Max(1u, floor);
const uint ceiling = 1u << 31;
if (floor >= ceiling)
return floor;
var bytes = new byte[4];
using (var random = System.Security.Cryptography.RandomNumberGenerator.Create())
random.GetBytes(bytes);
return BitConverter.ToUInt32(bytes, 0) % int.MaxValue + 1;
return floor + BitConverter.ToUInt32(bytes, 0) % (ceiling - floor);
}
// Whether `sequence` is at or before `mark` in serial order, within the receive window. The
// compare is modular, so it holds across the wrap of the 32-bit space; a sequence further back
// than the window is the duplicate test's below-window clause, and one ahead of the mark is
// never before it.
private static bool AtOrBefore(uint sequence, uint mark) =>
unchecked(mark - sequence) < ReceivedSequenceWindow;
internal static readonly byte[] RefusedPacketReason = Encoding.UTF8.GetBytes("session refused a packet");
/// <summary>
/// A session cannot admit a Connect when its sequence space starts exhausted or its reliable
/// queue holds nothing, so neither is a usable configuration.
/// </summary>
internal static void ValidateLimits(uint initialSequence, int? maxPendingReliablePackets)
internal static void ValidateLimits(uint? initialSequence, int? maxPendingReliablePackets)
{
if (initialSequence == uint.MaxValue)
{
@ -1036,29 +1060,46 @@ namespace GameCult.Networking
var packet = CreatePacket(CultNetRudpPacketType.Connect, "control", payload ?? Array.Empty<byte>(), reliable: true, ordered: true, sequenced: false);
_connectSequence = packet.Sequence;
_awaitingAccept = true;
_connectStartedAtMs = nowMs;
TrackReliable(packet, nowMs);
return packet;
}
/// <summary>
/// Whether <paramref name="packet"/> is a retransmit of the Connect that started this session's
/// current generation: the session is connected and the Connect carries that Connect's sequence.
/// Servers that keep one session per peer ask this to tell a repeat from a new session; anything
/// else that reaches <see cref="AcceptConnect"/> starts a new generation.
/// Whether <paramref name="packet"/> is a Connect this session's current generation already owns:
/// a retransmit of the Connect that started it (the session is connected and the sequence is
/// that Connect's), or a stale copy of an earlier attempt by the same client (a sequence before
/// it, within the receive window). Servers that keep one session per peer ask this to tell a
/// Connect that starts a new session from one that does not; anything else that reaches
/// <see cref="AcceptConnect"/> starts a new generation.
/// </summary>
public bool ConnectRepeats(CultNetRudpPacket packet)
{
if (packet == null) throw new ArgumentNullException(nameof(packet));
return packet.PacketType == CultNetRudpPacketType.Connect
&& _connected
&& _connectSequence == packet.Sequence;
&& _connectSequence.HasValue
&& (_connectSequence.Value == packet.Sequence
|| ConnectIsStale(packet.Sequence, _connectSequence.Value));
}
// A Connect that precedes the current generation's within the receive window is the client's
// earlier attempt, delayed in the network: a client that retried never sends a lower sequence
// again. Restarting on it would strand the client, which honours only the Accept for its newest
// Connect. The rule is TCP's answer to a delayed SYN (RFC 5961's challenge ACK): keep the
// connection and answer with an Ack. A restarted client whose random initial sequence lands in
// this window is answered the same way and abandons the attempt for a fresh draw
// (ConnectAttemptMs).
private static bool ConnectIsStale(uint sequence, uint current) =>
sequence != current && AtOrBefore(sequence, current);
/// <summary>
/// Answers a connect packet. A repeat of the accepted connect queues nothing, so a connect storm
/// cannot grow the reliable queue: the reply is the accept still awaiting acknowledgement, or an
/// ack once it was acknowledged. Any other connect ends the current generation, forgets the peer
/// and accepts a new one with a reliable ordered accept packet.
/// ack once it was acknowledged. A stale copy of an earlier attempt gets the same reply and
/// changes nothing else: it is not evidence the peer is alive. Any other connect ends the
/// current generation, forgets the peer and accepts a new one with a reliable ordered accept
/// packet.
/// </summary>
public CultNetRudpPacket AcceptConnect(CultNetRudpPacket packet, long nowMs = 0, byte[]? payload = null)
{
@ -1069,10 +1110,19 @@ namespace GameCult.Networking
}
if (ConnectRepeats(packet))
return AnswerRepeatedConnect(packet, nowMs);
{
if (_connectSequence == packet.Sequence)
{
ApplyAcknowledgements(packet);
RememberReceived(packet.Sequence);
_lastReceivedAtMs = nowMs;
}
return PendingAcceptForResend(nowMs) ?? CreateAck();
}
ResetPeerState();
EnsureReliableCapacity(1);
SeedReceived(packet.Sequence);
_lastReceivedAtMs = nowMs;
_connectSequence = packet.Sequence;
_connected = true;
_ended = false;
@ -1081,11 +1131,9 @@ namespace GameCult.Networking
return response;
}
private CultNetRudpPacket AnswerRepeatedConnect(CultNetRudpPacket packet, long nowMs)
// The Accept still awaiting acknowledgement, resent.
private CultNetRudpPacket? PendingAcceptForResend(long nowMs)
{
ApplyAcknowledgements(packet);
RememberReceived(packet.Sequence);
_lastReceivedAtMs = nowMs;
lock (_pendingReliableGate)
{
foreach (var pending in _pendingReliable.Values)
@ -1097,7 +1145,7 @@ namespace GameCult.Networking
}
}
return CreateAck();
return null;
}
/// <summary>
@ -1265,11 +1313,7 @@ namespace GameCult.Networking
return new CultNetRudpReceiveResult { ReadyToSend = readyToSend };
}
var duplicate = packet.Reliable
&& (_receivedSequences.Contains(packet.Sequence)
|| (_highestReceivedSequence.HasValue
&& packet.Sequence < _highestReceivedSequence.Value
&& _highestReceivedSequence.Value - packet.Sequence >= ReceivedSequenceWindow));
var duplicate = packet.Reliable && WasReceived(packet.Sequence);
if (packet.Reliable)
{
RememberReceived(packet.Sequence);
@ -1435,6 +1479,9 @@ namespace GameCult.Networking
/// </summary>
public IReadOnlyList<CultNetRudpPacket> DueResends(long nowMs)
{
var fresh = AbandonUnansweredConnect(nowMs);
if (fresh != null)
return new[] { fresh };
var due = new List<CultNetRudpPacket>();
lock (_pendingReliableGate)
{
@ -1613,6 +1660,26 @@ namespace GameCult.Networking
return false;
}
// A Connect unanswered for ConnectAttemptMs is replaced, not retransmitted further, by a Connect
// with a newly drawn sequence and the same payload. Nothing was ever sent in an unanswered
// generation, so no sequence issued so far is owed; the draw stays above them, so a frame the
// peer still remembers from an earlier generation of this session stays at or before the new
// Connect.
private CultNetRudpPacket? AbandonUnansweredConnect(long nowMs)
{
if (!_awaitingAccept || nowMs - _connectStartedAtMs < ConnectAttemptMs)
return null;
byte[] payload;
lock (_pendingReliableGate)
{
if (!_connectSequence.HasValue || !_pendingReliable.TryGetValue(_connectSequence.Value, out var pending))
return null;
payload = pending.Packet.Payload ?? Array.Empty<byte>();
}
_nextSequence = DrawSequence(_nextSequence);
return CreateConnect(nowMs, payload);
}
// The handshake's one act on the watermark: the peer's Connect or Accept is the first sequence
// of the session, whatever else it has sent.
private void SeedReceived(uint sequence)
@ -1621,14 +1688,18 @@ namespace GameCult.Networking
RememberReceived(sequence);
}
// True for a sequence in the window that was received, and for one below the window, which
// Receive treats as a duplicate of something received.
// The one duplicate test: true for a sequence in the window that was received, for one below
// the window, and for one at or before the watermark. The watermark starts at the handshake's
// seed, so a frame the peer sent in an earlier generation (every sequence it issued is below
// the Connect that began this one) is a duplicate of something already delivered and is
// acknowledged, never delivered again.
private bool WasReceived(uint sequence)
{
return _receivedSequences.Contains(sequence)
|| (_highestReceivedSequence.HasValue
&& sequence < _highestReceivedSequence.Value
&& _highestReceivedSequence.Value - sequence >= ReceivedSequenceWindow);
&& _highestReceivedSequence.Value - sequence >= ReceivedSequenceWindow)
|| (_receivedThrough.HasValue && AtOrBefore(sequence, _receivedThrough.Value));
}
private void RememberReceived(uint sequence)
@ -2282,6 +2353,10 @@ namespace GameCult.Networking
_stats.PacketsDropped++;
return true;
}
// A Connect from another endpoint is a new client, whatever sequence it carries: the endpoint
// moves and the Connect starts a new generation. Only a Connect from the peer's own
// endpoint can be its repeat.
var connectFromNewEndpoint = false;
if (_remoteEndPoint == null)
{
// Only a Connect claims the endpoint of a server-mode transport.
@ -2295,11 +2370,10 @@ namespace GameCult.Networking
}
else if (!_remoteEndPoint.Equals(remote))
{
// A Connect from another endpoint claims a server-mode transport for it: the session
// decides whether it starts a new generation.
if (_mode == CultNetRudpSocketMode.Server && packet.PacketType == CultNetRudpPacketType.Connect)
{
_remoteEndPoint = remote;
connectFromNewEndpoint = true;
}
else
{
@ -2318,6 +2392,8 @@ namespace GameCult.Networking
{
// The session answers a repeat of the connect it accepted with the accept
// already owed, and anything else with a new generation.
if (connectFromNewEndpoint)
_session.ResetPeerState();
accept = _session.AcceptConnect(packet, NowMs());
DisconnectReason = null;
}
@ -2485,9 +2561,10 @@ namespace GameCult.Networking
/// </summary>
public uint ConnectionId { get; set; }
/// <summary>
/// Gets or sets the first local packet sequence for each accepted peer session.
/// Gets or sets the first local packet sequence for each accepted peer session. Unset, each
/// session draws its own at random.
/// </summary>
public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
public uint? InitialSequence { get; set; }
/// <summary>
/// Gets or sets the resend delay in milliseconds.
/// </summary>
@ -2579,7 +2656,7 @@ namespace GameCult.Networking
private const int WireBurstPackets = 32;
private readonly Socket _socket;
private readonly uint _connectionId;
private readonly uint _initialSequence;
private readonly uint? _initialSequence;
private readonly long _resendDelayMs;
private readonly int? _maxFragmentBytes;
private readonly int? _maxPendingReliablePackets;

View file

@ -279,22 +279,308 @@ namespace GameCult.Networking.Tests
Assert.That(server.Receive(s2, 4).Delivered, Is.Empty, "the Disconnect forgot what was received");
}
/// <summary>
/// The options say nothing about the initial sequence; each session draws its own, so one options
/// object serves many sessions without giving them one sequence.
/// </summary>
[Test]
public void DefaultInitialSequencesAreDrawnAtRandomFromOneToTwoToTheThirtyFirst()
public void SessionsFromOneOptionsObjectEachDrawTheirOwnInitialSequence()
{
static void AssertRandom(string label, Func<uint> draw)
{
var draws = Enumerable.Range(0, 64).Select(_ => draw()).ToArray();
Assert.That(draws.Distinct().Count(), Is.GreaterThan(32), $"{label} does not draw at random");
Assert.That(draws.All(value => value >= 1 && value < (1u << 31)), Is.True, $"{label} left [1, 2^31)");
}
Assert.That(new CultNetRudpSessionOptions().InitialSequence, Is.Null);
Assert.That(new CultNetRudpSocketTransportOptions().InitialSequence, Is.Null);
Assert.That(new CultNetRudpSocketTransportServerOptions().InitialSequence, Is.Null);
Assert.That(new RudpCultNetSchemaServerOptions().InitialSequence, Is.Null);
Assert.That(new CultMeshRudpSocketOptions().InitialSequence, Is.Null);
AssertRandom("RandomInitialSequence", CultNetRudpSession.RandomInitialSequence);
AssertRandom("session options", () => new CultNetRudpSessionOptions().InitialSequence);
AssertRandom("socket transport options", () => new CultNetRudpSocketTransportOptions().InitialSequence);
AssertRandom("listener options", () => new CultNetRudpSocketTransportServerOptions().InitialSequence);
AssertRandom("schema server options", () => new RudpCultNetSchemaServerOptions().InitialSequence);
AssertRandom("cultmesh options", () => new CultMeshRudpSocketOptions().InitialSequence);
var options = new CultNetRudpSessionOptions { ConnectionId = ConnectionId };
var firsts = Enumerable.Range(0, 64)
.Select(_ => new CultNetRudpSession(options).CreateConnect(0).Sequence)
.ToArray();
Assert.That(firsts.Distinct().Count(), Is.GreaterThan(32), "a default session does not draw its own sequence");
Assert.That(firsts.All(value => value >= 1 && value < (1u << 31)), Is.True, "a draw left [1, 2^31)");
}
/// <summary>
/// A transport built from the same options each time (a reconnect loop's factory) opens each
/// connection with its own Connect sequence.
/// </summary>
[Test]
public void AReusedOptionsObjectYieldsADifferentConnectSequenceForEachTransport()
{
using var listener = Bind();
using var clientSocket = Bind();
var options = new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-client",
Socket = clientSocket,
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = listener.LocalEndPoint,
ConnectionId = ConnectionId
};
var sequences = new HashSet<uint>();
for (var index = 0; index < 8; index++)
{
var client = new CultNetRudpSocketTransportConnection(options);
client.Connect("hello");
sequences.Add(Drain(listener).Last(p => p.PacketType == CultNetRudpPacketType.Connect).Sequence);
}
Assert.That(sequences, Has.Count.EqualTo(8), "two connections opened with one Connect sequence");
}
/// <summary>
/// A listener builds a session for every client it admits; each draws its own sequence, so no two
/// clients are accepted with the same one.
/// </summary>
[Test]
public void TheListenerAcceptsEachClientWithItsOwnSequence()
{
using var serverSocket = Bind();
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-rudp-listener",
Socket = serverSocket,
ConnectionId = ConnectionId
});
var accepts = new HashSet<uint>();
for (var index = 0; index < 8; index++)
{
using var peerSocket = Bind();
peerSocket.SendTo(CultNetRudpPacketCodec.Encode(Session(1).CreateConnect(0)), serverSocket.LocalEndPoint!);
server.ReceiveOnce();
accepts.Add(Drain(peerSocket).First(p => p.PacketType == CultNetRudpPacketType.Accept).Sequence);
}
Assert.That(accepts, Has.Count.EqualTo(8), "two clients were accepted with one sequence");
}
// Ack Cut 1d: a frame from an earlier generation is a duplicate, a delayed Connect from an
// earlier attempt is stale, a Connect from a new endpoint is a new client.
private static bool NamesSequence(CultNetRudpPacket ack, uint sequence) =>
ack.Ack == sequence
|| Enumerable.Range(0, 32).Any(bit => (ack.AckMask & (1u << bit)) != 0 && ack.Ack > bit && ack.Ack - (uint)bit - 1 == sequence);
/// <summary>
/// A frame the client's old generation already delivered, retransmitted after the client
/// reconnected, is acknowledged and not delivered into the new generation.
/// </summary>
[Test]
public void AFrameFromTheClientsEarlierGenerationIsAcknowledgedNotDeliveredAgain()
{
var client = Session(10);
var server = Session(500);
Handshake(client, server);
var old = Send(client, "old");
Assert.That(Names(server.Receive(old, 1)), Is.EqualTo(new[] { "old" }));
var connect = client.CreateConnect(2);
client.Receive(server.AcceptConnect(connect, 2), 2);
Assert.That(old.Sequence, Is.LessThan(connect.Sequence));
Assert.That(server.Receive(old, 3).Delivered, Is.Empty, "an earlier generation's frame was delivered again");
Assert.That(NamesSequence(server.CreateAckForReceived(old.Sequence), old.Sequence), Is.True, "the duplicate was not acknowledged");
Assert.That(Names(server.Receive(Send(client, "fresh"), 4)), Is.EqualTo(new[] { "fresh" }));
}
[Test]
public void AFrameFromTheServersEarlierGenerationIsAcknowledgedNotDeliveredAgain()
{
var client = Session(10);
var server = Session(500);
Handshake(client, server);
var old = Send(server, "old");
Assert.That(Names(client.Receive(old, 1)), Is.EqualTo(new[] { "old" }));
client.Receive(server.AcceptConnect(client.CreateConnect(2), 2), 2);
Assert.That(client.Receive(old, 3).Delivered, Is.Empty, "an earlier generation's frame was delivered again");
Assert.That(NamesSequence(client.CreateAckForReceived(old.Sequence), old.Sequence), Is.True);
Assert.That(Names(client.Receive(Send(server, "fresh"), 4)), Is.EqualTo(new[] { "fresh" }));
}
[Test]
public void ADuplicateBelowTheReceiveWindowIsAcknowledgedByName()
{
var client = Session(10);
var server = Session(500);
Handshake(client, server);
var first = Send(client, "first");
Assert.That(Names(server.Receive(first, 1)), Is.EqualTo(new[] { "first" }));
for (var index = 0; index < 4_200; index++)
{
server.Receive(Send(client, "x"), 1);
client.Receive(server.CreateAck(), 1);
}
Assert.That(server.Receive(first, 2).Delivered, Is.Empty);
Assert.That(server.CreateAckForReceived(first.Sequence).Ack, Is.EqualTo(first.Sequence), "a duplicate below the window was not acknowledged by name");
}
/// <summary>
/// A Connect delayed from an earlier attempt of the client that is now connected does not
/// restart the session: the server answers with an Ack and the client's ordered frames keep
/// flowing.
/// </summary>
[Test]
public void ADelayedConnectFromAnEarlierAttemptDoesNotStrandTheClient()
{
var client = Session(10);
var server = Session(500);
var earlier = client.CreateConnect(0);
var retried = client.CreateConnect(300);
var accept = server.AcceptConnect(retried, 301);
client.Receive(accept, 302);
server.Receive(client.CreateAckForReceived(accept.Sequence), 302);
Assert.That(client.Connected, Is.True);
var a = Send(client, "a");
Assert.That(Names(server.Receive(a, 303)), Is.EqualTo(new[] { "a" }));
client.Receive(server.CreateAckForReceived(a.Sequence), 303);
Assert.That(server.ConnectRepeats(earlier), Is.True, "the delayed Connect starts nothing");
var reply = server.AcceptConnect(earlier, 304);
Assert.That(reply.PacketType, Is.EqualTo(CultNetRudpPacketType.Ack));
client.Receive(reply, 305);
Assert.That(client.Connected, Is.True);
var b = Send(client, "b");
Assert.That(Names(server.Receive(b, 306)), Is.EqualTo(new[] { "b" }), "the delayed Connect reset the server");
client.Receive(server.CreateAckForReceived(b.Sequence), 307);
Assert.That(client.PendingReliableSequences, Does.Not.Contain(b.Sequence));
}
/// <summary>
/// A stale Connect is the peer's echo from the past, not evidence it is alive; a retransmit of
/// the accepted Connect is.
/// </summary>
[Test]
public void ARepeatedConnectRefreshesLivenessAndAStaleOneDoesNot()
{
var client = Session(10);
var server = Session(500);
var earlier = client.CreateConnect(0);
var current = client.CreateConnect(1);
server.AcceptConnect(current, 0);
server.AcceptConnect(current, 900);
Assert.That(server.CheckTimeout(1_000, 500), Is.False, "a repeated Connect did not refresh liveness");
server.AcceptConnect(earlier, 1_400);
Assert.That(server.CheckTimeout(1_600, 500), Is.True, "a stale Connect refreshed liveness");
}
/// <summary>
/// An accepted peer that never speaks again times out: accepting a Connect is the first thing
/// heard from it.
/// </summary>
[Test]
public void AnAcceptedPeerThatGoesSilentTimesOut()
{
var server = Session(500);
server.AcceptConnect(Session(10).CreateConnect(0), 0);
Assert.That(server.CheckTimeout(100_000, 1_000), Is.True);
}
[Test]
public void StaleConnectsAreRecognisedBySerialArithmetic()
{
var server = Session(500);
server.AcceptConnect(Session(3).CreateConnect(0), 0);
CultNetRudpPacket Connect(uint sequence) => Session(sequence).CreateConnect(0);
Assert.That(server.ConnectRepeats(Connect(uint.MaxValue - 1)), Is.True, "just before the wrap");
Assert.That(server.ConnectRepeats(Connect(4)), Is.False, "just after");
Assert.That(server.ConnectRepeats(Connect(3 + 4_096)), Is.False, "far after");
Assert.That(server.ConnectRepeats(Connect(unchecked(3u - 4_095))), Is.True, "the window's edge");
Assert.That(server.ConnectRepeats(Connect(unchecked(3u - 4_096))), Is.False, "past the window");
}
/// <summary>
/// A restarted client whose random first sequence lands just before the server's current
/// Connect is answered with an Ack, never an Accept. It does not retransmit that Connect for
/// ever: once the attempt times out it starts a fresh one with a newly drawn sequence, and is
/// admitted.
/// </summary>
[Test]
public void ARestartedClientWhoseFirstSequenceIsStaleConnectsAfterRedrawing()
{
var server = Session(500);
var old = Session(1_000);
var oldAccept = server.AcceptConnect(old.CreateConnect(0), 0);
old.Receive(oldAccept, 0);
var restarted = Session(900);
var first = restarted.CreateConnect(0, Encoding.UTF8.GetBytes("join"));
// The old client has not acknowledged its Accept yet, so that is the reply: it names the
// old Connect, not this one.
var early = server.AcceptConnect(first, 1);
Assert.That(early.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept));
restarted.Receive(early, 1);
Assert.That(restarted.Connected, Is.False, "an Accept for another Connect connected the client");
server.Receive(old.CreateAckForReceived(oldAccept.Sequence), 1);
var reply = server.AcceptConnect(first, 2);
Assert.That(reply.PacketType, Is.EqualTo(CultNetRudpPacketType.Ack));
restarted.Receive(reply, 2);
Assert.That(restarted.Connected, Is.False);
var retransmitted = restarted.DueResends(1_000);
Assert.That(retransmitted, Has.Count.EqualTo(1));
Assert.That(retransmitted[0].Sequence, Is.EqualTo(first.Sequence), "the attempt is still young");
var fresh = restarted.DueResends(3_500);
Assert.That(fresh, Has.Count.EqualTo(1));
Assert.That(fresh[0].PacketType, Is.EqualTo(CultNetRudpPacketType.Connect));
Assert.That(fresh[0].Sequence, Is.Not.EqualTo(first.Sequence), "the same Connect was retransmitted for ever");
Assert.That(Encoding.UTF8.GetString(fresh[0].Payload), Is.EqualTo("join"), "the fresh attempt lost the Connect payload");
var accept = server.AcceptConnect(fresh[0], 3_500);
Assert.That(accept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept));
restarted.Receive(accept, 3_500);
Assert.That(restarted.Connected, Is.True);
Assert.That(Names(server.Receive(Send(restarted, "hello"), 3_501)), Is.EqualTo(new[] { "hello" }));
}
[Test]
public void AnAnsweredConnectIsNeverReplacedByTheAttemptTimeout()
{
var client = Session(10);
var server = Session(500);
var (connect, _) = Handshake(client, server);
var resent = client.DueResends(60_000);
Assert.That(resent.All(p => p.PacketType != CultNetRudpPacketType.Connect || p.Sequence == connect.Sequence), Is.True, "a connected client started a new attempt");
Assert.That(client.Connected, Is.True);
}
/// <summary>
/// A pinned client that restarts on a new port against a server-mode transport is a new client:
/// the Connect starts a new generation and the client is admitted, though its sequence is the
/// one the old client's Connect carried.
/// </summary>
[Test]
public void ServerModeAdmitsAPinnedClientThatRestartsOnANewPort()
{
using var serverSocket = Bind();
using var firstSocket = Bind();
using var secondSocket = Bind();
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = "csharp-rudp-server",
Socket = serverSocket,
Mode = CultNetRudpSocketMode.Server,
ConnectionId = ConnectionId
});
var serverEndPoint = serverSocket.LocalEndPoint!;
void ToServer(Socket from, CultNetRudpPacket packet) => from.SendTo(CultNetRudpPacketCodec.Encode(packet), serverEndPoint);
var first = Session(1);
ToServer(firstSocket, first.CreateConnect(0, Encoding.UTF8.GetBytes("join")));
server.ReceiveOnce();
first.Receive(Drain(firstSocket).First(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
ToServer(firstSocket, Send(first, "hello"));
Assert.That(Encoding.UTF8.GetString(server.ReceiveOnce()!.Payload), Is.EqualTo("hello"));
var second = Session(1);
ToServer(secondSocket, second.CreateConnect(0, Encoding.UTF8.GetBytes("join")));
server.ReceiveOnce();
var accepts = Drain(secondSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept).ToArray();
Assert.That(accepts, Is.Not.Empty, "the restarted client on a new port was not admitted");
second.Receive(accepts[0], 0);
ToServer(secondSocket, Send(second, "after"));
Assert.That(Encoding.UTF8.GetString(server.ReceiveOnce()!.Payload), Is.EqualTo("after"));
}
// Transports.
@ -315,7 +601,7 @@ namespace GameCult.Networking.Tests
var serverEndPoint = serverSocket.LocalEndPoint!;
void ToServer(Socket from, CultNetRudpPacket packet) => from.SendTo(CultNetRudpPacketCodec.Encode(packet), serverEndPoint);
var first = Session(50);
var first = Session(50_000);
var connect = first.CreateConnect(0, Encoding.UTF8.GetBytes("same"));
ToServer(peerSocket, connect);
server.ReceiveOnce();
@ -367,7 +653,7 @@ namespace GameCult.Networking.Tests
var serverEndPoint = serverSocket.LocalEndPoint!;
void ToServer(CultNetRudpPacket packet) => peerSocket.SendTo(CultNetRudpPacketCodec.Encode(packet), serverEndPoint);
var first = Session(50);
var first = Session(50_000);
ToServer(first.CreateConnect(0, Encoding.UTF8.GetBytes("same")));
server.ReceiveOnce();
first.Receive(Drain(peerSocket).First(p => p.PacketType == CultNetRudpPacketType.Accept), 0);