diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index b5e87044..32fcc2e6 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -543,8 +543,8 @@ namespace GameCult.Mesh
public int BindPort { get; set; }
/// Gets or sets a caller-owned bound socket.
public Socket? Socket { get; set; }
- /// Gets or sets the first local packet sequence.
- public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
+ /// Gets or sets the first local packet sequence. Unset, each session draws its own at random.
+ public uint? InitialSequence { get; set; }
/// Gets or sets the reliable resend delay in milliseconds.
public long ResendDelayMs { get; set; } = 250;
/// Gets or sets the advertised transport id.
diff --git a/src/GameCult.Networking/CultNetRudpSchemaServer.cs b/src/GameCult.Networking/CultNetRudpSchemaServer.cs
index 64e71625..e19fccc3 100644
--- a/src/GameCult.Networking/CultNetRudpSchemaServer.cs
+++ b/src/GameCult.Networking/CultNetRudpSchemaServer.cs
@@ -43,9 +43,10 @@ namespace GameCult.Networking
///
public uint ConnectionId { get; set; } = 0x43554c54;
///
- /// 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.
///
- public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
+ public uint? InitialSequence { get; set; }
///
/// Gets or sets the resend delay in milliseconds.
///
diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs
index 2c704747..187f2a6b 100644
--- a/src/GameCult.Networking/CultNetTransport.cs
+++ b/src/GameCult.Networking/CultNetTransport.cs
@@ -768,9 +768,11 @@ namespace GameCult.Networking
///
public uint ConnectionId { get; set; }
///
- /// 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.
///
- public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
+ public uint? InitialSequence { get; set; }
///
/// Gets or sets the resend delay in milliseconds.
///
@@ -845,9 +847,11 @@ namespace GameCult.Networking
///
public uint ConnectionId { get; set; }
///
- /// 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.
///
- public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
+ public uint? InitialSequence { get; set; }
///
/// Gets or sets the resend delay in milliseconds.
///
@@ -882,6 +886,11 @@ namespace GameCult.Networking
/// Maximum reliable packets admitted to the wire before acknowledgements advance the window.
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 _receivedSequences = new HashSet();
@@ -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;
}
///
- /// 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 [, 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.
///
- 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");
///
/// A session cannot admit a Connect when its sequence space starts exhausted or its reliable
/// queue holds nothing, so neither is a usable configuration.
///
- 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(), reliable: true, ordered: true, sequenced: false);
_connectSequence = packet.Sequence;
_awaitingAccept = true;
+ _connectStartedAtMs = nowMs;
TrackReliable(packet, nowMs);
return packet;
}
///
- /// Whether 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 starts a new generation.
+ /// Whether 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
+ /// starts a new generation.
///
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);
+
///
/// 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.
///
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;
}
///
@@ -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
///
public IReadOnlyList DueResends(long nowMs)
{
+ var fresh = AbandonUnansweredConnect(nowMs);
+ if (fresh != null)
+ return new[] { fresh };
var due = new List();
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();
+ }
+ _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
///
public uint ConnectionId { get; set; }
///
- /// 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.
///
- public uint InitialSequence { get; set; } = CultNetRudpSession.RandomInitialSequence();
+ public uint? InitialSequence { get; set; }
///
/// Gets or sets the resend delay in milliseconds.
///
@@ -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;
diff --git a/tests/GameCult.Networking.Tests/CultNetRudpConnectGenerationTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpConnectGenerationTests.cs
index 63cfea76..1d24d722 100644
--- a/tests/GameCult.Networking.Tests/CultNetRudpConnectGenerationTests.cs
+++ b/tests/GameCult.Networking.Tests/CultNetRudpConnectGenerationTests.cs
@@ -279,22 +279,308 @@ namespace GameCult.Networking.Tests
Assert.That(server.Receive(s2, 4).Delivered, Is.Empty, "the Disconnect forgot what was received");
}
+ ///
+ /// 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.
+ ///
[Test]
- public void DefaultInitialSequencesAreDrawnAtRandomFromOneToTwoToTheThirtyFirst()
+ public void SessionsFromOneOptionsObjectEachDrawTheirOwnInitialSequence()
{
- static void AssertRandom(string label, Func 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)");
+ }
+
+ ///
+ /// A transport built from the same options each time (a reconnect loop's factory) opens each
+ /// connection with its own Connect sequence.
+ ///
+ [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();
+ 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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ 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);
+
+ ///
+ /// A frame the client's old generation already delivered, retransmitted after the client
+ /// reconnected, is acknowledged and not delivered into the new generation.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// A stale Connect is the peer's echo from the past, not evidence it is alive; a retransmit of
+ /// the accepted Connect is.
+ ///
+ [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");
+ }
+
+ ///
+ /// An accepted peer that never speaks again times out: accepting a Connect is the first thing
+ /// heard from it.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);