From 1569f013c6bc1d665f3e0689a1a819236e4b4727 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 09:11:52 +0200 Subject: [PATCH] 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 --- src/GameCult.Mesh/CultMesh.cs | 4 +- .../CultNetRudpSchemaServer.cs | 5 +- src/GameCult.Networking/CultNetTransport.cs | 149 +++++++-- .../CultNetRudpConnectGenerationTests.cs | 316 +++++++++++++++++- 4 files changed, 419 insertions(+), 55 deletions(-) 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);